A simple, clean, fast browser for the AtmosphereConf(2026) VODs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add mobile data saver and tune app-wide haptics

j4ckxyz 20cd1abf 9aadb2e2

+379 -55
+9 -1
src/components/error-panel.tsx
··· 1 1 import { AlertTriangle, RefreshCcw } from 'lucide-react' 2 2 3 3 import { Button } from '@/components/ui/button' 4 + import { hapticTap } from '@/lib/haptics' 4 5 5 6 interface ErrorPanelProps { 6 7 title: string ··· 20 21 </div> 21 22 22 23 {onRetry ? ( 23 - <Button variant="secondary" className="mt-5" onClick={onRetry}> 24 + <Button 25 + variant="secondary" 26 + className="mt-5" 27 + onClick={() => { 28 + hapticTap() 29 + onRetry() 30 + }} 31 + > 24 32 <RefreshCcw className="h-4 w-4" /> 25 33 Try again 26 34 </Button>
+12 -2
src/components/talk-card.tsx
··· 15 15 selected?: boolean 16 16 cardId?: string 17 17 hapticPattern?: 'tap' | 'select' 18 + disableThumbnails?: boolean 18 19 } 19 20 20 21 export function TalkCard({ ··· 23 24 selected = false, 24 25 cardId, 25 26 hapticPattern = 'tap', 27 + disableThumbnails = false, 26 28 }: TalkCardProps) { 27 29 const cardRef = useRef<HTMLAnchorElement | null>(null) 28 30 const [thumbnail, setThumbnail] = useState<string | null>(() => getCachedThumbnail(talk.uri)) 29 31 const [hasEnteredView, setHasEnteredView] = useState<boolean>(false) 30 32 31 33 useEffect(() => { 34 + if (disableThumbnails) { 35 + return 36 + } 37 + 32 38 if (thumbnail || hasEnteredView || !cardRef.current) { 33 39 return 34 40 } ··· 54 60 return () => { 55 61 observer.disconnect() 56 62 } 57 - }, [thumbnail, hasEnteredView]) 63 + }, [thumbnail, hasEnteredView, disableThumbnails]) 58 64 59 65 useEffect(() => { 66 + if (disableThumbnails) { 67 + return 68 + } 69 + 60 70 if (!hasEnteredView || thumbnail) { 61 71 return 62 72 } ··· 73 83 return () => { 74 84 active = false 75 85 } 76 - }, [hasEnteredView, thumbnail, talk.uri]) 86 + }, [hasEnteredView, thumbnail, talk.uri, disableThumbnails]) 77 87 78 88 return ( 79 89 <Link
+62 -1
src/lib/api.ts
··· 72 72 return (await response.json()) as T 73 73 } 74 74 75 + function resolveVariantUrl(masterUrl: string, variantPath: string): string { 76 + try { 77 + return new URL(variantPath, masterUrl).toString() 78 + } catch { 79 + return variantPath 80 + } 81 + } 82 + 83 + function getLowestBandwidthVariantUrl(masterUrl: string, manifestText: string): string | null { 84 + const lines = manifestText 85 + .split(/\r?\n/) 86 + .map((line) => line.trim()) 87 + .filter((line) => line.length > 0) 88 + 89 + let bestBandwidth = Number.POSITIVE_INFINITY 90 + let bestVariant: string | null = null 91 + 92 + for (let index = 0; index < lines.length; index += 1) { 93 + const line = lines[index] 94 + if (!line.startsWith('#EXT-X-STREAM-INF:')) { 95 + continue 96 + } 97 + 98 + const bandwidthMatch = line.match(/(?:^|,)BANDWIDTH=(\d+)/) 99 + const bandwidth = bandwidthMatch ? Number(bandwidthMatch[1]) : Number.POSITIVE_INFINITY 100 + 101 + let nextIndex = index + 1 102 + while (nextIndex < lines.length && lines[nextIndex].startsWith('#')) { 103 + nextIndex += 1 104 + } 105 + 106 + const variantPath = lines[nextIndex] 107 + if (!variantPath) { 108 + continue 109 + } 110 + 111 + if (bandwidth < bestBandwidth) { 112 + bestBandwidth = bandwidth 113 + bestVariant = variantPath 114 + } 115 + } 116 + 117 + if (!bestVariant) { 118 + return null 119 + } 120 + 121 + return resolveVariantUrl(masterUrl, bestVariant) 122 + } 123 + 75 124 async function mapWithConcurrency<T, R>( 76 125 items: T[], 77 126 limit: number, ··· 377 426 return toAppTalkFromRecord(record) 378 427 } 379 428 380 - export async function fetchVideoPlaylist(uri: string): Promise<string> { 429 + export async function fetchVideoPlaylist( 430 + uri: string, 431 + options?: { 432 + dataSaver?: boolean 433 + }, 434 + ): Promise<string> { 381 435 const query = new URLSearchParams({ uri }) 382 436 const playlistUrl = `${VOD_PLAYLIST_ENDPOINT}?${query.toString()}` 383 437 let response: Response ··· 400 454 401 455 if (!text.includes('#EXTM3U')) { 402 456 throw new Error('Playback endpoint did not return an HLS playlist') 457 + } 458 + 459 + if (options?.dataSaver) { 460 + const variantUrl = getLowestBandwidthVariantUrl(playlistUrl, text) 461 + if (variantUrl) { 462 + return variantUrl 463 + } 403 464 } 404 465 405 466 return playlistUrl
+130
src/lib/data-saver.ts
··· 1 + const DATA_SAVER_KEY = 'data-saver-enabled' 2 + const DATA_SAVER_EVENT = 'data-saver-change' 3 + 4 + type NavigatorWithConnection = Navigator & { 5 + connection?: { 6 + saveData?: boolean 7 + } 8 + } 9 + 10 + interface DataSaverChangeDetail { 11 + enabled: boolean 12 + } 13 + 14 + interface DataSaverWindow extends Window { 15 + CustomEvent: typeof CustomEvent 16 + } 17 + 18 + type DataSaverListener = (enabled: boolean) => void 19 + 20 + function canUseDom(): boolean { 21 + return typeof window !== 'undefined' && typeof navigator !== 'undefined' 22 + } 23 + 24 + function isTouchDevice(): boolean { 25 + if (typeof navigator === 'undefined') { 26 + return false 27 + } 28 + 29 + return navigator.maxTouchPoints > 0 30 + } 31 + 32 + function getSystemReducedDataPreference(): boolean { 33 + if (!canUseDom()) { 34 + return false 35 + } 36 + 37 + const saveDataEnabled = Boolean((navigator as NavigatorWithConnection).connection?.saveData) 38 + const reducedDataMedia = 39 + typeof window.matchMedia === 'function' && 40 + window.matchMedia('(prefers-reduced-data: reduce)').matches 41 + 42 + return saveDataEnabled || reducedDataMedia 43 + } 44 + 45 + function getStoredPreference(): boolean | null { 46 + if (!canUseDom()) { 47 + return null 48 + } 49 + 50 + try { 51 + const raw = window.localStorage.getItem(DATA_SAVER_KEY) 52 + if (raw === 'true') { 53 + return true 54 + } 55 + if (raw === 'false') { 56 + return false 57 + } 58 + } catch { 59 + return null 60 + } 61 + 62 + return null 63 + } 64 + 65 + function dispatchDataSaverChange(enabled: boolean) { 66 + if (typeof window === 'undefined') { 67 + return 68 + } 69 + 70 + const safeWindow = window as DataSaverWindow 71 + window.dispatchEvent(new safeWindow.CustomEvent<DataSaverChangeDetail>(DATA_SAVER_EVENT, { detail: { enabled } })) 72 + } 73 + 74 + export function isDataSaverSupported(): boolean { 75 + return isTouchDevice() 76 + } 77 + 78 + export function isDataSaverEnabled(): boolean { 79 + const stored = getStoredPreference() 80 + if (stored !== null) { 81 + return stored 82 + } 83 + 84 + return getSystemReducedDataPreference() 85 + } 86 + 87 + export function setDataSaverEnabled(enabled: boolean) { 88 + if (!canUseDom()) { 89 + return 90 + } 91 + 92 + try { 93 + window.localStorage.setItem(DATA_SAVER_KEY, enabled ? 'true' : 'false') 94 + } catch { 95 + return 96 + } 97 + 98 + dispatchDataSaverChange(enabled) 99 + } 100 + 101 + export function subscribeToDataSaver(listener: DataSaverListener): () => void { 102 + if (typeof window === 'undefined') { 103 + return () => undefined 104 + } 105 + 106 + const onCustomChange = (event: Event) => { 107 + const detail = (event as CustomEvent<DataSaverChangeDetail>).detail 108 + if (detail && typeof detail.enabled === 'boolean') { 109 + listener(detail.enabled) 110 + return 111 + } 112 + 113 + listener(isDataSaverEnabled()) 114 + } 115 + 116 + const onStorage = (event: StorageEvent) => { 117 + if (event.key && event.key !== DATA_SAVER_KEY) { 118 + return 119 + } 120 + listener(isDataSaverEnabled()) 121 + } 122 + 123 + window.addEventListener(DATA_SAVER_EVENT, onCustomChange) 124 + window.addEventListener('storage', onStorage) 125 + 126 + return () => { 127 + window.removeEventListener(DATA_SAVER_EVENT, onCustomChange) 128 + window.removeEventListener('storage', onStorage) 129 + } 130 + }
+8 -8
src/lib/haptics.ts
··· 45 45 46 46 const HAPTICS_DISABLED_KEY = 'haptics-disabled' 47 47 const SEEK_HAPTIC_INTERVAL_MS = 500 48 - const GLOBAL_HAPTIC_INTERVAL_MS = 30 48 + const GLOBAL_HAPTIC_INTERVAL_MS = 24 49 49 const MIN_HAPTIC_MS = 10 50 50 const MAX_HAPTIC_MS = 500 51 51 const IOS_SWITCH_MAX_PULSES = 5 52 52 53 53 const PATTERNS = { 54 - tap: 14, 55 - select: 18, 56 - play: [16, 45, 24], 57 - seek: 12, 58 - success: [14, 65, 22], 59 - error: [26, 40, 26, 40, 26], 60 - back: 14, 54 + tap: 12, 55 + select: 16, 56 + play: [12, 36, 18], 57 + seek: 10, 58 + success: [10, 48, 20], 59 + error: [22, 34, 22, 34, 22], 60 + back: 12, 61 61 } as const 62 62 63 63 let lastSeekHapticAt = 0
+2 -12
src/lib/thumbnails.ts
··· 1 1 import { fetchVideoPlaylist } from './api' 2 + import { isDataSaverEnabled } from './data-saver' 2 3 3 4 const THUMBNAIL_KEY_PREFIX = 'thumb:' 4 5 const THUMBNAIL_QUALITY = 0.6 ··· 14 15 15 16 type IdleWindow = Window & { 16 17 requestIdleCallback?: (callback: IdleCallback, options?: { timeout: number }) => number 17 - } 18 - 19 - type NavigatorWithConnection = Navigator & { 20 - connection?: { 21 - saveData?: boolean 22 - } 23 18 } 24 19 25 20 const inflightExtractions = new Map<string, Promise<ThumbnailResult>>() ··· 39 34 return false 40 35 } 41 36 42 - const saveDataEnabled = Boolean((navigator as NavigatorWithConnection).connection?.saveData) 43 - const reducedDataMedia = 44 - typeof window.matchMedia === 'function' && 45 - window.matchMedia('(prefers-reduced-data: reduce)').matches 46 - 47 - return saveDataEnabled || reducedDataMedia 37 + return isDataSaverEnabled() 48 38 } 49 39 50 40 function runWhenBrowserIdle(task: () => Promise<ThumbnailResult>): Promise<ThumbnailResult> {
+30
src/lib/use-data-saver.ts
··· 1 + import { useCallback, useEffect, useState } from 'react' 2 + 3 + import { 4 + isDataSaverEnabled, 5 + isDataSaverSupported, 6 + setDataSaverEnabled, 7 + subscribeToDataSaver, 8 + } from '@/lib/data-saver' 9 + 10 + export function useDataSaver() { 11 + const [enabled, setEnabled] = useState<boolean>(() => isDataSaverEnabled()) 12 + const [supported] = useState<boolean>(() => isDataSaverSupported()) 13 + 14 + useEffect(() => { 15 + return subscribeToDataSaver((nextEnabled) => { 16 + setEnabled(nextEnabled) 17 + }) 18 + }, []) 19 + 20 + const updateEnabled = useCallback((nextEnabled: boolean) => { 21 + setDataSaverEnabled(nextEnabled) 22 + setEnabled(nextEnabled) 23 + }, []) 24 + 25 + return { 26 + enabled, 27 + supported, 28 + setEnabled: updateEnabled, 29 + } 30 + }
+36
src/pages/about-page.tsx
··· 6 6 type ThemePreference, 7 7 } from '@/lib/theme' 8 8 import { 9 + hapticSelect, 9 10 hapticTap, 10 11 isHapticsSupported, 11 12 isHapticsDisabledByUser, 12 13 setHapticsDisabled, 13 14 } from '@/lib/haptics' 15 + import { useDataSaver } from '@/lib/use-data-saver' 14 16 15 17 export function AboutPage() { 16 18 const [themePreference, setLocalThemePreference] = useState<ThemePreference>(() => getStoredThemePreference()) 17 19 const [showHapticsToggle] = useState<boolean>(() => isHapticsSupported()) 18 20 const [hapticsDisabled, setLocalHapticsDisabled] = useState<boolean>(() => isHapticsDisabledByUser()) 21 + const { enabled: dataSaverEnabled, supported: dataSaverSupported, setEnabled: setDataSaverEnabled } = useDataSaver() 19 22 20 23 const onThemeChange = (value: ThemePreference) => { 21 24 setLocalThemePreference(value) ··· 30 33 if (!nextDisabled) { 31 34 hapticTap() 32 35 } 36 + } 37 + 38 + const onDataSaverToggle = () => { 39 + const nextEnabled = !dataSaverEnabled 40 + setDataSaverEnabled(nextEnabled) 41 + if (nextEnabled) { 42 + hapticSelect() 43 + return 44 + } 45 + hapticTap() 33 46 } 34 47 35 48 return ( ··· 127 140 /> 128 141 </button> 129 142 <span className="text-sm text-muted">{!hapticsDisabled ? 'Enabled' : 'Disabled'}</span> 143 + </div> 144 + </section> 145 + ) : null} 146 + 147 + {dataSaverSupported ? ( 148 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 149 + <h2 className="text-base font-semibold text-text">Mobile data saver</h2> 150 + <p className="mt-2 text-sm text-muted"> 151 + Reduce network usage on mobile by disabling thumbnail extraction and preferring lower-bandwidth streams. 152 + </p> 153 + <div className="mt-3 flex items-center gap-3"> 154 + <button 155 + type="button" 156 + role="switch" 157 + aria-checked={dataSaverEnabled} 158 + onClick={onDataSaverToggle} 159 + className="inline-flex h-11 w-[3.25rem] items-center rounded-full border border-line/45 bg-surface/80 px-1 transition" 160 + > 161 + <span 162 + className={`h-5 w-5 rounded-full bg-text transition-transform ${dataSaverEnabled ? 'translate-x-5' : 'translate-x-0'}`} 163 + /> 164 + </button> 165 + <span className="text-sm text-muted">{dataSaverEnabled ? 'Enabled' : 'Disabled'}</span> 130 166 </div> 131 167 </section> 132 168 ) : null}
+30 -15
src/pages/atmosphereconf-page.tsx
··· 5 5 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 6 6 import { isAtmosphereTalk } from '@/lib/api' 7 7 import { formatDateTime } from '@/lib/format' 8 + import { hapticSelect, hapticTap } from '@/lib/haptics' 8 9 import { fetchAtmosphereIonosphereEnrichment } from '@/lib/ionosphere' 9 10 import type { IonosphereEnrichmentResult } from '@/lib/types' 10 11 import { searchTalkUris } from '@/lib/semantic-search' 12 + import { useDataSaver } from '@/lib/use-data-saver' 11 13 import { useVideos } from '@/state/videos-context' 12 14 13 15 export function AtmosphereConfPage() { 16 + const { enabled: dataSaverEnabled } = useDataSaver() 14 17 const { talks, loading, error, refresh } = useVideos() 15 18 const filteredTalks = talks.filter((talk) => isAtmosphereTalk(talk)) 16 19 const [enrichment, setEnrichment] = useState<IonosphereEnrichmentResult>({ ··· 127 130 128 131 {enrichment.allTopics.length > 0 ? ( 129 132 <div className="flex flex-wrap gap-2 pt-2"> 130 - <button 131 - type="button" 132 - onClick={() => setSelectedTopic('')} 133 - className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 134 - > 135 - All topics 136 - </button> 137 - {enrichment.allTopics.slice(0, 20).map((topic) => ( 138 133 <button 139 - key={topic} 140 134 type="button" 141 - onClick={() => setSelectedTopic(topic)} 135 + onClick={() => { 136 + hapticTap() 137 + setSelectedTopic('') 138 + }} 142 139 className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 143 140 > 144 - {topic} 141 + All topics 142 + </button> 143 + {enrichment.allTopics.slice(0, 20).map((topic) => ( 144 + <button 145 + key={topic} 146 + type="button" 147 + onClick={() => { 148 + hapticSelect() 149 + setSelectedTopic(topic) 150 + }} 151 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 152 + > 153 + {topic} 145 154 </button> 146 155 ))} 147 156 </div> ··· 168 177 {featuredTalk ? ( 169 178 <section className="space-y-3 md:space-y-4"> 170 179 <h2 className="text-sm font-medium text-muted">Latest Upload</h2> 171 - <TalkCard talk={featuredTalk} featured /> 180 + <TalkCard talk={featuredTalk} featured disableThumbnails={dataSaverEnabled} /> 172 181 {enrichment.byVodUri.get(featuredTalk.uri) ? ( 173 182 <article className="rounded-lg border border-line/45 bg-surface/80 p-4 text-sm text-muted"> 174 183 <p> ··· 194 203 <button 195 204 key={topic} 196 205 type="button" 197 - onClick={() => setSelectedTopic(topic)} 206 + onClick={() => { 207 + hapticSelect() 208 + setSelectedTopic(topic) 209 + }} 198 210 className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/70 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 199 211 > 200 212 {topic} ··· 227 239 <div className="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-3 md:grid-cols-[repeat(auto-fit,minmax(280px,1fr))] md:gap-4"> 228 240 {remainingTalks.map((talk) => ( 229 241 <div key={talk.uri} className="space-y-2"> 230 - <TalkCard talk={talk} /> 242 + <TalkCard talk={talk} disableThumbnails={dataSaverEnabled} /> 231 243 {enrichment.byVodUri.get(talk.uri) ? ( 232 244 <article className="rounded-lg border border-line/45 bg-surface/80 p-3 text-xs text-muted"> 233 245 <p> ··· 241 253 <button 242 254 key={topic} 243 255 type="button" 244 - onClick={() => setSelectedTopic(topic)} 256 + onClick={() => { 257 + hapticSelect() 258 + setSelectedTopic(topic) 259 + }} 245 260 className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/70 px-2.5 text-[11px] text-muted transition hover:border-line/60 hover:text-text" 246 261 > 247 262 {topic}
+10 -6
src/pages/browse-page.tsx
··· 6 6 import { ErrorPanel } from '@/components/error-panel' 7 7 import { isAtmosphereTalk } from '@/lib/api' 8 8 import { hapticTap } from '@/lib/haptics' 9 + import { useDataSaver } from '@/lib/use-data-saver' 9 10 import { useKeyboard } from '@/lib/use-keyboard' 10 11 import { useVideos } from '@/state/videos-context' 11 12 12 13 export function BrowsePage() { 13 14 const navigate = useNavigate() 15 + const { enabled: dataSaverEnabled } = useDataSaver() 14 16 const { talks, loading, error, refresh } = useVideos() 15 17 const [featuredTalk, ...remainingTalks] = talks 16 18 const sourceRepos = Array.from(new Set(talks.map((talk) => talk.sourceRepoDid))).sort((a, b) => ··· 113 115 featured 114 116 selected={selectedIndex === 0} 115 117 cardId={`talk-card-${encodeURIComponent(featuredTalk.uri)}`} 118 + disableThumbnails={dataSaverEnabled} 116 119 /> 117 120 </section> 118 121 ) : null} ··· 122 125 <h2 className="text-sm font-medium text-muted">More Videos</h2> 123 126 <div className="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-3 md:grid-cols-[repeat(auto-fit,minmax(280px,1fr))] md:gap-4"> 124 127 {remainingTalks.map((talk, index) => ( 125 - <TalkCard 126 - key={talk.uri} 127 - talk={talk} 128 - selected={selectedIndex === index + 1} 129 - cardId={`talk-card-${encodeURIComponent(talk.uri)}`} 130 - /> 128 + <TalkCard 129 + key={talk.uri} 130 + talk={talk} 131 + selected={selectedIndex === index + 1} 132 + cardId={`talk-card-${encodeURIComponent(talk.uri)}`} 133 + disableThumbnails={dataSaverEnabled} 134 + /> 131 135 ))} 132 136 </div> 133 137 </section>
+7 -1
src/pages/search-page.tsx
··· 6 6 import { TalkCard } from '@/components/talk-card' 7 7 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 8 8 import { isAtmosphereTalk } from '@/lib/api' 9 - import { hapticTap } from '@/lib/haptics' 9 + import { hapticSelect, hapticTap } from '@/lib/haptics' 10 10 import { fetchAtmosphereIonosphereEnrichment } from '@/lib/ionosphere' 11 11 import { searchTalkUris } from '@/lib/semantic-search' 12 + import { useDataSaver } from '@/lib/use-data-saver' 12 13 import { toTagPath } from '@/lib/routes' 13 14 import { getTalkTaxonomyTokens, scoreTalkForQuery } from '@/lib/taxonomy' 14 15 import type { IonosphereEnrichmentResult } from '@/lib/types' ··· 16 17 import { useVideos } from '@/state/videos-context' 17 18 18 19 export function SearchPage() { 20 + const { enabled: dataSaverEnabled } = useDataSaver() 19 21 const [searchParams] = useSearchParams() 20 22 const [query, setQuery] = useState<string>('') 21 23 const [remoteQuery, setRemoteQuery] = useState<string>('') ··· 322 324 <Link 323 325 key={token} 324 326 to={toTagPath(token)} 327 + onClick={() => { 328 + hapticSelect() 329 + }} 325 330 className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted transition hover:border-line/60 hover:text-text" 326 331 > 327 332 #{token} ··· 370 375 selected={selectedTalkIndex === index} 371 376 cardId={`talk-card-${encodeURIComponent(talk.uri)}`} 372 377 hapticPattern="select" 378 + disableThumbnails={dataSaverEnabled} 373 379 /> 374 380 ))} 375 381 </div>
+16 -2
src/pages/tag-page.tsx
··· 3 3 import { ErrorPanel } from '@/components/error-panel' 4 4 import { TalkCard } from '@/components/talk-card' 5 5 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 6 + import { hapticBack } from '@/lib/haptics' 6 7 import { fromTagParam } from '@/lib/routes' 7 8 import { matchesTagRoute, normalizeSearchValue } from '@/lib/taxonomy' 9 + import { useDataSaver } from '@/lib/use-data-saver' 8 10 import { useVideos } from '@/state/videos-context' 9 11 10 12 export function TagPage() { 13 + const { enabled: dataSaverEnabled } = useDataSaver() 11 14 const { tagParam } = useParams<{ tagParam: string }>() 12 15 const { talks, loading, error, refresh } = useVideos() 13 16 ··· 29 32 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 30 33 <header className="space-y-2"> 31 34 <p className="text-sm text-muted"> 32 - <Link to="/search" className="underline-offset-4 hover:text-text hover:underline"> 35 + <Link 36 + to="/search" 37 + onClick={() => { 38 + hapticBack() 39 + }} 40 + className="underline-offset-4 hover:text-text hover:underline" 41 + > 33 42 Search 34 43 </Link>{' '} 35 44 / #{normalizedTag} ··· 68 77 </p> 69 78 <div className="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-3 md:grid-cols-[repeat(auto-fit,minmax(280px,1fr))] md:gap-4"> 70 79 {filteredTalks.map((talk, index) => ( 71 - <TalkCard key={talk.uri} talk={talk} featured={index === 0} /> 80 + <TalkCard 81 + key={talk.uri} 82 + talk={talk} 83 + featured={index === 0} 84 + disableThumbnails={dataSaverEnabled} 85 + /> 72 86 ))} 73 87 </div> 74 88 </section>
+27 -7
src/pages/video-page.tsx
··· 25 25 import { fetchAtmosphereIonosphereEnrichment } from '@/lib/ionosphere' 26 26 import { toTagPath, toVideoUriFromParams } from '@/lib/routes' 27 27 import { getTalkTaxonomyTokens } from '@/lib/taxonomy' 28 + import { useDataSaver } from '@/lib/use-data-saver' 28 29 import { useKeyboard } from '@/lib/use-keyboard' 29 30 import type { AppTalk, IonosphereEnrichment } from '@/lib/types' 30 31 import { useVideos } from '@/state/videos-context' ··· 39 40 40 41 export function VideoPage() { 41 42 const navigate = useNavigate() 43 + const { enabled: dataSaverEnabled } = useDataSaver() 42 44 const { didParam, rkeyParam } = useParams<{ didParam: string; rkeyParam: string }>() 43 45 const { talks, loading: talksLoading } = useVideos() 44 46 ··· 142 144 143 145 async function load() { 144 146 try { 145 - const playlistUrl = await fetchVideoPlaylist(uri) 147 + const playlistUrl = await fetchVideoPlaylist(uri, { dataSaver: dataSaverEnabled }) 146 148 if (cancelled) { 147 149 return 148 150 } ··· 158 160 } 159 161 160 162 if (Hls.isSupported()) { 161 - const hls = new Hls({ 162 - maxBufferLength: 30, 163 - lowLatencyMode: true, 164 - }) 163 + const hls = new Hls( 164 + dataSaverEnabled 165 + ? { 166 + maxBufferLength: 12, 167 + maxMaxBufferLength: 20, 168 + lowLatencyMode: false, 169 + capLevelToPlayerSize: true, 170 + startLevel: 0, 171 + abrEwmaFastLive: 2, 172 + abrEwmaSlowLive: 8, 173 + } 174 + : { 175 + maxBufferLength: 30, 176 + lowLatencyMode: true, 177 + }, 178 + ) 165 179 hls.loadSource(playlistUrl) 166 180 hls.attachMedia(video) 167 181 hlsRef.current = hls ··· 173 187 } 174 188 175 189 video.muted = false 176 - video.volume = Math.max(video.volume || 1, 0.75) 190 + video.volume = dataSaverEnabled ? Math.max(video.volume || 1, 0.6) : Math.max(video.volume || 1, 0.75) 177 191 178 192 setPlaylistUrl(playlistUrl) 179 193 setError(null) ··· 206 220 video.load() 207 221 setPlaylistUrl(null) 208 222 } 209 - }, [resolvedUri, reloadToken, videoElement]) 223 + }, [resolvedUri, reloadToken, videoElement, dataSaverEnabled]) 210 224 211 225 useEffect(() => { 212 226 if (!talk || !isAtmosphereTalk(talk)) { ··· 439 453 onRetry={onRetryPlayback} 440 454 /> 441 455 </div> 456 + ) : null} 457 + 458 + {dataSaverEnabled ? ( 459 + <p className="text-xs text-muted" role="status"> 460 + Data saver is on. Playback prefers lower-bandwidth variants. 461 + </p> 442 462 ) : null} 443 463 444 464 {status !== 'error' && error ? (