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: haptic feedback system with mobile detection and settings toggle (AI-assisted)

jack 54cbed63 05426939

+545 -86
+28 -2
functions/api/search.ts
··· 46 46 } | null = null 47 47 const INDEX_TTL_MS = 5 * 60 * 1000 48 48 const LIVE_CATALOG_TTL_MS = 2 * 60 * 1000 49 + const EMBEDDING_DIMENSIONS = 4096 49 50 50 51 function normalizeVector(vector: number[]): number { 51 52 let sum = 0 ··· 267 268 }) 268 269 269 270 if (remoteResponse.ok) { 270 - index = (await remoteResponse.json()) as EmbeddingIndex 271 + index = validateEmbeddingIndex((await remoteResponse.json()) as EmbeddingIndex) 271 272 } 272 273 } catch { 273 274 index = null ··· 293 294 throw new Error(`Embeddings asset unavailable (${response.status})`) 294 295 } 295 296 296 - index = (await response.json()) as EmbeddingIndex 297 + index = validateEmbeddingIndex((await response.json()) as EmbeddingIndex) 297 298 } 298 299 299 300 const norms = (index.entries ?? []).map((entry) => normalizeVector(entry.embedding ?? [])) ··· 381 382 normByUri.set(entries[i].uri, norms[i] ?? 0) 382 383 } 383 384 return normByUri 385 + } 386 + 387 + function validateEmbeddingIndex(index: EmbeddingIndex): EmbeddingIndex { 388 + if (!Array.isArray(index.entries)) { 389 + throw new Error('Embedding index entries missing') 390 + } 391 + 392 + const validEntries = index.entries.filter((entry) => { 393 + if (!entry?.uri || !Array.isArray(entry.embedding)) { 394 + return false 395 + } 396 + if (entry.embedding.length !== EMBEDDING_DIMENSIONS) { 397 + return false 398 + } 399 + return entry.embedding.every((value) => Number.isFinite(value)) 400 + }) 401 + 402 + if (validEntries.length === 0) { 403 + throw new Error('No valid embeddings in index') 404 + } 405 + 406 + return { 407 + ...index, 408 + entries: validEntries, 409 + } 384 410 } 385 411 386 412 function jsonResponse(body: unknown, init?: ResponseInit): Response {
+8
src/components/layout/app-shell.tsx
··· 2 2 import { NavLink, type NavLinkProps } from 'react-router-dom' 3 3 import { type PropsWithChildren, useEffect, useRef, useState } from 'react' 4 4 5 + import { hapticTap } from '@/lib/haptics' 5 6 import { cn } from '@/lib/utils' 6 7 7 8 const navItems = [ ··· 32 33 return ( 33 34 <NavLink 34 35 {...props} 36 + onClick={(event) => { 37 + props.onClick?.(event) 38 + if (event.defaultPrevented) { 39 + return 40 + } 41 + hapticTap() 42 + }} 35 43 className={({ isActive }) => 36 44 cn( 37 45 'flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-[background-color,color,border-color] md:justify-start md:px-3.5',
+18 -5
src/components/talk-card.tsx
··· 2 2 import { useEffect, useRef, useState } from 'react' 3 3 import { Link } from 'react-router-dom' 4 4 5 - import { cardTapHaptic } from '@/lib/haptics' 5 + import { cardTapHaptic, hapticSelect } from '@/lib/haptics' 6 6 import { formatDate, formatDuration, truncateDid } from '@/lib/format' 7 7 import { toVideoPath } from '@/lib/routes' 8 8 import { getCachedThumbnail, getOrCreateThumbnail } from '@/lib/thumbnails' ··· 14 14 featured?: boolean 15 15 selected?: boolean 16 16 cardId?: string 17 + hapticPattern?: 'tap' | 'select' 17 18 } 18 19 19 - export function TalkCard({ talk, featured = false, selected = false, cardId }: TalkCardProps) { 20 + export function TalkCard({ 21 + talk, 22 + featured = false, 23 + selected = false, 24 + cardId, 25 + hapticPattern = 'tap', 26 + }: TalkCardProps) { 20 27 const cardRef = useRef<HTMLAnchorElement | null>(null) 21 28 const [thumbnail, setThumbnail] = useState<string | null>(() => getCachedThumbnail(talk.uri)) 22 29 const [hasEnteredView, setHasEnteredView] = useState<boolean>(false) ··· 73 80 id={cardId} 74 81 ref={cardRef} 75 82 to={toVideoPath(talk.uri)} 76 - onClick={cardTapHaptic} 83 + onClick={() => { 84 + if (hapticPattern === 'select') { 85 + hapticSelect() 86 + return 87 + } 88 + cardTapHaptic() 89 + }} 77 90 className={cn( 78 91 'group relative block w-full overflow-hidden rounded-xl border transition-[background-color,border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text/35', 79 92 featured ··· 99 112 style={{ 100 113 backgroundImage: 101 114 featured 102 - ? 'linear-gradient(165deg, oklch(0 0 0 / 0.8), oklch(0.12 0 0 / 0.58))' 103 - : 'linear-gradient(165deg, oklch(0 0 0 / 0.9), oklch(0.12 0 0 / 0.72))', 115 + ? 'linear-gradient(165deg, oklch(var(--bg) / 0.8), oklch(var(--surface) / 0.58))' 116 + : 'linear-gradient(165deg, oklch(var(--bg) / 0.9), oklch(var(--surface) / 0.72))', 104 117 }} 105 118 /> 106 119 </div>
+30 -2
src/index.css
··· 9 9 text-rendering: optimizeLegibility; 10 10 -webkit-font-smoothing: antialiased; 11 11 -moz-osx-font-smoothing: grayscale; 12 + --bg: 0 0 0; 13 + --surface: 0.12 0 0; 14 + --line: 0.34 0 0; 15 + --text: 0.95 0 0; 16 + --muted: 0.72 0 0; 17 + --accent: 0.75 0.11 190; 18 + } 19 + 20 + :root[data-theme='light'] { 21 + color-scheme: light; 22 + --bg: 0.98 0 0; 23 + --surface: 0.95 0 0; 24 + --line: 0.78 0 0; 25 + --text: 0.2 0 0; 26 + --muted: 0.45 0 0; 27 + --accent: 0.62 0.1 190; 28 + } 29 + 30 + @media (prefers-color-scheme: light) { 31 + :root:not([data-theme]) { 32 + color-scheme: light; 33 + --bg: 0.98 0 0; 34 + --surface: 0.95 0 0; 35 + --line: 0.78 0 0; 36 + --text: 0.2 0 0; 37 + --muted: 0.45 0 0; 38 + --accent: 0.62 0.1 190; 39 + } 12 40 } 13 41 14 42 * { ··· 18 46 body { 19 47 margin: 0; 20 48 min-height: 100svh; 21 - color: oklch(0.95 0 0); 22 - background: oklch(0 0 0); 49 + color: oklch(var(--text)); 50 + background: oklch(var(--bg)); 23 51 } 24 52 25 53 a {
+127 -7
src/lib/haptics.ts
··· 1 - export function vibrate(pattern: number | number[]) { 2 - if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') { 3 - navigator.vibrate(pattern) 1 + type HapticPattern = number | ReadonlyArray<number> 2 + 3 + const HAPTICS_DISABLED_KEY = 'haptics-disabled' 4 + const SEEK_HAPTIC_INTERVAL_MS = 500 5 + 6 + const PATTERNS = { 7 + tap: 8, 8 + select: 12, 9 + play: [10, 50, 20], 10 + seek: 6, 11 + success: [10, 80, 15], 12 + error: [30, 50, 30, 50, 30], 13 + back: 8, 14 + } as const 15 + 16 + let lastSeekHapticAt = 0 17 + 18 + function supportsTouchMobile(): boolean { 19 + if (typeof window === 'undefined' || typeof navigator === 'undefined') { 20 + return false 21 + } 22 + 23 + return navigator.maxTouchPoints > 0 && window.matchMedia('(hover: none)').matches 24 + } 25 + 26 + function hapticsDisabledByUser(): boolean { 27 + if (typeof window === 'undefined') { 28 + return false 29 + } 30 + 31 + return window.localStorage.getItem(HAPTICS_DISABLED_KEY) === 'true' 32 + } 33 + 34 + function prefersReducedMotion(): boolean { 35 + if (typeof window === 'undefined') { 36 + return false 4 37 } 38 + 39 + return window.matchMedia('(prefers-reduced-motion: reduce)').matches 5 40 } 6 41 7 - export function cardTapHaptic() { 8 - vibrate(10) 42 + function canVibrate(): boolean { 43 + if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') { 44 + return false 45 + } 46 + 47 + if (!supportsTouchMobile()) { 48 + return false 49 + } 50 + 51 + if (hapticsDisabledByUser()) { 52 + return false 53 + } 54 + 55 + if (prefersReducedMotion()) { 56 + return false 57 + } 58 + 59 + return true 9 60 } 10 61 11 - export function playHaptic() { 12 - vibrate([10, 20, 20]) 62 + function vibrate(pattern: HapticPattern) { 63 + if (!canVibrate()) { 64 + return 65 + } 66 + 67 + const normalizedPattern: number | number[] = 68 + typeof pattern === 'number' ? pattern : [...pattern] 69 + navigator.vibrate(normalizedPattern) 70 + } 71 + 72 + export function isTouchDevice(): boolean { 73 + if (typeof navigator === 'undefined') { 74 + return false 75 + } 76 + 77 + return navigator.maxTouchPoints > 0 78 + } 79 + 80 + export function isHapticsDisabledByUser(): boolean { 81 + return hapticsDisabledByUser() 82 + } 83 + 84 + export function setHapticsDisabled(disabled: boolean) { 85 + if (typeof window === 'undefined') { 86 + return 87 + } 88 + 89 + if (disabled) { 90 + window.localStorage.setItem(HAPTICS_DISABLED_KEY, 'true') 91 + return 92 + } 93 + 94 + window.localStorage.removeItem(HAPTICS_DISABLED_KEY) 95 + } 96 + 97 + export function hapticTap() { 98 + vibrate(PATTERNS.tap) 99 + } 100 + 101 + export function hapticSelect() { 102 + vibrate(PATTERNS.select) 103 + } 104 + 105 + export function hapticPlay() { 106 + vibrate(PATTERNS.play) 107 + } 108 + 109 + export function hapticSeek() { 110 + const now = Date.now() 111 + if (now - lastSeekHapticAt < SEEK_HAPTIC_INTERVAL_MS) { 112 + return 113 + } 114 + 115 + lastSeekHapticAt = now 116 + vibrate(PATTERNS.seek) 117 + } 118 + 119 + export function hapticSuccess() { 120 + vibrate(PATTERNS.success) 121 + } 122 + 123 + export function hapticError() { 124 + vibrate(PATTERNS.error) 125 + } 126 + 127 + export function hapticBack() { 128 + vibrate(PATTERNS.back) 129 + } 130 + 131 + export function cardTapHaptic() { 132 + hapticTap() 13 133 }
+40
src/lib/theme.ts
··· 1 + export type ThemePreference = 'system' | 'light' | 'dark' 2 + 3 + const THEME_STORAGE_KEY = 'theme-preference' 4 + 5 + function isThemePreference(value: string | null): value is ThemePreference { 6 + return value === 'system' || value === 'light' || value === 'dark' 7 + } 8 + 9 + export function getStoredThemePreference(): ThemePreference { 10 + if (typeof window === 'undefined') { 11 + return 'system' 12 + } 13 + 14 + const value = window.localStorage.getItem(THEME_STORAGE_KEY) 15 + return isThemePreference(value) ? value : 'system' 16 + } 17 + 18 + export function applyThemePreference(preference: ThemePreference) { 19 + if (typeof document === 'undefined') { 20 + return 21 + } 22 + 23 + if (preference === 'system') { 24 + document.documentElement.removeAttribute('data-theme') 25 + return 26 + } 27 + 28 + document.documentElement.setAttribute('data-theme', preference) 29 + } 30 + 31 + export function setThemePreference(preference: ThemePreference) { 32 + if (typeof window !== 'undefined') { 33 + window.localStorage.setItem(THEME_STORAGE_KEY, preference) 34 + } 35 + applyThemePreference(preference) 36 + } 37 + 38 + export function initializeTheme() { 39 + applyThemePreference(getStoredThemePreference()) 40 + }
+9 -3
src/lib/use-keyboard.ts
··· 1 - import { useEffect } from 'react' 1 + import { useEffect, useRef } from 'react' 2 2 3 3 interface UseKeyboardOptions { 4 4 enabled?: boolean ··· 22 22 onKeyDown: (event: KeyboardEvent) => void, 23 23 options?: UseKeyboardOptions, 24 24 ) { 25 + const callbackRef = useRef(onKeyDown) 26 + 25 27 const enabled = options?.enabled ?? true 26 28 const allowInInputs = options?.allowInInputs ?? false 27 29 28 30 useEffect(() => { 31 + callbackRef.current = onKeyDown 32 + }, [onKeyDown]) 33 + 34 + useEffect(() => { 29 35 if (!enabled) { 30 36 return 31 37 } ··· 35 41 return 36 42 } 37 43 38 - onKeyDown(event) 44 + callbackRef.current(event) 39 45 } 40 46 41 47 window.addEventListener('keydown', handleKeyDown) 42 48 return () => { 43 49 window.removeEventListener('keydown', handleKeyDown) 44 50 } 45 - }, [onKeyDown, enabled, allowInInputs]) 51 + }, [enabled, allowInInputs]) 46 52 }
+3
src/main.tsx
··· 4 4 import { registerSW } from 'virtual:pwa-register' 5 5 import './index.css' 6 6 import App from './App.tsx' 7 + import { initializeTheme } from './lib/theme.ts' 7 8 import { VideosProvider } from './state/videos-context.tsx' 9 + 10 + initializeTheme() 8 11 9 12 registerSW({ 10 13 immediate: true,
+80
src/pages/about-page.tsx
··· 1 + import { useState } from 'react' 2 + 3 + import { 4 + getStoredThemePreference, 5 + setThemePreference, 6 + type ThemePreference, 7 + } from '@/lib/theme' 8 + import { 9 + hapticTap, 10 + isHapticsDisabledByUser, 11 + isTouchDevice, 12 + setHapticsDisabled, 13 + } from '@/lib/haptics' 14 + 1 15 export function AboutPage() { 16 + const [themePreference, setLocalThemePreference] = useState<ThemePreference>(() => getStoredThemePreference()) 17 + const [showHapticsToggle] = useState<boolean>(() => isTouchDevice()) 18 + const [hapticsDisabled, setLocalHapticsDisabled] = useState<boolean>(() => isHapticsDisabledByUser()) 19 + 20 + const onThemeChange = (value: ThemePreference) => { 21 + setLocalThemePreference(value) 22 + setThemePreference(value) 23 + hapticTap() 24 + } 25 + 26 + const onHapticsToggle = () => { 27 + const nextDisabled = !hapticsDisabled 28 + setLocalHapticsDisabled(nextDisabled) 29 + setHapticsDisabled(nextDisabled) 30 + } 31 + 2 32 return ( 3 33 <div className="space-y-4"> 4 34 <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> ··· 30 60 <li>Video: <code>&lt;</code>/<code>&gt;</code> adjust speed by 0.25x, <code>Esc</code> back to browse.</li> 31 61 </ul> 32 62 </section> 63 + 64 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 65 + <h2 className="text-base font-semibold text-text">Theme</h2> 66 + <p className="mt-2 text-sm text-muted">Preference is saved in this browser.</p> 67 + <fieldset className="mt-3" aria-label="Theme preference"> 68 + <legend className="sr-only">Theme preference</legend> 69 + <div className="flex flex-wrap gap-2"> 70 + <button 71 + type="button" 72 + onClick={() => onThemeChange('system')} 73 + aria-pressed={themePreference === 'system'} 74 + 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 aria-pressed:border-accent/60 aria-pressed:text-text" 75 + > 76 + System 77 + </button> 78 + <button 79 + type="button" 80 + onClick={() => onThemeChange('light')} 81 + aria-pressed={themePreference === 'light'} 82 + 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 aria-pressed:border-accent/60 aria-pressed:text-text" 83 + > 84 + Light 85 + </button> 86 + <button 87 + type="button" 88 + onClick={() => onThemeChange('dark')} 89 + aria-pressed={themePreference === 'dark'} 90 + 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 aria-pressed:border-accent/60 aria-pressed:text-text" 91 + > 92 + Dark 93 + </button> 94 + </div> 95 + </fieldset> 96 + </section> 97 + 98 + {showHapticsToggle ? ( 99 + <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 100 + <h2 className="text-base font-semibold text-text">Haptic feedback</h2> 101 + <p className="mt-2 text-sm text-muted">Subtle vibrations on interactions (Android only)</p> 102 + <button 103 + type="button" 104 + role="switch" 105 + aria-checked={!hapticsDisabled} 106 + onClick={onHapticsToggle} 107 + className="mt-3 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" 108 + > 109 + {!hapticsDisabled ? 'Enabled' : 'Disabled'} 110 + </button> 111 + </section> 112 + ) : null} 33 113 </div> 34 114 ) 35 115 }
+57 -26
src/pages/browse-page.tsx
··· 5 5 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 6 6 import { ErrorPanel } from '@/components/error-panel' 7 7 import { isAtmosphereTalk } from '@/lib/api' 8 + import { hapticTap } from '@/lib/haptics' 8 9 import { useKeyboard } from '@/lib/use-keyboard' 9 10 import { useVideos } from '@/state/videos-context' 10 11 ··· 18 19 const atmosphereCount = talks.filter((talk) => isAtmosphereTalk(talk)).length 19 20 const orderedTalks = useMemo(() => talks, [talks]) 20 21 const [selectedIndex, setSelectedIndex] = useState(0) 22 + const prefersReducedMotion = 23 + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches 24 + 25 + const focusSelectedCard = (index: number) => { 26 + const selectedTalk = orderedTalks[index] 27 + if (!selectedTalk) { 28 + return 29 + } 30 + const card = document.getElementById(`talk-card-${encodeURIComponent(selectedTalk.uri)}`) 31 + if (card instanceof HTMLElement) { 32 + card.focus({ preventScroll: true }) 33 + card.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion ? 'auto' : 'smooth' }) 34 + } 35 + } 21 36 22 37 useKeyboard((event) => { 23 38 if (event.metaKey || event.ctrlKey || event.altKey || orderedTalks.length === 0) { ··· 27 42 const key = event.key.toLowerCase() 28 43 if (key === 'j') { 29 44 event.preventDefault() 30 - setSelectedIndex((value) => Math.min(orderedTalks.length - 1, value + 1)) 45 + hapticTap() 46 + setSelectedIndex((value) => { 47 + const next = Math.min(orderedTalks.length - 1, value + 1) 48 + window.requestAnimationFrame(() => focusSelectedCard(next)) 49 + return next 50 + }) 31 51 return 32 52 } 33 53 34 54 if (key === 'k') { 35 55 event.preventDefault() 36 - setSelectedIndex((value) => Math.max(0, value - 1)) 56 + hapticTap() 57 + setSelectedIndex((value) => { 58 + const next = Math.max(0, value - 1) 59 + window.requestAnimationFrame(() => focusSelectedCard(next)) 60 + return next 61 + }) 37 62 return 38 63 } 39 64 ··· 80 105 81 106 {!loading && !error ? ( 82 107 <> 83 - {sourceRepos.length > 0 ? ( 84 - <section className="space-y-3 rounded-lg border border-line/45 bg-surface/80 p-4 md:p-5"> 85 - <h2 className="text-sm font-medium text-muted">Discovered repos</h2> 86 - <p className="text-sm text-muted"> 87 - {sourceRepos.length} repo{sourceRepos.length === 1 ? '' : 's'} with{' '} 88 - <code>place.stream.video</code> records. 89 - </p> 90 - <div className="flex flex-wrap gap-2"> 91 - {sourceRepos.map((did) => ( 92 - <span 93 - key={did} 94 - className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/70 px-3 text-xs text-muted" 95 - > 96 - {did} 97 - </span> 98 - ))} 99 - </div> 100 - <p className="text-xs text-muted"> 101 - AtmosphereConf official repo contributes {atmosphereCount} video 102 - {atmosphereCount === 1 ? '' : 's'}. 103 - </p> 104 - </section> 105 - ) : null} 106 - 107 108 {featuredTalk ? ( 108 109 <section className="space-y-3 md:space-y-4"> 109 110 <h2 className="text-sm font-medium text-muted">Latest Upload</h2> ··· 129 130 /> 130 131 ))} 131 132 </div> 133 + </section> 134 + ) : null} 135 + 136 + {sourceRepos.length > 0 ? ( 137 + <section className="pt-2"> 138 + <details className="rounded-lg border border-line/45 bg-surface/80 p-4 md:p-5"> 139 + <summary className="cursor-pointer text-sm font-medium text-muted"> 140 + Discovered repos ({sourceRepos.length}) 141 + </summary> 142 + <div className="mt-3 space-y-3"> 143 + <p className="text-sm text-muted"> 144 + {sourceRepos.length} repo{sourceRepos.length === 1 ? '' : 's'} with{' '} 145 + <code>place.stream.video</code> records. 146 + </p> 147 + <div className="flex flex-wrap gap-2"> 148 + {sourceRepos.map((did) => ( 149 + <span 150 + key={did} 151 + className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/70 px-3 text-xs text-muted" 152 + > 153 + {did} 154 + </span> 155 + ))} 156 + </div> 157 + <p className="text-xs text-muted"> 158 + AtmosphereConf official repo contributes {atmosphereCount} video 159 + {atmosphereCount === 1 ? '' : 's'}. 160 + </p> 161 + </div> 162 + </details> 132 163 </section> 133 164 ) : null} 134 165 </>
+65 -34
src/pages/search-page.tsx
··· 1 1 import { Search } from 'lucide-react' 2 - import { useEffect, useState, type ChangeEvent } from 'react' 2 + import { useEffect, useMemo, useState, type ChangeEvent } from 'react' 3 3 import { Link, useSearchParams } from 'react-router-dom' 4 4 5 5 import { ErrorPanel } from '@/components/error-panel' 6 6 import { TalkCard } from '@/components/talk-card' 7 7 import { TalkGridSkeleton } from '@/components/talk-grid-skeleton' 8 + import { hapticTap } from '@/lib/haptics' 8 9 import { searchTalkUris } from '@/lib/semantic-search' 9 10 import { toTagPath } from '@/lib/routes' 10 11 import { getTalkTaxonomyTokens, scoreTalkForQuery } from '@/lib/taxonomy' ··· 25 26 const [selectedIndex, setSelectedIndex] = useState(0) 26 27 const { talks, loading, error, refresh } = useVideos() 27 28 const trimmedQuery = query.trim() 29 + const prefersReducedMotion = 30 + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches 28 31 29 32 const onQueryChange = (event: ChangeEvent<HTMLInputElement>) => { 30 33 const nextValue = event.target.value ··· 108 111 } 109 112 }, [trimmedQuery, talks.length]) 110 113 111 - const filteredTalks = !trimmedQuery 112 - ? talks 113 - : (() => { 114 - const hasCurrentRemote = remoteQuery === trimmedQuery 114 + const filteredTalks = useMemo(() => { 115 + if (!trimmedQuery) { 116 + return talks 117 + } 115 118 116 - if (hasCurrentRemote && remoteUris && remoteUris.length > 0) { 117 - const talkByUri = new Map(talks.map((talk) => [talk.uri, talk])) 118 - const orderedFromRemote = remoteUris 119 - .map((uri) => talkByUri.get(uri)) 120 - .filter((talk): talk is NonNullable<typeof talk> => Boolean(talk)) 119 + const hasCurrentRemote = remoteQuery === trimmedQuery 120 + 121 + if (hasCurrentRemote && remoteUris && remoteUris.length > 0) { 122 + const talkByUri = new Map(talks.map((talk) => [talk.uri, talk])) 123 + const orderedFromRemote = remoteUris 124 + .map((uri) => talkByUri.get(uri)) 125 + .filter((talk): talk is NonNullable<typeof talk> => Boolean(talk)) 121 126 122 - if (orderedFromRemote.length > 0) { 123 - return orderedFromRemote 124 - } 125 - } 127 + if (orderedFromRemote.length > 0) { 128 + return orderedFromRemote 129 + } 130 + } 126 131 127 - return talks 128 - .map((talk) => ({ 129 - talk, 130 - score: scoreTalkForQuery(talk, trimmedQuery), 131 - })) 132 - .filter((entry) => entry.score > 0) 133 - .sort((a, b) => b.score - a.score) 134 - .map((entry) => entry.talk) 135 - })() 132 + return talks 133 + .map((talk) => ({ 134 + talk, 135 + score: scoreTalkForQuery(talk, trimmedQuery), 136 + })) 137 + .filter((entry) => entry.score > 0) 138 + .sort((a, b) => b.score - a.score) 139 + .map((entry) => entry.talk) 140 + }, [trimmedQuery, talks, remoteQuery, remoteUris]) 136 141 137 142 const selectedTalkIndex = 138 143 filteredTalks.length > 0 ? Math.min(selectedIndex, filteredTalks.length - 1) : 0 139 144 145 + const focusSelectedCard = (index: number) => { 146 + const selectedTalk = filteredTalks[index] 147 + if (!selectedTalk) { 148 + return 149 + } 150 + const card = document.getElementById(`talk-card-${encodeURIComponent(selectedTalk.uri)}`) 151 + if (card instanceof HTMLElement) { 152 + card.focus({ preventScroll: true }) 153 + card.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion ? 'auto' : 'smooth' }) 154 + } 155 + } 156 + 140 157 useKeyboard((event) => { 141 158 if (event.metaKey || event.ctrlKey || event.altKey) { 142 159 return ··· 150 167 return 151 168 } 152 169 event.preventDefault() 153 - setSelectedIndex((value) => Math.min(filteredTalks.length - 1, value + 1)) 170 + hapticTap() 171 + setSelectedIndex((value) => { 172 + const next = Math.min(filteredTalks.length - 1, value + 1) 173 + window.requestAnimationFrame(() => focusSelectedCard(next)) 174 + return next 175 + }) 154 176 return 155 177 } 156 178 ··· 159 181 return 160 182 } 161 183 event.preventDefault() 162 - setSelectedIndex((value) => Math.max(0, value - 1)) 184 + hapticTap() 185 + setSelectedIndex((value) => { 186 + const next = Math.max(0, value - 1) 187 + window.requestAnimationFrame(() => focusSelectedCard(next)) 188 + return next 189 + }) 163 190 return 164 191 } 165 192 ··· 188 215 } 189 216 }) 190 217 191 - const counts = new Map<string, number>() 192 - for (const talk of filteredTalks) { 193 - for (const token of getTalkTaxonomyTokens(talk)) { 194 - counts.set(token, (counts.get(token) ?? 0) + 1) 218 + const popularTokens = useMemo(() => { 219 + const counts = new Map<string, number>() 220 + for (const talk of filteredTalks) { 221 + for (const token of getTalkTaxonomyTokens(talk)) { 222 + counts.set(token, (counts.get(token) ?? 0) + 1) 223 + } 195 224 } 196 - } 197 - const popularTokens = [...counts.entries()] 198 - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 199 - .slice(0, 12) 200 - .map(([token]) => token) 225 + 226 + return [...counts.entries()] 227 + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 228 + .slice(0, 12) 229 + .map(([token]) => token) 230 + }, [filteredTalks]) 201 231 202 232 useEffect(() => { 203 233 if (searchParams.get('focus') !== '1') { ··· 303 333 featured={index === 0 && trimmedQuery.length > 0} 304 334 selected={selectedTalkIndex === index} 305 335 cardId={`talk-card-${encodeURIComponent(talk.uri)}`} 336 + hapticPattern="select" 306 337 /> 307 338 ))} 308 339 </div>
+74 -1
src/pages/video-page.tsx
··· 10 10 useRef, 11 11 useState, 12 12 type ChangeEvent, 13 + type FormEvent, 13 14 } from 'react' 14 15 import { Link, useNavigate, useParams } from 'react-router-dom' 15 16 ··· 17 18 import { Button } from '@/components/ui/button' 18 19 import { fetchTalkByUri, fetchVideoPlaylist, getArchiveBlobUrl } from '@/lib/api' 19 20 import { formatDate, formatDuration, truncateDid } from '@/lib/format' 21 + import { 22 + hapticBack, 23 + hapticError, 24 + hapticPlay, 25 + hapticSeek, 26 + hapticSuccess, 27 + hapticTap, 28 + } from '@/lib/haptics' 20 29 import { toTagPath, toVideoUriFromParams } from '@/lib/routes' 21 30 import { getTalkTaxonomyTokens } from '@/lib/taxonomy' 22 31 import { useKeyboard } from '@/lib/use-keyboard' ··· 125 134 126 135 setError('Video playback failed in this browser. Please retry.') 127 136 setStatus('error') 137 + hapticError() 128 138 } 129 139 130 140 video.addEventListener('error', onVideoError) ··· 167 177 setPlaylistUrl(playlistUrl) 168 178 setError(null) 169 179 setStatus('ready') 180 + hapticSuccess() 170 181 } catch (loadError) { 171 182 if (cancelled) { 172 183 return ··· 176 187 setError(message) 177 188 setPlaylistUrl(null) 178 189 setStatus('error') 190 + hapticError() 179 191 } 180 192 } 181 193 ··· 242 254 setPlaybackRate(nextRate) 243 255 }, []) 244 256 257 + const onSeekInput = useCallback((event: FormEvent<HTMLInputElement>) => { 258 + const video = videoRef.current 259 + if (!video || !Number.isFinite(video.duration) || video.duration <= 0) { 260 + return 261 + } 262 + 263 + const percent = Number(event.currentTarget.value) 264 + if (!Number.isFinite(percent)) { 265 + return 266 + } 267 + 268 + video.currentTime = (video.duration * percent) / 100 269 + hapticSeek() 270 + }, []) 271 + 272 + const onTogglePlay = useCallback(() => { 273 + const video = videoRef.current 274 + if (!video) { 275 + return 276 + } 277 + 278 + hapticPlay() 279 + if (video.paused) { 280 + void video.play() 281 + } else { 282 + video.pause() 283 + } 284 + }, []) 285 + 245 286 useEffect(() => { 246 287 let startY = 0 247 288 let latestY = 0 ··· 289 330 290 331 if (key === ' ') { 291 332 event.preventDefault() 333 + hapticPlay() 292 334 if (video.paused) { 293 335 void video.play() 294 336 } else { ··· 299 341 300 342 if (lower === 'k') { 301 343 event.preventDefault() 344 + hapticPlay() 302 345 if (video.paused) { 303 346 void video.play() 304 347 } else { ··· 310 353 if (lower === 'j') { 311 354 event.preventDefault() 312 355 video.currentTime = Math.max(0, video.currentTime - 10) 356 + hapticSeek() 313 357 return 314 358 } 315 359 ··· 317 361 event.preventDefault() 318 362 const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime + 10 319 363 video.currentTime = Math.min(duration, video.currentTime + 10) 364 + hapticSeek() 320 365 return 321 366 } 322 367 323 368 if (lower === 'f') { 324 369 event.preventDefault() 370 + hapticTap() 325 371 const container = playerContainerRef.current 326 372 if (!container) { 327 373 return ··· 338 384 if (lower === 'm') { 339 385 event.preventDefault() 340 386 video.muted = !video.muted 387 + hapticTap() 341 388 return 342 389 } 343 390 ··· 346 393 const pct = Number(key) / 10 347 394 if (Number.isFinite(video.duration) && video.duration > 0) { 348 395 video.currentTime = video.duration * pct 396 + hapticSeek() 349 397 } 350 398 return 351 399 } ··· 355 403 const nextRate = Math.max(0.25, video.playbackRate - 0.25) 356 404 video.playbackRate = nextRate 357 405 setPlaybackRate(nextRate) 406 + hapticTap() 358 407 return 359 408 } 360 409 ··· 363 412 const nextRate = Math.min(4, video.playbackRate + 0.25) 364 413 video.playbackRate = nextRate 365 414 setPlaybackRate(nextRate) 415 + hapticTap() 366 416 return 367 417 } 368 418 369 419 if (key === 'Escape') { 370 420 event.preventDefault() 421 + hapticBack() 371 422 navigate('/') 372 423 } 373 424 }) ··· 403 454 return ( 404 455 <div className="space-y-7 md:space-y-10"> 405 456 <Button asChild variant="ghost"> 406 - <Link to="/"> 457 + <Link 458 + to="/" 459 + onClick={() => { 460 + hapticBack() 461 + }} 462 + > 407 463 <ArrowLeft className="h-4 w-4" /> 408 464 Back to Browse 409 465 </Link> ··· 444 500 ) : null} 445 501 446 502 <div className="flex flex-wrap items-center gap-3 text-sm text-muted"> 503 + <Button variant="secondary" onClick={onTogglePlay}> 504 + Play / Pause 505 + </Button> 447 506 <p> 448 507 {playbackElapsed} / {playbackTotal} 449 508 </p> 509 + 510 + <label className="flex min-h-11 min-w-[12rem] flex-1 items-center gap-2"> 511 + <span className="sr-only">Seek timeline</span> 512 + <input 513 + type="range" 514 + min={0} 515 + max={100} 516 + step={1} 517 + value={duration > 0 ? Math.round((currentTime / duration) * 100) : 0} 518 + onChange={onSeekInput} 519 + onInput={onSeekInput} 520 + className="h-11 w-full accent-[oklch(var(--accent))]" 521 + /> 522 + </label> 450 523 451 524 <label className="flex min-h-11 items-center gap-2"> 452 525 <span>Speed</span>
+6 -6
tailwind.config.js
··· 7 7 theme: { 8 8 extend: { 9 9 colors: { 10 - bg: 'oklch(0 0 0 / <alpha-value>)', 11 - surface: 'oklch(0.12 0 0 / <alpha-value>)', 12 - line: 'oklch(0.34 0 0 / <alpha-value>)', 13 - text: 'oklch(0.95 0 0 / <alpha-value>)', 14 - muted: 'oklch(0.72 0 0 / <alpha-value>)', 15 - accent: 'oklch(0.75 0.11 190 / <alpha-value>)', 10 + bg: 'oklch(var(--bg) / <alpha-value>)', 11 + surface: 'oklch(var(--surface) / <alpha-value>)', 12 + line: 'oklch(var(--line) / <alpha-value>)', 13 + text: 'oklch(var(--text) / <alpha-value>)', 14 + muted: 'oklch(var(--muted) / <alpha-value>)', 15 + accent: 'oklch(var(--accent) / <alpha-value>)', 16 16 info: 'oklch(0.72 0 0 / <alpha-value>)', 17 17 success: 'oklch(0.72 0 0 / <alpha-value>)', 18 18 warning: 'oklch(0.72 0 0 / <alpha-value>)',