From aa6ea92b53d628bc57e1118d89ca0c9e31c60798 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 30 Aug 2026 12:35:26 +0200 Subject: [PATCH 001/118] fix: reject empty localization messages --- .../tests/message-catalog-validation.test.ts | 45 +++++++++++ scripts/check-localization.mjs | 81 +++---------------- scripts/localization-source-rules.mjs | 59 ++++++++++++++ scripts/message-catalog-validation.mjs | 39 +++++++++ 4 files changed, 152 insertions(+), 72 deletions(-) create mode 100644 apps/web/tests/message-catalog-validation.test.ts create mode 100644 scripts/localization-source-rules.mjs create mode 100644 scripts/message-catalog-validation.mjs diff --git a/apps/web/tests/message-catalog-validation.test.ts b/apps/web/tests/message-catalog-validation.test.ts new file mode 100644 index 00000000..8f8a94dc --- /dev/null +++ b/apps/web/tests/message-catalog-validation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { validateMessageCatalog } from "../../../scripts/message-catalog-validation.mjs"; + +const source = { + $schema: "https://example.com/messages.schema.json", + greeting: "Hello {name}", + title: "Title", +}; + +describe("message catalog validation", () => { + test("accepts a complete catalog with matching placeholders", () => { + expect( + validateMessageCatalog(source, { + $schema: source.$schema, + greeting: "Bonjour {name}", + title: "Titre", + }), + ).toEqual([]); + }); + + test("rejects empty and non-text message values without duplicate placeholder errors", () => { + expect( + validateMessageCatalog(source, { + $schema: source.$schema, + greeting: " ", + title: null, + }), + ).toEqual(["empty message value for greeting", "message value must be text for title"]); + }); + + test("rejects missing, unknown and mismatched catalog entries", () => { + expect( + validateMessageCatalog(source, { + $schema: "https://example.com/other.schema.json", + greeting: "Bonjour", + extra: "Unexpected", + }), + ).toEqual([ + "message schema must match the source catalog", + "placeholder mismatch for greeting", + "missing message key title", + "unknown message key extra", + ]); + }); +}); diff --git a/scripts/check-localization.mjs b/scripts/check-localization.mjs index 15848849..ac35cb7e 100644 --- a/scripts/check-localization.mjs +++ b/scripts/check-localization.mjs @@ -1,5 +1,13 @@ import fs from "node:fs"; import path from "node:path"; +import { + allowedFiles, + allowedTechnicalText, + allowedText, + helperSourceFiles, + propertySourceFiles, +} from "./localization-source-rules.mjs"; +import { validateMessageCatalog } from "./message-catalog-validation.mjs"; const root = process.cwd(); const messagesDir = path.join(root, "apps/web/messages"); @@ -12,69 +20,12 @@ function addFailure(file, line, message) { failures.push(`${path.relative(root, file)}:${line}: ${message}`); } -function placeholders(value) { - return [...value.matchAll(/\{([A-Za-z0-9_.-]+)\}/g)].map((match) => match[1]).sort(); -} - for (const fileName of fs.readdirSync(messagesDir).filter((name) => name.endsWith(".json"))) { const file = path.join(messagesDir, fileName); const locale = JSON.parse(fs.readFileSync(file, "utf8")); - const expectedKeys = Object.keys(sourceLocale).sort(); - const actualKeys = Object.keys(locale).sort(); - for (const key of expectedKeys) { - if (!(key in locale)) addFailure(file, 1, `missing message key ${key}`); - else if ( - JSON.stringify(placeholders(sourceLocale[key])) !== JSON.stringify(placeholders(locale[key])) - ) { - addFailure(file, 1, `placeholder mismatch for ${key}`); - } - } - for (const key of actualKeys) { - if (!(key in sourceLocale)) addFailure(file, 1, `unknown message key ${key}`); - } + for (const failure of validateMessageCatalog(sourceLocale, locale)) addFailure(file, 1, failure); } -const allowedText = new Set([ - "TYPETYPE", - "TypeType", - "Reddit", - "OpenMoji", - "takeout.google.com", - "2x", - "x", - "T", - "GitHub", - "Google", - "YouTube", - "NicoNico", - "BiliBili", - "RSS", - "HDR", - "3D", - "4K", - "backward", - "forward", - "previous", - "next", - "dark", - "light", - "members_only", - "delete", -]); -const allowedFiles = new Set([ - "apps/web/src/lib/languages.ts", - "apps/web/src/lib/openmoji-catalog.ts", -]); -const allowedTechnicalText = new Map([ - ["apps/web/src/components/portability-import-panel.tsx", new Set(["queryClient.setQueryData"])], - ["apps/web/src/components/video-player-layout.tsx", new Set(["height"])], - ["apps/web/src/hooks/use-interface-locale.tsx", new Set(["Promise"])], - ["apps/web/src/routes/youtube-session.tsx", new Set(["unknown"])], - [ - "apps/web/src/settings/settings-about.tsx", - new Set(["Frontend", "Server", "Token", "Downloader"]), - ], -]); const visibleAttributes = /\b(?:aria-label|ariaLabel|title|placeholder|alt|label|description|message|subtitle|heading|confirmLabel|emptyLabel)\s*=\s*(["'])(.*?)\1/g; const visibleProperties = @@ -90,20 +41,6 @@ const visibleFallbackLiteral = const jsxConditionalLiteral = /\{[^{}\n]*\?\s*(["'`])([^"'`\n]+)\1\s*:\s*(["'`])([^"'`\n]+)\3[^{}\n]*\}/g; const jsxText = />\s*([^<>{}\n]*[A-Za-zÀ-ÿ][^<>{}\n]*)\s* match[1]).sort(); +} + +export function validateMessageCatalog(sourceLocale, locale) { + const failures = []; + const expectedKeys = Object.keys(sourceLocale).sort(); + const actualKeys = Object.keys(locale).sort(); + + for (const key of expectedKeys) { + if (!(key in locale)) { + failures.push(`missing message key ${key}`); + continue; + } + + const value = locale[key]; + if (typeof value !== "string") { + failures.push(`message value must be text for ${key}`); + continue; + } + if (value.trim().length === 0) { + failures.push(`empty message value for ${key}`); + continue; + } + if (key === "$schema" && value !== sourceLocale[key]) { + failures.push("message schema must match the source catalog"); + continue; + } + if (JSON.stringify(placeholders(sourceLocale[key])) !== JSON.stringify(placeholders(value))) { + failures.push(`placeholder mismatch for ${key}`); + } + } + + for (const key of actualKeys) { + if (!(key in sourceLocale)) failures.push(`unknown message key ${key}`); + } + + return failures; +} From 4d6aeaf453a3552a234f8e70bb7d32ff6787923b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 31 Aug 2026 14:19:48 +0200 Subject: [PATCH 002/118] ci: route workflows to R730 runners --- .github/workflows/ci.yml | 4 ++-- .github/workflows/coverage.yml | 2 +- .github/workflows/docker.yml | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef46abc..05b13277 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ env: jobs: quality: - runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }} steps: - uses: actions/checkout@v7.0.1 @@ -70,7 +70,7 @@ jobs: build: needs: quality - runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }} steps: - uses: actions/checkout@v7.0.1 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f15f1f21..ec7019c5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -11,7 +11,7 @@ env: jobs: coverage: - runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }} steps: - uses: actions/checkout@v7.0.1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9f15fa93..e8aa031c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,7 +19,7 @@ jobs: prepare: needs: verify - runs-on: [self-hosted, Linux, X64, arko, typetype] + runs-on: [self-hosted, Linux, X64, r730, typetype] permissions: contents: read outputs: @@ -70,7 +70,7 @@ jobs: build-platform: needs: prepare - runs-on: [self-hosted, Linux, X64, arko, typetype] + runs-on: [self-hosted, Linux, X64, r730, typetype] timeout-minutes: 20 permissions: contents: read @@ -138,7 +138,7 @@ jobs: publish: needs: [prepare, build-platform] - runs-on: [self-hosted, Linux, X64, arko, typetype] + runs-on: [self-hosted, Linux, X64, r730, typetype] timeout-minutes: 10 permissions: contents: read @@ -194,7 +194,7 @@ jobs: notify-orchestrator: needs: [prepare, publish] - runs-on: [self-hosted, Linux, X64, arko, typetype] + runs-on: [self-hosted, Linux, X64, r730, typetype] permissions: contents: read env: From f72003de1adaa872ee85e4013254f813baf2f4a4 Mon Sep 17 00:00:00 2001 From: Tax_Tux <138765817+Priveetee@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:31:03 +0200 Subject: [PATCH 003/118] chore: benchmark dev runners From ce6e56362a4027f25d53dd2c43ccfe2dc11b9788 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 31 Aug 2026 21:59:33 +0200 Subject: [PATCH 004/118] ci: route localization checks to R730 --- .github/workflows/localization.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/localization.yml b/.github/workflows/localization.yml index a17b3338..bb064ba0 100644 --- a/.github/workflows/localization.yml +++ b/.github/workflows/localization.yml @@ -12,7 +12,7 @@ env: jobs: report: - runs-on: ubuntu-24.04 + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }} steps: - uses: actions/checkout@v7.0.1 From 5a94ed656a84ee8a22b98909e3a99e9eb2c43653 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 11:16:16 +0200 Subject: [PATCH 005/118] fix: stabilize remote YouTube input handling --- .../src/components/youtube-remote-browser.tsx | 77 +++++++------ .../src/hooks/use-youtube-remote-browser.ts | 79 +++++++++----- .../web/src/lib/youtube-remote-input-queue.ts | 54 +++++++++ apps/web/src/lib/youtube-remote-pointer.ts | 52 +++++++++ .../tests/youtube-remote-input-queue.test.ts | 103 ++++++++++++++++++ apps/web/tests/youtube-remote-pointer.test.ts | 46 ++++++++ 6 files changed, 355 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/lib/youtube-remote-input-queue.ts create mode 100644 apps/web/src/lib/youtube-remote-pointer.ts create mode 100644 apps/web/tests/youtube-remote-input-queue.test.ts create mode 100644 apps/web/tests/youtube-remote-pointer.test.ts diff --git a/apps/web/src/components/youtube-remote-browser.tsx b/apps/web/src/components/youtube-remote-browser.tsx index 26a6fd0a..4b8bf32a 100644 --- a/apps/web/src/components/youtube-remote-browser.tsx +++ b/apps/web/src/components/youtube-remote-browser.tsx @@ -2,6 +2,7 @@ import type { KeyboardEvent, PointerEvent } from "react"; import { useEffect, useRef, useState } from "react"; import type { YoutubeRemoteInput, YoutubeRemotePhase } from "../hooks/use-youtube-remote-browser"; import { youtubeRemotePhaseLabel } from "../lib/youtube-remote-phase"; +import { mapYoutubeRemotePointer, type RemotePointerSize } from "../lib/youtube-remote-pointer"; import { m } from "../paraglide/messages.js"; type Props = { @@ -11,11 +12,6 @@ type Props = { onInput: (message: YoutubeRemoteInput) => void; }; -type FrameSize = { - width: number; - height: number; -}; - function modifiers(event: KeyboardEvent): string[] { const next: string[] = []; if (event.altKey) next.push("Alt"); @@ -36,7 +32,9 @@ function isPasteShortcut(event: KeyboardEvent): boolean { export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) { const rootRef = useRef(null); const inputRef = useRef(null); - const [frameSize, setFrameSize] = useState(null); + const pointerIdRef = useRef(null); + const [frameSize, setFrameSize] = useState(null); + const [viewportSize, setViewportSize] = useState(null); useEffect(() => { const root = rootRef.current; @@ -44,12 +42,23 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) const observer = new ResizeObserver(([entry]) => { const width = Math.round(entry.contentRect.width); const height = Math.round(entry.contentRect.height); - if (width > 0 && height > 0) onInput({ type: "resize", width, height }); + if (width <= 0 || height <= 0) return; + setViewportSize((previous) => + previous?.width === width && previous.height === height ? previous : { width, height }, + ); + onInput({ type: "resize", width, height }); }); observer.observe(root); return () => observer.disconnect(); }, [onInput]); + useEffect(() => { + if (!frameUrl) { + setFrameSize(null); + pointerIdRef.current = null; + } + }, [frameUrl]); + useEffect(() => { const input = inputRef.current; if (!input) return; @@ -62,25 +71,15 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) }, [onInput]); function point(event: PointerEvent) { - const rect = - rootRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect(); - const rawX = event.clientX - rect.left; - const rawY = event.clientY - rect.top; - if (!frameSize) { - return { x: Math.round(rawX), y: Math.round(rawY) }; - } - const scale = Math.min(rect.width / frameSize.width, rect.height / frameSize.height); - if (!Number.isFinite(scale) || scale <= 0) { - return { x: Math.round(rawX), y: Math.round(rawY) }; + const rect = event.currentTarget.getBoundingClientRect(); + return mapYoutubeRemotePointer(event.clientX, event.clientY, rect, frameSize, viewportSize); + } + + function releasePointer(event: PointerEvent) { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); } - const offsetX = (rect.width - frameSize.width * scale) / 2; - const offsetY = (rect.height - frameSize.height * scale) / 2; - const x = Math.round((rawX - offsetX) / scale); - const y = Math.round((rawY - offsetY) / scale); - return { - x: Math.max(0, Math.min(frameSize.width - 1, x)), - y: Math.max(0, Math.min(frameSize.height - 1, y)), - }; + pointerIdRef.current = null; } return ( @@ -119,16 +118,32 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) onChange={() => undefined} className="absolute inset-0 h-full w-full touch-none resize-none cursor-default border-0 bg-transparent p-0 text-base text-transparent caret-transparent outline-none" onPointerDown={(event) => { + if (pointerIdRef.current !== null && pointerIdRef.current !== event.pointerId) return; + event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); + pointerIdRef.current = event.pointerId; onInput({ type: "pointer", event: "down", ...point(event), button: "left" }); }} - onPointerMove={(event) => - onInput({ type: "pointer", event: "move", ...point(event), button: "left" }) - } - onPointerUp={(event) => - onInput({ type: "pointer", event: "up", ...point(event), button: "left" }) - } + onPointerMove={(event) => { + if (pointerIdRef.current !== null && pointerIdRef.current !== event.pointerId) return; + event.preventDefault(); + onInput({ type: "pointer", event: "move", ...point(event), button: "left" }); + }} + onPointerUp={(event) => { + event.preventDefault(); + if (pointerIdRef.current === event.pointerId) { + onInput({ type: "pointer", event: "up", ...point(event), button: "left" }); + releasePointer(event); + } + }} + onPointerCancel={(event) => { + event.preventDefault(); + if (pointerIdRef.current === event.pointerId) { + onInput({ type: "pointer", event: "up", ...point(event), button: "left" }); + releasePointer(event); + } + }} onKeyDown={(event) => { if (isPasteShortcut(event)) return; event.preventDefault(); diff --git a/apps/web/src/hooks/use-youtube-remote-browser.ts b/apps/web/src/hooks/use-youtube-remote-browser.ts index 70e073fe..f9dd708e 100644 --- a/apps/web/src/hooks/use-youtube-remote-browser.ts +++ b/apps/web/src/hooks/use-youtube-remote-browser.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { recordClientEvent } from "../lib/client-debug-log"; +import { createYoutubeRemoteInputQueue } from "../lib/youtube-remote-input-queue"; import { m } from "../paraglide/messages.js"; export type YoutubeRemotePhase = @@ -14,7 +15,9 @@ export type YoutubeRemotePhase = export type YoutubeRemoteInput = | { type: "resize"; width: number; height: number } - | { type: "pointer"; event: "down" | "up" | "move"; x: number; y: number; button: "left" } + | { type: "pointer"; event: "down"; x: number; y: number; button: "left" } + | { type: "pointer"; event: "up"; x: number; y: number; button: "left" } + | { type: "pointer"; event: "move"; x: number; y: number; button: "left" } | { type: "wheel"; deltaX: number; deltaY: number } | { type: "key"; event: "down" | "up"; key: string; code: string; modifiers: string[] } | { type: "text"; value: string } @@ -69,12 +72,46 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { const wsRef = useRef(null); const frameRef = useRef(null); const inputCountRef = useRef(0); + const lastResizeRef = useRef | null>(null); + const inputQueueRef = useRef | null>(null); const [phase, setPhase] = useState(wsUrl ? "connecting" : "idle"); const [frameUrl, setFrameUrl] = useState(null); const [error, setError] = useState(null); + const sendImmediate = useCallback((message: YoutubeRemoteInput) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + recordClientEvent("youtube_remote.input_dropped", { type: message.type }); + return false; + } + ws.send(JSON.stringify(message)); + inputCountRef.current += 1; + if ( + message.type !== "pointer" || + message.event !== "move" || + inputCountRef.current % 25 === 0 + ) { + recordClientEvent("youtube_remote.input_sent", { + type: message.type, + event: "event" in message ? message.event : null, + length: message.type === "text" ? message.value.length : null, + }); + } + return true; + }, []); + + const canSend = useCallback(() => { + const ws = wsRef.current; + return ws !== null && ws.readyState === WebSocket.OPEN; + }, []); + + if (inputQueueRef.current === null) { + inputQueueRef.current = createYoutubeRemoteInputQueue({ canSend, sendImmediate }); + } + useEffect(() => { if (!wsUrl) { + lastResizeRef.current = null; setPhase("idle"); setError(null); return; @@ -92,10 +129,13 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { recordClientEvent("youtube_remote.ws_connecting", { hasUrl: true }); ws.onopen = () => { + if (!active) return; recordClientEvent("youtube_remote.ws_open"); + if (lastResizeRef.current) sendImmediate(lastResizeRef.current); }; ws.onmessage = (event) => { + if (!active) return; if (typeof event.data === "string") { const message = parseRemoteMessage(event.data); if (message?.type === "status") { @@ -122,6 +162,7 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { }; ws.onerror = () => { + if (!active) return; finished = true; setPhase("error"); setError(m.ui_remote_browser_connection_failed()); @@ -129,8 +170,9 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { }; ws.onclose = () => { + if (!active) return; recordClientEvent("youtube_remote.ws_close", { finished }); - if (active && !finished) setPhase("closed"); + if (!finished) setPhase("closed"); }; return () => { @@ -139,31 +181,18 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { wsRef.current = null; if (frameRef.current) URL.revokeObjectURL(frameRef.current); frameRef.current = null; + inputQueueRef.current?.reset(); setFrameUrl(null); }; - }, [wsUrl]); - - const send = useCallback((message: YoutubeRemoteInput) => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) { - recordClientEvent("youtube_remote.input_dropped", { type: message.type }); - return false; - } - ws.send(JSON.stringify(message)); - inputCountRef.current += 1; - if ( - message.type !== "pointer" || - message.event !== "move" || - inputCountRef.current % 25 === 0 - ) { - recordClientEvent("youtube_remote.input_sent", { - type: message.type, - event: "event" in message ? message.event : null, - length: message.type === "text" ? message.value.length : null, - }); - } - return true; - }, []); + }, [wsUrl, sendImmediate]); + + const send = useCallback( + (message: YoutubeRemoteInput) => { + if (message.type === "resize") lastResizeRef.current = message; + return inputQueueRef.current?.send(message) ?? sendImmediate(message); + }, + [sendImmediate], + ); return { phase, frameUrl, error, send }; } diff --git a/apps/web/src/lib/youtube-remote-input-queue.ts b/apps/web/src/lib/youtube-remote-input-queue.ts new file mode 100644 index 00000000..d25506be --- /dev/null +++ b/apps/web/src/lib/youtube-remote-input-queue.ts @@ -0,0 +1,54 @@ +import type { YoutubeRemoteInput } from "../hooks/use-youtube-remote-browser"; + +export const POINTER_MOVE_BATCH_MS = 16; + +type PointerMove = Extract; +type Timer = ReturnType; +type Schedule = (callback: () => void, delayMs: number) => Timer; +type Cancel = (timer: Timer) => void; + +type Options = { + canSend: () => boolean; + sendImmediate: (message: YoutubeRemoteInput) => boolean; + schedule?: Schedule; + cancel?: Cancel; +}; + +export function createYoutubeRemoteInputQueue({ + canSend, + sendImmediate, + schedule = setTimeout, + cancel = clearTimeout, +}: Options) { + let pendingMove: PointerMove | null = null; + let timer: Timer | null = null; + + function flush() { + if (timer !== null) { + cancel(timer); + timer = null; + } + const move = pendingMove; + pendingMove = null; + if (move) sendImmediate(move); + } + + function send(message: YoutubeRemoteInput) { + if (message.type === "pointer" && message.event === "move") { + if (!canSend()) return sendImmediate(message); + pendingMove = message; + if (timer === null) timer = schedule(flush, POINTER_MOVE_BATCH_MS); + return true; + } + flush(); + return sendImmediate(message); + } + + function reset() { + if (timer !== null) cancel(timer); + timer = null; + pendingMove = null; + } + + return { send, flush, reset }; +} diff --git a/apps/web/src/lib/youtube-remote-pointer.ts b/apps/web/src/lib/youtube-remote-pointer.ts new file mode 100644 index 00000000..828853dd --- /dev/null +++ b/apps/web/src/lib/youtube-remote-pointer.ts @@ -0,0 +1,52 @@ +export type RemotePointerSize = { + width: number; + height: number; +}; + +export type RemotePointerCoordinates = { + x: number; + y: number; +}; + +type RemotePointerSurface = RemotePointerSize & { + left: number; + top: number; +}; + +function validSize(size: RemotePointerSize | null): size is RemotePointerSize { + return size !== null && size.width > 0 && size.height > 0; +} + +export function mapYoutubeRemotePointer( + clientX: number, + clientY: number, + surface: RemotePointerSurface, + frameSize: RemotePointerSize | null, + viewportSize: RemotePointerSize | null, +): RemotePointerCoordinates { + const targetSize = validSize(viewportSize) ? viewportSize : frameSize; + const displaySize = validSize(frameSize) ? frameSize : targetSize; + const rawX = clientX - surface.left; + const rawY = clientY - surface.top; + + if (!validSize(targetSize) || !validSize(displaySize)) { + return { x: Math.round(rawX), y: Math.round(rawY) }; + } + + const scale = Math.min(surface.width / displaySize.width, surface.height / displaySize.height); + if (!Number.isFinite(scale) || scale <= 0) { + return { x: Math.round(rawX), y: Math.round(rawY) }; + } + + const offsetX = (surface.width - displaySize.width * scale) / 2; + const offsetY = (surface.height - displaySize.height * scale) / 2; + const displayX = (rawX - offsetX) / scale; + const displayY = (rawY - offsetY) / scale; + const x = Math.round((displayX / displaySize.width) * targetSize.width); + const y = Math.round((displayY / displaySize.height) * targetSize.height); + + return { + x: Math.max(0, Math.min(targetSize.width - 1, x)), + y: Math.max(0, Math.min(targetSize.height - 1, y)), + }; +} diff --git a/apps/web/tests/youtube-remote-input-queue.test.ts b/apps/web/tests/youtube-remote-input-queue.test.ts new file mode 100644 index 00000000..a214a7b2 --- /dev/null +++ b/apps/web/tests/youtube-remote-input-queue.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import type { YoutubeRemoteInput } from "../src/hooks/use-youtube-remote-browser"; +import { + createYoutubeRemoteInputQueue, + POINTER_MOVE_BATCH_MS, +} from "../src/lib/youtube-remote-input-queue"; + +const move = (x: number): YoutubeRemoteInput => ({ + type: "pointer", + event: "move", + x, + y: 12, + button: "left", +}); + +describe("YouTube remote input queue", () => { + test("coalesces pointer moves and sends the latest sample", () => { + const sent: YoutubeRemoteInput[] = []; + let scheduled: (() => void) | null = null; + const queue = createYoutubeRemoteInputQueue({ + canSend: () => true, + sendImmediate: (message) => { + sent.push(message); + return true; + }, + schedule: (callback, delayMs) => { + expect(delayMs).toBe(POINTER_MOVE_BATCH_MS); + scheduled = callback; + return 1; + }, + cancel: () => undefined, + }); + + queue.send(move(10)); + queue.send(move(20)); + expect(sent).toEqual([]); + scheduled?.(); + expect(sent).toEqual([move(20)]); + }); + + test("flushes a move before a terminal input", () => { + const sent: YoutubeRemoteInput[] = []; + const queue = createYoutubeRemoteInputQueue({ + canSend: () => true, + sendImmediate: (message) => { + sent.push(message); + return true; + }, + schedule: () => 1, + cancel: () => undefined, + }); + + queue.send(move(42)); + queue.send({ type: "key", event: "down", key: "Enter", code: "Enter", modifiers: [] }); + expect(sent.map((message) => (message.type === "pointer" ? message.x : message.type))).toEqual([ + 42, + "key", + ]); + }); + + test("sends moves immediately while disconnected", () => { + const sent: YoutubeRemoteInput[] = []; + let scheduled = false; + const queue = createYoutubeRemoteInputQueue({ + canSend: () => false, + sendImmediate: (message) => { + sent.push(message); + return true; + }, + schedule: () => { + scheduled = true; + return 1; + }, + cancel: () => undefined, + }); + + queue.send(move(7)); + expect(sent).toEqual([move(7)]); + expect(scheduled).toBe(false); + }); + + test("reset drops a pending move and cancels its timer", () => { + const sent: YoutubeRemoteInput[] = []; + let cancelled = false; + const queue = createYoutubeRemoteInputQueue({ + canSend: () => true, + sendImmediate: (message) => { + sent.push(message); + return true; + }, + schedule: () => 1, + cancel: () => { + cancelled = true; + }, + }); + + queue.send(move(99)); + queue.reset(); + queue.flush(); + expect(sent).toEqual([]); + expect(cancelled).toBe(true); + }); +}); diff --git a/apps/web/tests/youtube-remote-pointer.test.ts b/apps/web/tests/youtube-remote-pointer.test.ts new file mode 100644 index 00000000..b1fb3b8d --- /dev/null +++ b/apps/web/tests/youtube-remote-pointer.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { mapYoutubeRemotePointer } from "../src/lib/youtube-remote-pointer"; + +const surface = { left: 100, top: 50, width: 800, height: 600 }; + +test("maps an object-contain click to the CSS viewport", () => { + expect( + mapYoutubeRemotePointer( + 500, + 350, + surface, + { width: 2560, height: 1440 }, + { + width: 1280, + height: 720, + }, + ), + ).toEqual({ x: 640, y: 360 }); +}); + +test("clamps clicks in letterboxed space to the remote viewport", () => { + expect( + mapYoutubeRemotePointer( + 100, + 50, + surface, + { width: 1280, height: 720 }, + { + width: 1280, + height: 720, + }, + ), + ).toEqual({ x: 0, y: 0 }); + expect( + mapYoutubeRemotePointer( + 900, + 650, + surface, + { width: 1280, height: 720 }, + { + width: 1280, + height: 720, + }, + ), + ).toEqual({ x: 1279, y: 719 }); +}); From f21d656ac49825b5b182fa0ca5c509e6284d92ca Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 11:35:41 +0200 Subject: [PATCH 006/118] feat: add remote login bug reporting --- .../youtube-session-browser-panel.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/youtube-session-browser-panel.tsx b/apps/web/src/components/youtube-session-browser-panel.tsx index e24b61e0..b679a7bf 100644 --- a/apps/web/src/components/youtube-session-browser-panel.tsx +++ b/apps/web/src/components/youtube-session-browser-panel.tsx @@ -1,6 +1,9 @@ +import { useState } from "react"; import type { YoutubeRemoteInput, YoutubeRemotePhase } from "../hooks/use-youtube-remote-browser"; import { youtubeRemotePhaseLabel } from "../lib/youtube-remote-phase"; import { m } from "../paraglide/messages.js"; +import { ReportBugModal } from "./report-bug-modal"; +import { BugIcon } from "./watch-icons"; import { YoutubeIcon } from "./youtube-icon"; import { YoutubeRemoteBrowser } from "./youtube-remote-browser"; @@ -37,6 +40,8 @@ export function YoutubeSessionBrowserPanel({ onCancel, onInput, }: Props) { + const [reportOpen, setReportOpen] = useState(false); + if (browserOpen) { return (
@@ -46,14 +51,25 @@ export function YoutubeSessionBrowserPanel({ {m.ui_phase()} {youtubeRemotePhaseLabel(phase)}.{" "} {m.ui_click_the_browser_area_before_typing()}

- +
+ + +
+ {reportOpen && setReportOpen(false)} />} ); } From 0541f47659fad3e51cb8b79b8d4737bab7f2b4e5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 12:25:12 +0200 Subject: [PATCH 007/118] fix: consume mse stalled playback recovery --- apps/web/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index cb1c9933..9b9edc9e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.56", + "@typetype/mse": "0.1.57", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", diff --git a/bun.lock b/bun.lock index ae5329ff..df62416c 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", - "@typetype/mse": "0.1.56", + "@typetype/mse": "0.1.57", "@vidstack/react": "1.12.13", "dashjs": "^5.2.0", "hls.js": "1.6.16", @@ -371,7 +371,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@typetype/mse": ["@typetype/mse@0.1.56", "", {}, "sha512-zOEcOpiJDdHKF+vxCogg2kEupqBghuNs42T5Py2ahNpwm2cTXmNqsUOkynilQ4h9x2t7IcnEa26hP5OaxQgEEQ=="], + "@typetype/mse": ["@typetype/mse@0.1.57", "", {}, "sha512-ydSpqIZw3Ll8LUysxP80Yz7YCYRZBHcsHO5U0kS5nf+bGEMBRFcNOgUwBQ/pJqBWIGdnNQ678A0PP19gs4NdpQ=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From c1e40fc775ff387b9b8545db922b5269171c5bbb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 15:19:39 +0200 Subject: [PATCH 008/118] fix: handle volume wheel on every player layout --- .../src/components/player-volume-control.tsx | 59 ++++++++++++++++++- .../src/components/video-player-layout.tsx | 5 +- apps/web/src/lib/volume-wheel.ts | 17 ++++++ apps/web/tests/volume-wheel.test.ts | 20 +++++++ 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/volume-wheel.ts create mode 100644 apps/web/tests/volume-wheel.test.ts diff --git a/apps/web/src/components/player-volume-control.tsx b/apps/web/src/components/player-volume-control.tsx index ce719d5c..372f63af 100644 --- a/apps/web/src/components/player-volume-control.tsx +++ b/apps/web/src/components/player-volume-control.tsx @@ -1,12 +1,63 @@ +import type { WheelEvent } from "react"; +import { useRef } from "react"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; -import { defaultLayoutIcons, MuteButton, useMediaState, VolumeSlider } from "../lib/vidstack"; +import { + defaultLayoutIcons, + MuteButton, + useMediaRemote, + useMediaState, + VolumeSlider, +} from "../lib/vidstack"; +import { volumeAfterWheel } from "../lib/volume-wheel"; import { m } from "../paraglide/messages.js"; +function useVolumeWheel() { + const remote = useMediaRemote(); + const volume = useMediaState("volume"); + const canSetVolume = useMediaState("canSetVolume"); + const volumeRef = useRef(volume); + volumeRef.current = volume; + + return (event: WheelEvent) => { + if (!canSetVolume || !Number.isFinite(event.deltaY) || event.deltaY === 0) return; + + const nextVolume = volumeAfterWheel(volumeRef.current, event.deltaY); + if (nextVolume === volumeRef.current) return; + + event.preventDefault(); + volumeRef.current = nextVolume; + remote.changeVolume(nextVolume, event.nativeEvent); + }; +} + +export function PlayerVolumeSlider() { + const canSetVolume = useMediaState("canSetVolume"); + const handleWheel = useVolumeWheel(); + + if (!canSetVolume) return null; + + return ( + + + + + + + + + ); +} + export function PlayerVolumeControl() { const { locale } = useInterfaceLocale(); const muted = useMediaState("muted"); const volume = useMediaState("volume"); const canSetVolume = useMediaState("canSetVolume"); + const handleWheel = useVolumeWheel(); const Icon = muted || volume === 0 ? defaultLayoutIcons.MuteButton.Mute @@ -23,7 +74,11 @@ export function PlayerVolumeControl() { {canSetVolume ? ( - + diff --git a/apps/web/src/components/video-player-layout.tsx b/apps/web/src/components/video-player-layout.tsx index 18f00bc4..8e15f5bd 100644 --- a/apps/web/src/components/video-player-layout.tsx +++ b/apps/web/src/components/video-player-layout.tsx @@ -9,7 +9,7 @@ import { AudioTrackSelector } from "./audio-track-selector"; import { CinemaModeControl } from "./cinema-mode-control"; import { FormatSelector } from "./format-selector"; import { PlayerTrackButton } from "./player-track-button"; -import { PlayerVolumeControl } from "./player-volume-control"; +import { PlayerVolumeControl, PlayerVolumeSlider } from "./player-volume-control"; import { QualitySelector } from "./quality-selector"; import { SabrCurrentTime } from "./sabr-current-time"; import { SabrTimeSlider } from "./sabr-time-slider"; @@ -97,6 +97,7 @@ export function VideoPlayerLayout({ ), afterCaptionButton: , beforeSettingsMenu: , + volumeSlider: , fullscreenButton: null, pipButton: null, title: null, @@ -121,6 +122,7 @@ export function VideoPlayerLayout({ beforeCaptionButton: , afterCaptionButton: , beforeSettingsMenu: , + volumeSlider: , }} /> ); @@ -155,6 +157,7 @@ export function VideoPlayerLayout({ {!hideCinemaMode && } ), + volumeSlider: , }} /> ); diff --git a/apps/web/src/lib/volume-wheel.ts b/apps/web/src/lib/volume-wheel.ts new file mode 100644 index 00000000..e40351de --- /dev/null +++ b/apps/web/src/lib/volume-wheel.ts @@ -0,0 +1,17 @@ +const VOLUME_SCROLL_REFERENCE = 100; +const VOLUME_SCROLL_STEP = 0.05; + +function clampVolume(volume: number): number { + return Math.min(1, Math.max(0, volume)); +} + +export function volumeAfterWheel(volume: number, deltaY: number): number { + const currentVolume = clampVolume(Number.isFinite(volume) ? volume : 0); + if (!Number.isFinite(deltaY) || deltaY === 0) return currentVolume; + + const change = Math.min( + VOLUME_SCROLL_STEP, + (Math.abs(deltaY) / VOLUME_SCROLL_REFERENCE) * VOLUME_SCROLL_STEP, + ); + return clampVolume(currentVolume + (deltaY < 0 ? change : -change)); +} diff --git a/apps/web/tests/volume-wheel.test.ts b/apps/web/tests/volume-wheel.test.ts new file mode 100644 index 00000000..478bdb87 --- /dev/null +++ b/apps/web/tests/volume-wheel.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import { volumeAfterWheel } from "../src/lib/volume-wheel"; + +test("raises volume when scrolling up", () => { + expect(volumeAfterWheel(0.4, -100)).toBeCloseTo(0.45); +}); + +test("lowers volume when scrolling down", () => { + expect(volumeAfterWheel(0.4, 100)).toBeCloseTo(0.35); +}); + +test("keeps volume within the media range", () => { + expect(volumeAfterWheel(0, 100)).toBe(0); + expect(volumeAfterWheel(1, -100)).toBe(1); +}); + +test("ignores invalid wheel deltas", () => { + expect(volumeAfterWheel(0.4, 0)).toBe(0.4); + expect(volumeAfterWheel(0.4, Number.NaN)).toBe(0.4); +}); From 833a6efa1eb580695226886969599f96fa8ed9cb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 18:31:01 +0200 Subject: [PATCH 009/118] fix: localize volume slider labels --- apps/web/src/components/player-volume-control.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/player-volume-control.tsx b/apps/web/src/components/player-volume-control.tsx index 372f63af..f306bcf2 100644 --- a/apps/web/src/components/player-volume-control.tsx +++ b/apps/web/src/components/player-volume-control.tsx @@ -31,6 +31,7 @@ function useVolumeWheel() { } export function PlayerVolumeSlider() { + const { locale } = useInterfaceLocale(); const canSetVolume = useMediaState("canSetVolume"); const handleWheel = useVolumeWheel(); @@ -40,7 +41,7 @@ export function PlayerVolumeSlider() { @@ -77,7 +78,7 @@ export function PlayerVolumeControl() { From ed484376109a2961c7dd60ca1609fd218c24fb79 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 18:43:49 +0200 Subject: [PATCH 010/118] fix: capture volume wheel on native controls --- .../src/components/player-volume-control.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/player-volume-control.tsx b/apps/web/src/components/player-volume-control.tsx index f306bcf2..529b70ef 100644 --- a/apps/web/src/components/player-volume-control.tsx +++ b/apps/web/src/components/player-volume-control.tsx @@ -38,18 +38,19 @@ export function PlayerVolumeSlider() { if (!canSetVolume) return null; return ( - - - - - - - - +
+ + + + + + + + +
); } @@ -67,7 +68,7 @@ export function PlayerVolumeControl() { : defaultLayoutIcons.MuteButton.VolumeHigh; return ( -
+
From 9f3c2e3b26d2164908d8d3cc4c5dad0f9c09f039 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 18:54:00 +0200 Subject: [PATCH 011/118] fix: prevent page scroll on volume wheel --- .../src/components/player-volume-control.tsx | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/player-volume-control.tsx b/apps/web/src/components/player-volume-control.tsx index 529b70ef..2b820824 100644 --- a/apps/web/src/components/player-volume-control.tsx +++ b/apps/web/src/components/player-volume-control.tsx @@ -1,5 +1,4 @@ -import type { WheelEvent } from "react"; -import { useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; import { defaultLayoutIcons, @@ -16,29 +15,54 @@ function useVolumeWheel() { const volume = useMediaState("volume"); const canSetVolume = useMediaState("canSetVolume"); const volumeRef = useRef(volume); + const canSetVolumeRef = useRef(canSetVolume); volumeRef.current = volume; + canSetVolumeRef.current = canSetVolume; - return (event: WheelEvent) => { - if (!canSetVolume || !Number.isFinite(event.deltaY) || event.deltaY === 0) return; + return useCallback( + (event: globalThis.WheelEvent) => { + if (!canSetVolumeRef.current || !Number.isFinite(event.deltaY) || event.deltaY === 0) { + return; + } - const nextVolume = volumeAfterWheel(volumeRef.current, event.deltaY); - if (nextVolume === volumeRef.current) return; + const nextVolume = volumeAfterWheel(volumeRef.current, event.deltaY); + if (nextVolume === volumeRef.current) return; - event.preventDefault(); - volumeRef.current = nextVolume; - remote.changeVolume(nextVolume, event.nativeEvent); - }; + event.preventDefault(); + volumeRef.current = nextVolume; + remote.changeVolume(nextVolume, event); + }, + [remote], + ); +} + +function useVolumeWheelTarget(handleWheel: (event: globalThis.WheelEvent) => void) { + const targetRef = useRef(null); + + useEffect(() => { + const target = targetRef.current; + if (!target) return; + + target.addEventListener("wheel", handleWheel, { + capture: true, + passive: false, + }); + return () => target.removeEventListener("wheel", handleWheel, true); + }, [handleWheel]); + + return targetRef; } export function PlayerVolumeSlider() { const { locale } = useInterfaceLocale(); const canSetVolume = useMediaState("canSetVolume"); const handleWheel = useVolumeWheel(); + const wheelTargetRef = useVolumeWheelTarget(handleWheel); if (!canSetVolume) return null; return ( -
+
+
Date: Thu, 3 Sep 2026 19:06:25 +0200 Subject: [PATCH 012/118] fix: capture volume wheel on native slider --- .../src/components/player-volume-control.tsx | 50 +++++++++++++------ apps/web/src/lib/vidstack.ts | 1 + 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/player-volume-control.tsx b/apps/web/src/components/player-volume-control.tsx index 2b820824..03ce1792 100644 --- a/apps/web/src/components/player-volume-control.tsx +++ b/apps/web/src/components/player-volume-control.tsx @@ -6,6 +6,7 @@ import { useMediaRemote, useMediaState, VolumeSlider, + type VolumeSliderInstance, } from "../lib/vidstack"; import { volumeAfterWheel } from "../lib/volume-wheel"; import { m } from "../paraglide/messages.js"; @@ -53,28 +54,49 @@ function useVolumeWheelTarget(handleWheel: (event: globalThis.WheelEvent) => voi return targetRef; } +function useVolumeWheelInstanceTarget(handleWheel: (event: globalThis.WheelEvent) => void) { + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => () => cleanupRef.current?.(), []); + + return useCallback( + (instance: VolumeSliderInstance | null) => { + cleanupRef.current?.(); + cleanupRef.current = null; + const target = instance?.el; + if (!target) return; + + target.addEventListener("wheel", handleWheel, { + capture: true, + passive: false, + }); + cleanupRef.current = () => target.removeEventListener("wheel", handleWheel, true); + }, + [handleWheel], + ); +} + export function PlayerVolumeSlider() { const { locale } = useInterfaceLocale(); const canSetVolume = useMediaState("canSetVolume"); const handleWheel = useVolumeWheel(); - const wheelTargetRef = useVolumeWheelTarget(handleWheel); + const wheelTargetRef = useVolumeWheelInstanceTarget(handleWheel); if (!canSetVolume) return null; return ( -
- - - - - - - - -
+ + + + + + + + ); } diff --git a/apps/web/src/lib/vidstack.ts b/apps/web/src/lib/vidstack.ts index d4942263..c8509fed 100644 --- a/apps/web/src/lib/vidstack.ts +++ b/apps/web/src/lib/vidstack.ts @@ -8,6 +8,7 @@ export type { Src, VideoProvider, VideoQualityOption, + VolumeSliderInstance, } from "@vidstack/react"; export { isAudioProvider, From b6ba355c9134df090a5887bc9aa3480c799c6244 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 19:06:25 +0200 Subject: [PATCH 013/118] test: cover YouTube short share URLs --- apps/web/tests/watch-url.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/tests/watch-url.test.ts b/apps/web/tests/watch-url.test.ts index 9de14dcd..32b0f0db 100644 --- a/apps/web/tests/watch-url.test.ts +++ b/apps/web/tests/watch-url.test.ts @@ -11,9 +11,18 @@ test("builds a YouTube thumbnail URL from watch values", () => { expect(youtubeThumbnailUrl("https://www.youtube.com/watch?v=Z05XGDSTe7U")).toBe( "https://i.ytimg.com/vi/Z05XGDSTe7U/hq720.jpg", ); + expect(youtubeThumbnailUrl("https://youtu.be/RjdGmIUbYIQ?is=zpKL5N9GylwAIBHO")).toBe( + "https://i.ytimg.com/vi/RjdGmIUbYIQ/hq720.jpg", + ); expect(youtubeThumbnailUrl("sm46525483")).toBeNull(); }); +test("normalizes a YouTube short URL while ignoring share parameters", () => { + const sourceUrl = "https://youtu.be/RjdGmIUbYIQ?is=zpKL5N9GylwAIBHO"; + expect(toPublicWatchParam(sourceUrl)).toBe("RjdGmIUbYIQ"); + expect(toWatchSourceUrl(sourceUrl)).toBe(sourceUrl); +}); + test("shortens and expands NicoNico watch URLs", () => { expect(toPublicWatchParam("https://www.nicovideo.jp/watch/sm46525483")).toBe("sm46525483"); expect(toWatchSourceUrl("sm46525483")).toBe("https://www.nicovideo.jp/watch/sm46525483"); From 3d8e21c2eef66bb0a95d4bb23a62a8a941aa7816 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 20:42:15 +0200 Subject: [PATCH 014/118] fix: normalize YouTube short share links --- apps/web/src/lib/watch-url.ts | 87 ++++++++++++++++++++------------ apps/web/src/routes/watch.tsx | 22 ++++++-- apps/web/tests/watch-url.test.ts | 13 +++++ 3 files changed, 88 insertions(+), 34 deletions(-) diff --git a/apps/web/src/lib/watch-url.ts b/apps/web/src/lib/watch-url.ts index 5fd8fd10..de2b43a8 100644 --- a/apps/web/src/lib/watch-url.ts +++ b/apps/web/src/lib/watch-url.ts @@ -11,6 +11,31 @@ function hostMatches(host: string, domain: string): boolean { return host === domain || host.endsWith(`.${domain}`); } +function parseUrl(value: string): URL | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + return new URL(trimmed); + } catch { + try { + return new URL(`https://${trimmed}`); + } catch { + return null; + } + } +} + +function isSupportedVideoHost(host: string): boolean { + return ( + host === "youtu.be" || + hostMatches(host, "youtube.com") || + host === "nico.ms" || + hostMatches(host, "nicovideo.jp") || + host === "b23.tv" || + hostMatches(host, "bilibili.com") + ); +} + function youtubeIdFromPath(pathname: string): string | null { const segments = pathname.split("/").filter(Boolean); const nestedVideoPath = @@ -20,43 +45,39 @@ function youtubeIdFromPath(pathname: string): string | null { } function youtubeVideoIdFromUrl(value: string): string | null { - try { - const parsed = new URL(value); - const host = parsed.hostname.toLowerCase(); - if (host === "youtu.be") return youtubeIdFromPath(parsed.pathname); - if (!hostMatches(host, "youtube.com")) return null; - const watchId = parsed.searchParams.get("v"); - if (watchId && YOUTUBE_VIDEO_ID_PATTERN.test(watchId)) return watchId; - return youtubeIdFromPath(parsed.pathname); - } catch { - return null; - } + const parsed = parseUrl(value); + if (!parsed) return null; + const host = parsed.hostname.toLowerCase(); + if (host === "youtu.be") return youtubeIdFromPath(parsed.pathname); + if (!hostMatches(host, "youtube.com")) return null; + const watchId = parsed.searchParams.get("v"); + if (watchId && YOUTUBE_VIDEO_ID_PATTERN.test(watchId)) return watchId; + return youtubeIdFromPath(parsed.pathname); } function niconicoVideoIdFromUrl(value: string): string | null { - try { - const parsed = new URL(value); - if (!hostMatches(parsed.hostname.toLowerCase(), "nicovideo.jp")) return null; - const segments = parsed.pathname.split("/").filter(Boolean); - const candidate = segments[0] === "watch" ? segments[1] : null; - return candidate && NICONICO_VIDEO_ID_PATTERN.test(candidate) ? candidate : null; - } catch { - return null; - } + const parsed = parseUrl(value); + if (!parsed || !hostMatches(parsed.hostname.toLowerCase(), "nicovideo.jp")) return null; + const segments = parsed.pathname.split("/").filter(Boolean); + const candidate = segments[0] === "watch" ? segments[1] : null; + return candidate && NICONICO_VIDEO_ID_PATTERN.test(candidate) ? candidate : null; } function bilibiliWatchParamFromUrl(value: string): string | null { - try { - const parsed = new URL(value); - if (!hostMatches(parsed.hostname.toLowerCase(), "bilibili.com")) return null; - const segments = parsed.pathname.split("/").filter(Boolean); - const candidate = segments[0] === "video" ? segments[1] : null; - if (!candidate || !BILIBILI_VIDEO_ID_PATTERN.test(candidate)) return null; - const page = Number(parsed.searchParams.get("p") ?? "1"); - return Number.isSafeInteger(page) && page > 1 ? `${candidate}?p=${page}` : candidate; - } catch { - return null; - } + const parsed = parseUrl(value); + if (!parsed || !hostMatches(parsed.hostname.toLowerCase(), "bilibili.com")) return null; + const segments = parsed.pathname.split("/").filter(Boolean); + const candidate = segments[0] === "video" ? segments[1] : null; + if (!candidate || !BILIBILI_VIDEO_ID_PATTERN.test(candidate)) return null; + const page = Number(parsed.searchParams.get("p") ?? "1"); + return Number.isSafeInteger(page) && page > 1 ? `${candidate}?p=${page}` : candidate; +} + +export function isYoutubeShortShareUrl(value: string): boolean { + const parsed = parseUrl(value); + return Boolean( + parsed && parsed.hostname.toLowerCase() === "youtu.be" && youtubeIdFromPath(parsed.pathname), + ); } export function youtubeVideoId(value: string): string | null { @@ -83,6 +104,10 @@ export function toWatchSourceUrl(value: string): string { const suffix = Number.isSafeInteger(page) && page > 1 ? `?p=${page}` : ""; return `https://www.bilibili.com/video/${bilibili[1]}${suffix}`; } + const parsed = parseUrl(trimmed); + if (parsed && isSupportedVideoHost(parsed.hostname.toLowerCase())) { + return `${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`; + } return trimmed; } diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index ff63a310..28e61643 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -15,7 +15,12 @@ import { proxyImage } from "../lib/proxy"; import { videoAvailabilityCopy } from "../lib/video-availability"; import { resolveWatchStartTime, shouldWaitForWatchProgress } from "../lib/watch-resume"; import { shouldLoadFullWatchStream } from "../lib/watch-stream-loading"; -import { toPublicWatchParam, toWatchSourceUrl, youtubeThumbnailUrl } from "../lib/watch-url"; +import { + isYoutubeShortShareUrl, + toPublicWatchParam, + toWatchSourceUrl, + youtubeThumbnailUrl, +} from "../lib/watch-url"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; const WatchLayout = lazy(() => @@ -27,6 +32,7 @@ function WatchPage() { const navigate = useNavigate({ from: "/watch" }); const sourceUrl = toWatchSourceUrl(v); const publicParam = toPublicWatchParam(sourceUrl); + const shortShareUrl = isYoutubeShortShareUrl(v); const { authReady, isAuthed } = useAuth(); const { isPending: instancePending } = useInstance(); const { settings, settingsReady } = useSettings(); @@ -51,6 +57,7 @@ function WatchPage() { publicParam, previewRelated, ); + const fullStream = streamQuery.isPlaceholderData ? undefined : streamQuery.data; useDocumentTitle(activeStream?.title ?? previewStream?.title); const loadingPage = ( { - if (v.trim() && publicParam !== v.trim()) { + if (v.trim() && publicParam !== v.trim() && (!shortShareUrl || list || shuffle)) { navigate({ search: (prev) => ({ ...prev, v: publicParam }), replace: true }); } - }, [navigate, publicParam, v]); + }, [list, navigate, publicParam, shortShareUrl, shuffle, v]); + + useEffect(() => { + if (!shortShareUrl || list || shuffle || !fullStream || resumePending) return; + if (fullStream.isShortFormContent) { + void navigate({ to: "/shorts", search: { v: publicParam }, replace: true }); + return; + } + void navigate({ search: (prev) => ({ ...prev, v: publicParam }), replace: true }); + }, [fullStream, list, navigate, publicParam, resumePending, shortShareUrl, shuffle]); useEffect(() => { if (!activeStream || resumePending) return; diff --git a/apps/web/tests/watch-url.test.ts b/apps/web/tests/watch-url.test.ts index 32b0f0db..404544aa 100644 --- a/apps/web/tests/watch-url.test.ts +++ b/apps/web/tests/watch-url.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { + isYoutubeShortShareUrl, toPublicWatchParam, toWatchSourceUrl, watchServiceId, @@ -23,6 +24,18 @@ test("normalizes a YouTube short URL while ignoring share parameters", () => { expect(toWatchSourceUrl(sourceUrl)).toBe(sourceUrl); }); +test("normalizes YouTube Shorts links without a protocol", () => { + const sourceUrl = "youtube.com/shorts/2-J9d2VbA6o?si=abc"; + expect(toPublicWatchParam(sourceUrl)).toBe("2-J9d2VbA6o"); + expect(toWatchSourceUrl(sourceUrl)).toBe("https://youtube.com/shorts/2-J9d2VbA6o?si=abc"); +}); + +test("identifies youtu.be share links as Shorts candidates without guessing their type", () => { + expect(isYoutubeShortShareUrl("youtu.be/2-J9d2VbA6o?si=abc")).toBe(true); + expect(isYoutubeShortShareUrl("https://www.youtube.com/shorts/2-J9d2VbA6o")).toBe(false); + expect(isYoutubeShortShareUrl("https://youtu.be/not-video")).toBe(false); +}); + test("shortens and expands NicoNico watch URLs", () => { expect(toPublicWatchParam("https://www.nicovideo.jp/watch/sm46525483")).toBe("sm46525483"); expect(toWatchSourceUrl("sm46525483")).toBe("https://www.nicovideo.jp/watch/sm46525483"); From 6d5db72a2b2397215a7d37ddcc568e2debb4e3cc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 08:24:42 +0200 Subject: [PATCH 015/118] feat: show playback progress on video cards --- .../src/components/channel-page-content.tsx | 8 +++- apps/web/src/components/history-card.tsx | 2 +- .../web/src/components/playlist-video-row.tsx | 2 +- apps/web/src/components/related-card.tsx | 18 +++++++- apps/web/src/components/related-videos.tsx | 9 +++- .../src/components/search-results-grid.tsx | 25 +++++++++-- apps/web/src/components/video-card.tsx | 43 +++++++++++++++---- apps/web/src/components/video-grid.tsx | 4 ++ apps/web/src/hooks/use-progress.ts | 22 +++++++++- apps/web/src/lib/api-collections.ts | 17 ++++++++ apps/web/src/lib/video-progress.ts | 30 +++++++++++++ apps/web/tests/video-progress.test.ts | 35 +++++++++++++++ 12 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/lib/video-progress.ts create mode 100644 apps/web/tests/video-progress.test.ts diff --git a/apps/web/src/components/channel-page-content.tsx b/apps/web/src/components/channel-page-content.tsx index 94503d79..c6ac5745 100644 --- a/apps/web/src/components/channel-page-content.tsx +++ b/apps/web/src/components/channel-page-content.tsx @@ -2,11 +2,13 @@ import { useMemo } from "react"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useChannel } from "../hooks/use-channel"; import { useDocumentTitle } from "../hooks/use-document-title"; +import { useVideoProgressMap } from "../hooks/use-progress"; import { useSubscriptions } from "../hooks/use-subscriptions"; import { isChannelNotAllowedError } from "../lib/allow-list-error"; import type { ChannelSort } from "../lib/api-discovery"; import type { ChannelTab } from "../lib/channel-route-url"; import { detectProvider } from "../lib/provider"; +import { videoProgressUrl } from "../lib/video-progress"; import { m } from "../paraglide/messages.js"; import { ChannelFilterBar } from "./channel-filter-bar"; import { ChannelPageHeader } from "./channel-page-header"; @@ -47,6 +49,7 @@ export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNaviga const subscribed = isSubscribed(sourceUrl); const searchAvailable = detectProvider(sourceUrl) === "youtube"; const visibleVideos = useMemo(() => filter(videos), [filter, videos]); + const progressByUrl = useVideoProgressMap(visibleVideos); const isInitialLoading = isLoading && !meta; const isReplacingVideos = isFetching && !isFetchingNextPage && visibleVideos.length === 0; @@ -144,7 +147,10 @@ export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNaviga className="animate-card-pop-in" style={{ animationDelay: `${Math.min(index * 45, 270)}ms` }} > - +
))}
diff --git a/apps/web/src/components/history-card.tsx b/apps/web/src/components/history-card.tsx index 0915c39d..89dc2780 100644 --- a/apps/web/src/components/history-card.tsx +++ b/apps/web/src/components/history-card.tsx @@ -41,7 +41,7 @@ export function HistoryCard({ item, onRemove }: HistoryCardProps) { {branding.title} diff --git a/apps/web/src/components/playlist-video-row.tsx b/apps/web/src/components/playlist-video-row.tsx index c3ba244c..a6f60034 100644 --- a/apps/web/src/components/playlist-video-row.tsx +++ b/apps/web/src/components/playlist-video-row.tsx @@ -60,7 +60,7 @@ export function PlaylistVideoRow({ video, onRemove, reorderable, listId, onDragS {branding.title} diff --git a/apps/web/src/components/related-card.tsx b/apps/web/src/components/related-card.tsx index 9a4f47d5..784fa051 100644 --- a/apps/web/src/components/related-card.tsx +++ b/apps/web/src/components/related-card.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { useClientLocale } from "../hooks/use-client-locale"; import { useDeArrowBranding } from "../hooks/use-dearrow"; import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; +import { isVideoWatched } from "../lib/watch-progress"; import { watchRouteSearch } from "../lib/watch-url"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; import type { VideoStream } from "../types/stream"; @@ -10,15 +11,18 @@ import { ChannelAvatar } from "./channel-avatar"; import { ChannelRouteLink } from "./channel-route-link"; import { VideoCardFeedbackMenu } from "./video-card-feedback-menu"; import { VideoMembershipBadge } from "./video-membership-badge"; +import { VideoProgressBar } from "./video-progress-bar"; import { VideoStatusBadge } from "./video-status-badge"; import { VerifiedBadgeIcon } from "./watch-icons"; +import { WatchedBadge } from "./watched-badge"; type Props = { stream: VideoStream; relatedStreams?: VideoStream[]; + progressMs?: number; }; -function RelatedCardComponent({ stream, relatedStreams }: Props) { +function RelatedCardComponent({ stream, relatedStreams, progressMs = 0 }: Props) { const locale = useClientLocale(); const setNavigation = useWatchNavigationStore((state) => state.setNavigation); const { title, thumbnail } = useDeArrowBranding( @@ -29,6 +33,8 @@ function RelatedCardComponent({ stream, relatedStreams }: Props) { ); const publishedText = formatPublishedDate(stream.publishedAt, undefined, locale); const metadata = [formatViews(stream.views), publishedText].filter(Boolean).join(" · "); + const progressSeconds = Math.max(0, progressMs / 1_000); + const watched = !stream.isLive && isVideoWatched(progressSeconds, stream.duration); return (
@@ -42,7 +48,7 @@ function RelatedCardComponent({ stream, relatedStreams }: Props) { {title} @@ -56,11 +62,19 @@ function RelatedCardComponent({ stream, relatedStreams }: Props) { )} + {watched && ( + + + + )} {!stream.isLive && stream.duration > 0 && ( {formatDuration(stream.duration)} )} + {!stream.isLive && ( + + )}
uniqueStreams(filter(streams)), [filter, streams]); + const progressByUrl = useVideoProgressMap(visible); return (
@@ -37,7 +40,11 @@ export function RelatedVideos({ streams, isLoading = false }: Props) { className="animate-card-pop-in" style={{ animationDelay: `${Math.min(index * 35, 210)}ms` }} > - +
))}
diff --git a/apps/web/src/components/search-results-grid.tsx b/apps/web/src/components/search-results-grid.tsx index ecc17c8f..ef63f503 100644 --- a/apps/web/src/components/search-results-grid.tsx +++ b/apps/web/src/components/search-results-grid.tsx @@ -1,3 +1,6 @@ +import { useMemo } from "react"; +import { useVideoProgressMap } from "../hooks/use-progress"; +import { videoProgressUrl } from "../lib/video-progress"; import type { ChannelResultItem } from "../types/api"; import type { PublicPlaylistInfo } from "../types/playlist"; import type { VideoStream } from "../types/stream"; @@ -19,12 +22,16 @@ function itemKey(item: SearchResultItem): string { function ItemCard({ item, relatedStreams, + progressMs, }: { item: SearchResultItem; relatedStreams: VideoStream[]; + progressMs?: number; }) { if (item.kind === "video") - return ; + return ( + + ); if (item.kind === "channel") return ; return ; } @@ -34,7 +41,11 @@ type Props = { }; export function SearchResultsGrid({ items }: Props) { - const relatedStreams = items.flatMap((item) => (item.kind === "video" ? [item.stream] : [])); + const relatedStreams = useMemo( + () => items.flatMap((item) => (item.kind === "video" ? [item.stream] : [])), + [items], + ); + const progressByUrl = useVideoProgressMap(relatedStreams); return (
{items.map((item, index) => ( @@ -43,7 +54,15 @@ export function SearchResultsGrid({ items }: Props) { className="animate-card-pop-in" style={{ animationDelay: `${Math.min(index * 45, 270)}ms` }} > - +
))}
diff --git a/apps/web/src/components/video-card.tsx b/apps/web/src/components/video-card.tsx index d96f86a4..04449f72 100644 --- a/apps/web/src/components/video-card.tsx +++ b/apps/web/src/components/video-card.tsx @@ -4,6 +4,7 @@ import { useClientLocale } from "../hooks/use-client-locale"; import { useDeArrowBranding } from "../hooks/use-dearrow"; import { useVideoCardPreview } from "../hooks/use-video-card-preview"; import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; +import { isVideoWatched } from "../lib/watch-progress"; import { watchListSearch } from "../lib/watch-url"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; import type { VideoStream } from "../types/stream"; @@ -12,8 +13,10 @@ import { ChannelRouteLink } from "./channel-route-link"; import { VideoCardFeedbackMenu } from "./video-card-feedback-menu"; import { VideoMembershipBadge } from "./video-membership-badge"; import { VideoPreview } from "./video-preview"; +import { VideoProgressBar } from "./video-progress-bar"; import { VideoStatusBadge } from "./video-status-badge"; import { VerifiedBadgeIcon } from "./watch-icons"; +import { WatchedBadge } from "./watched-badge"; type Props = { stream: VideoStream; @@ -21,9 +24,17 @@ type Props = { onImpression?: () => void; listId?: string; relatedStreams?: VideoStream[]; + progressMs?: number; }; -function VideoCardComponent({ stream, onOpen, onImpression, listId, relatedStreams }: Props) { +function VideoCardComponent({ + stream, + onOpen, + onImpression, + listId, + relatedStreams, + progressMs = 0, +}: Props) { const locale = useClientLocale(); const rootRef = useRef(null); const setNavigation = useWatchNavigationStore((state) => state.setNavigation); @@ -35,6 +46,8 @@ function VideoCardComponent({ stream, onOpen, onImpression, listId, relatedStrea stream.duration, ); const publishedText = formatPublishedDate(stream.publishedAt, undefined, locale); + const progressSeconds = Math.max(0, progressMs / 1_000); + const watched = !stream.isLive && isVideoWatched(progressSeconds, stream.duration); const watchSearch = watchListSearch(stream.id, listId); const handleOpen = useCallback(() => { setNavigation(stream, relatedStreams); @@ -78,14 +91,18 @@ function VideoCardComponent({ stream, onOpen, onImpression, listId, relatedStrea onClick={handleOpen} >
- {title} - +
+ {title} + +
{preview.memberOnly && ( @@ -96,11 +113,19 @@ function VideoCardComponent({ stream, onOpen, onImpression, listId, relatedStrea )} + {watched && ( + + + + )} {!stream.isLive && stream.duration > 0 && ( {formatDuration(stream.duration)} )} + {!stream.isLive && ( + + )}
diff --git a/apps/web/src/components/video-grid.tsx b/apps/web/src/components/video-grid.tsx index 4bfd80a6..19f91cab 100644 --- a/apps/web/src/components/video-grid.tsx +++ b/apps/web/src/components/video-grid.tsx @@ -1,4 +1,6 @@ import { useMemo } from "react"; +import { useVideoProgressMap } from "../hooks/use-progress"; +import { videoProgressUrl } from "../lib/video-progress"; import type { VideoStream } from "../types/stream"; import { VideoCard } from "./video-card"; @@ -20,6 +22,7 @@ export function VideoGrid({ streams, onCardOpen, onCardImpression, listId }: Vid } return result; }, [streams]); + const progressByUrl = useVideoProgressMap(unique); return (
{unique.map((stream, index) => ( @@ -34,6 +37,7 @@ export function VideoGrid({ streams, onCardOpen, onCardImpression, listId }: Vid onImpression={onCardImpression ? () => onCardImpression(stream) : undefined} listId={listId} relatedStreams={unique} + progressMs={progressByUrl.get(videoProgressUrl(stream))?.position} />
))} diff --git a/apps/web/src/hooks/use-progress.ts b/apps/web/src/hooks/use-progress.ts index e3e7e686..9212dace 100644 --- a/apps/web/src/hooks/use-progress.ts +++ b/apps/web/src/hooks/use-progress.ts @@ -1,12 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { fetchProgress, updateProgress } from "../lib/api-collections"; +import { useMemo } from "react"; +import { fetchProgress, fetchProgressBatch, updateProgress } from "../lib/api-collections"; import { type HistoryPageData, type HistoryPagesData, updateHistoryPageProgress, updateHistoryPagesProgress, } from "../lib/history-progress-cache"; +import { progressItemsByUrl, updateProgressItems, videoProgressUrl } from "../lib/video-progress"; import { useAuthStore } from "../stores/auth-store"; +import type { VideoStream } from "../types/stream"; import type { ProgressItem } from "../types/user"; import { useAuth } from "./use-auth"; @@ -25,6 +28,20 @@ export function useProgress(videoUrl: string) { }); } +export function useVideoProgressMap(streams: VideoStream[]): Map { + const { authReady, isAuthed } = useAuth(); + const videoUrls = useMemo(() => [...new Set(streams.map(videoProgressUrl))].sort(), [streams]); + const query = useQuery({ + queryKey: ["progress-batch", videoUrls], + queryFn: () => fetchProgressBatch(videoUrls), + enabled: authReady && isAuthed && videoUrls.length > 0, + staleTime: 30_000, + refetchOnReconnect: true, + refetchOnWindowFocus: false, + }); + return useMemo(() => progressItemsByUrl(query.data ?? []), [query.data]); +} + export function useSaveProgress(videoUrl: string) { const { authReady, isAuthed } = useAuth(); const qc = useQueryClient(); @@ -41,6 +58,9 @@ export function useSaveProgress(videoUrl: string) { updatedAt: Date.now(), }; qc.setQueryData(["progress", videoUrl], next); + qc.setQueriesData({ queryKey: ["progress-batch"] }, (items) => + updateProgressItems(items, next), + ); qc.setQueriesData({ queryKey: ["history"] }, (data) => updateHistoryPagesProgress(data, videoUrl, position), ); diff --git a/apps/web/src/lib/api-collections.ts b/apps/web/src/lib/api-collections.ts index f339653a..51a462c1 100644 --- a/apps/web/src/lib/api-collections.ts +++ b/apps/web/src/lib/api-collections.ts @@ -11,6 +11,7 @@ import { authed, authedJson } from "./authed"; import { API_BASE as BASE } from "./env"; import { progressWriteQueue } from "./progress-write-queue"; +import { progressBatches } from "./video-progress"; async function throwIfFailed(res: Response, fallback: string): Promise { if (res.ok) return; @@ -29,6 +30,22 @@ export async function fetchProgress(videoUrl: string): Promise { return body as ProgressItem; } +export async function fetchProgressBatch(videoUrls: string[]): Promise { + await Promise.all(videoUrls.map((videoUrl) => progressWriteQueue.settle(videoUrl))); + const pages = await Promise.all( + progressBatches(videoUrls).map(async (batch) => { + const res = await authed(`${BASE}/progress/batch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ videoUrls: batch }), + }); + await throwIfFailed(res, "progress lookup failed"); + return (await res.json()) as ProgressItem[]; + }), + ); + return pages.flat(); +} + export async function updateProgress( videoUrl: string, position: number, diff --git a/apps/web/src/lib/video-progress.ts b/apps/web/src/lib/video-progress.ts new file mode 100644 index 00000000..2dd08f20 --- /dev/null +++ b/apps/web/src/lib/video-progress.ts @@ -0,0 +1,30 @@ +import type { VideoStream } from "../types/stream"; +import type { ProgressItem } from "../types/user"; +import { toPublicWatchParam, toWatchSourceUrl } from "./watch-url"; + +const PROGRESS_BATCH_SIZE = 200; + +export function videoProgressUrl(stream: Pick): string { + return toWatchSourceUrl(toPublicWatchParam(stream.id)); +} + +export function progressBatches(videoUrls: string[]): string[][] { + const urls = [...new Set(videoUrls.filter(Boolean))]; + const batches: string[][] = []; + for (let index = 0; index < urls.length; index += PROGRESS_BATCH_SIZE) { + batches.push(urls.slice(index, index + PROGRESS_BATCH_SIZE)); + } + return batches; +} + +export function progressItemsByUrl(items: ProgressItem[]): Map { + return new Map(items.map((item) => [item.videoUrl, item])); +} + +export function updateProgressItems( + items: ProgressItem[] | undefined, + next: ProgressItem, +): ProgressItem[] | undefined { + if (!items?.some((item) => item.videoUrl === next.videoUrl)) return items; + return items.map((item) => (item.videoUrl === next.videoUrl ? next : item)); +} diff --git a/apps/web/tests/video-progress.test.ts b/apps/web/tests/video-progress.test.ts new file mode 100644 index 00000000..837b2526 --- /dev/null +++ b/apps/web/tests/video-progress.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { + progressBatches, + progressItemsByUrl, + updateProgressItems, + videoProgressUrl, +} from "../src/lib/video-progress"; +import type { ProgressItem } from "../src/types/user"; + +test("uses the same canonical URL as the watch route", () => { + expect(videoProgressUrl({ id: "RjdGmIUbYIQ" })).toBe( + "https://www.youtube.com/watch?v=RjdGmIUbYIQ", + ); + expect(videoProgressUrl({ id: "https://youtu.be/RjdGmIUbYIQ?si=share" })).toBe( + "https://www.youtube.com/watch?v=RjdGmIUbYIQ", + ); +}); + +test("batches unique progress URLs without dropping entries", () => { + const urls = Array.from({ length: 201 }, (_, index) => `https://video.test/${index}`); + const batches = progressBatches([...urls, urls[0]]); + expect(batches.map((batch) => batch.length)).toEqual([200, 1]); + expect(batches.flat()).toEqual(urls); +}); + +test("indexes and updates the exact saved position", () => { + const initial: ProgressItem[] = [ + { videoUrl: "https://video.test/one", position: 12_345, updatedAt: 1 }, + { videoUrl: "https://video.test/two", position: 0, updatedAt: 0 }, + ]; + const next = { videoUrl: "https://video.test/two", position: 67_890, updatedAt: 2 }; + const updated = updateProgressItems(initial, next) ?? []; + expect(progressItemsByUrl(updated).get(next.videoUrl)).toEqual(next); + expect(updated[0]).toEqual(initial[0]); +}); From 4b60155d53844c3807a4a0972316c30446974a3b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 09:40:54 +0200 Subject: [PATCH 016/118] feat: add provider source link sharing --- apps/web/messages/en.json | 2 + apps/web/messages/fr.json | 2 + apps/web/src/components/share-sheet.tsx | 118 +++++++++++++++++++++ apps/web/src/components/shorts-actions.tsx | 18 +++- apps/web/src/components/watch-actions.tsx | 13 ++- apps/web/src/hooks/use-share-url.ts | 11 +- apps/web/src/lib/provider.ts | 7 +- apps/web/src/lib/share-link.ts | 23 ++++ apps/web/tests/share-link.test.ts | 27 +++++ 9 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/share-sheet.tsx create mode 100644 apps/web/src/lib/share-link.ts create mode 100644 apps/web/tests/share-link.test.ts diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index bd3e971a..3c8a4e74 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -99,6 +99,8 @@ "watch_saved_later": "Saved to Watch later", "watch_saving": "Saving...", "watch_share": "Share", + "watch_share_source_link": "Share {provider} link", + "watch_share_typetype_link": "Share TypeType link", "watch_show_less": "Show less", "watch_show_more": "Show more", "watch_show_replies": "Show replies", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index b280d451..d46287be 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -99,6 +99,8 @@ "watch_saved_later": "Ajouté à À regarder plus tard", "watch_saving": "Enregistrement...", "watch_share": "Partager", + "watch_share_source_link": "Partager le lien {provider}", + "watch_share_typetype_link": "Partager le lien TypeType", "watch_show_less": "Afficher moins", "watch_show_more": "Afficher plus", "watch_show_replies": "Afficher les réponses", diff --git a/apps/web/src/components/share-sheet.tsx b/apps/web/src/components/share-sheet.tsx new file mode 100644 index 00000000..64309f50 --- /dev/null +++ b/apps/web/src/components/share-sheet.tsx @@ -0,0 +1,118 @@ +import { useEffect } from "react"; +import { siBilibili, siNiconico, siYoutube } from "simple-icons"; +import { useInterfaceLocale } from "../hooks/use-interface-locale"; +import { getSourceShareTarget, type ShareProvider } from "../lib/share-link"; +import { m } from "../paraglide/messages.js"; +import { ServiceIcon } from "./service-icon"; +import { ShareIcon } from "./watch-icons"; + +type Props = { + sourceUrl: string; + typetypeUrl: string; + title: string; + onShare: (url: string, title: string) => void; + onClose: () => void; +}; + +const PROVIDER_ICONS: Record = { + youtube: { path: siYoutube.path, color: "#FF0000" }, + nicovideo: { path: siNiconico.path, color: "#aaaaaa" }, + bilibili: { path: siBilibili.path, color: "#00A1D6" }, +}; + +function ShareOption({ + icon, + label, + url, + onClick, +}: { + icon: React.ReactNode; + label: string; + url: string; + onClick: () => void; +}) { + return ( + + ); +} + +export function ShareSheet({ sourceUrl, typetypeUrl, title, onShare, onClose }: Props) { + const { locale } = useInterfaceLocale(); + const source = getSourceShareTarget(sourceUrl); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", onKeyDown); + }; + }, [onClose]); + + return ( +
+ +
+
+ } + label={m.watch_share_typetype_link({}, { locale })} + url={typetypeUrl} + onClick={() => { + onClose(); + onShare(typetypeUrl, title); + }} + /> + {source && ( + + } + label={m.watch_share_source_link({ provider: source.label }, { locale })} + url={source.url} + onClick={() => { + onClose(); + onShare(source.url, title); + }} + /> + )} +
+ +
+ ); +} diff --git a/apps/web/src/components/shorts-actions.tsx b/apps/web/src/components/shorts-actions.tsx index 8f93c46d..e8c089cc 100644 --- a/apps/web/src/components/shorts-actions.tsx +++ b/apps/web/src/components/shorts-actions.tsx @@ -1,4 +1,5 @@ import { Clock3, MessageCircle, Share2, Star } from "lucide-react"; +import { useState } from "react"; import { useAuth } from "../hooks/use-auth"; import { useFavoriteStatus } from "../hooks/use-favorite-status"; import { useShareUrl } from "../hooks/use-share-url"; @@ -7,6 +8,7 @@ import { shortsRouteKey, toPublicShortsUrl } from "../lib/shorts-route"; import { toWatchLaterPayload } from "../lib/watch-later-mappers"; import { m } from "../paraglide/messages.js"; import type { VideoStream } from "../types/stream"; +import { ShareSheet } from "./share-sheet"; import { ShortsActionButton } from "./shorts-action-button"; type Props = { @@ -26,6 +28,7 @@ export function ShortsActions({ }: Props) { const { isAuthed } = useAuth(); const { copied, share } = useShareUrl(); + const [shareOpen, setShareOpen] = useState(false); const { add: addFavorite, remove: removeFavorite, @@ -57,10 +60,6 @@ export function ShortsActions({ await watchLater.toggle(toWatchLaterPayload(stream)); } - function handleShare() { - void share(toPublicShortsUrl(stream.id, window.location.origin)); - } - return (
setShareOpen(true)} /> + {shareOpen && ( + void share(url, title)} + onClose={() => setShareOpen(false)} + /> + )}
); } diff --git a/apps/web/src/components/watch-actions.tsx b/apps/web/src/components/watch-actions.tsx index 98cb075f..210b09b7 100644 --- a/apps/web/src/components/watch-actions.tsx +++ b/apps/web/src/components/watch-actions.tsx @@ -14,6 +14,7 @@ import { DanmakuControls } from "./danmaku-controls"; import { DownloadSheet } from "./download-sheet"; import { PlaylistAddDropdown } from "./playlist-add-dropdown"; import { ReportBugModal } from "./report-bug-modal"; +import { ShareSheet } from "./share-sheet"; import { Toast } from "./toast"; import { WatchActionButton } from "./watch-action-button"; import { @@ -36,6 +37,7 @@ export function WatchActions({ stream, audioOnly }: Props) { const [playlistOpen, setPlaylistOpen] = useState(false); const [downloadOpen, setDownloadOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); const [toastLabel, setToastLabel] = useState(null); const saveAnchorRef = useRef(null); const { authReady, isAuthed } = useAuth(); @@ -113,11 +115,20 @@ export function WatchActions({ stream, audioOnly }: Props) { : m.watch_audio_only({}, { locale })} )} - share(toPublicWatchUrl(stream.id, window.location.origin))}> + setShareOpen(true)}> {m.watch_share({}, { locale })} + {shareOpen && ( + void share(url, title)} + onClose={() => setShareOpen(false)} + /> + )} {showSave && ( + return createPortal( +
+
+
+ TypeType +

+ {m.watch_share({}, { locale })} +

-
+ +
+
+ } + label={m.watch_share_typetype_link({}, { locale })} + url={typetypeUrl} + onClick={() => { + onClose(); + onShare(typetypeUrl, title); + }} + /> + {source && ( } - label={m.watch_share_typetype_link({}, { locale })} - url={typetypeUrl} + icon={ + + } + label={m.watch_share_source_link({ provider: source.label }, { locale })} + url={source.url} onClick={() => { onClose(); - onShare(typetypeUrl, title); + onShare(source.url, title); }} /> - {source && ( - - } - label={m.watch_share_source_link({ provider: source.label }, { locale })} - url={source.url} - onClick={() => { - onClose(); - onShare(source.url, title); - }} - /> - )} -
- -
+ )} +
+
, + document.body, ); } diff --git a/apps/web/src/components/shorts-action-button.tsx b/apps/web/src/components/shorts-action-button.tsx index 80baacc9..f8ab181b 100644 --- a/apps/web/src/components/shorts-action-button.tsx +++ b/apps/web/src/components/shorts-action-button.tsx @@ -1,4 +1,5 @@ type Props = { + buttonRef?: React.Ref; icon: React.ComponentType<{ className?: string }>; label: string; stateLabel?: string; @@ -9,6 +10,7 @@ type Props = { }; export function ShortsActionButton({ + buttonRef, icon: Icon, label, stateLabel, @@ -28,6 +30,7 @@ export function ShortsActionButton({ const activeClass = compact ? "border-white/80 bg-white text-black" : "border-fg bg-fg text-app"; return ( ); } @@ -67,8 +65,7 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o const panel = panelRef.current.getBoundingClientRect(); const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; - let left = anchor.left; - if (left + panel.width > vw - MARGIN) left = anchor.right - panel.width; + let left = anchor.right - panel.width; left = Math.max(MARGIN, Math.min(left, vw - panel.width - MARGIN)); const spaceBelow = vh - anchor.bottom - MARGIN; const spaceAbove = anchor.top - MARGIN; @@ -107,31 +104,15 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o
-
-
- TypeType -

- {m.watch_share({}, { locale })} -

-
- -
-
+
} label={m.watch_share_typetype_link({}, { locale })} - url={typetypeUrl} onClick={() => { onClose(); onShare(typetypeUrl, title); @@ -147,7 +128,6 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o /> } label={m.watch_share_source_link({ provider: source.label }, { locale })} - url={source.url} onClick={() => { onClose(); onShare(source.url, title); From 9cba24ce683194a96c1fbae78894c96bd03b1248 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 17:00:01 +0200 Subject: [PATCH 019/118] style: use icon-only share menu --- apps/web/src/components/share-sheet.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/share-sheet.tsx b/apps/web/src/components/share-sheet.tsx index ee9cd75e..d7c3248f 100644 --- a/apps/web/src/components/share-sheet.tsx +++ b/apps/web/src/components/share-sheet.tsx @@ -1,4 +1,3 @@ -import { ChevronRight } from "lucide-react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { siBilibili, siNiconico, siYoutube } from "simple-icons"; @@ -38,13 +37,13 @@ function ShareOption({ type="button" onClick={onClick} role="menuitem" - className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left text-sm text-fg transition-colors hover:bg-surface-strong focus-visible:bg-surface-strong focus-visible:outline-none" + aria-label={label} + title={label} + className="group flex h-10 w-10 items-center justify-center rounded-md text-fg transition-colors hover:bg-surface-strong focus-visible:bg-surface-strong focus-visible:outline-none" > - + {icon} - {label} - ); } @@ -107,9 +106,9 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o aria-orientation="vertical" aria-label={m.watch_share({}, { locale })} style={panelStyle} - className="fixed z-50 w-64 overflow-hidden rounded-lg border border-border-strong bg-surface p-1 shadow-2xl [animation:dropdown-fade-in_0.15s_ease-out]" + className="fixed z-50 overflow-hidden rounded-lg border border-border-strong bg-surface p-1 shadow-2xl [animation:dropdown-fade-in_0.15s_ease-out]" > -
+
} label={m.watch_share_typetype_link({}, { locale })} From 3233a74e37f994f95cd334fb4a06df199415838c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 17:37:27 +0200 Subject: [PATCH 020/118] style: use compact share chips --- apps/web/src/components/share-sheet.tsx | 37 +++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/share-sheet.tsx b/apps/web/src/components/share-sheet.tsx index d7c3248f..308754d9 100644 --- a/apps/web/src/components/share-sheet.tsx +++ b/apps/web/src/components/share-sheet.tsx @@ -39,7 +39,7 @@ function ShareOption({ role="menuitem" aria-label={label} title={label} - className="group flex h-10 w-10 items-center justify-center rounded-md text-fg transition-colors hover:bg-surface-strong focus-visible:bg-surface-strong focus-visible:outline-none" + className="group flex h-9 w-9 items-center justify-center rounded-full text-fg transition-colors hover:bg-surface-strong focus-visible:bg-surface-strong focus-visible:outline-none" > {icon} @@ -106,9 +106,9 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o aria-orientation="vertical" aria-label={m.watch_share({}, { locale })} style={panelStyle} - className="fixed z-50 overflow-hidden rounded-lg border border-border-strong bg-surface p-1 shadow-2xl [animation:dropdown-fade-in_0.15s_ease-out]" + className="fixed z-50 overflow-hidden rounded-full border border-border-strong bg-surface p-1.5 shadow-2xl [animation:dropdown-fade-in_0.15s_ease-out]" > -
+
} label={m.watch_share_typetype_link({}, { locale })} @@ -118,20 +118,23 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o }} /> {source && ( - - } - label={m.watch_share_source_link({ provider: source.label }, { locale })} - onClick={() => { - onClose(); - onShare(source.url, title); - }} - /> + <> +
, From cedaef1c5e7cb5564da4da7804d7545c1e9decbc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 18:22:22 +0200 Subject: [PATCH 021/118] fix: show share destination names --- apps/web/src/components/share-sheet.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/share-sheet.tsx b/apps/web/src/components/share-sheet.tsx index 308754d9..08a1810c 100644 --- a/apps/web/src/components/share-sheet.tsx +++ b/apps/web/src/components/share-sheet.tsx @@ -26,10 +26,12 @@ const PROVIDER_ICONS: Record = { function ShareOption({ icon, label, + name, onClick, }: { icon: React.ReactNode; label: string; + name: string; onClick: () => void; }) { return ( @@ -39,11 +41,12 @@ function ShareOption({ role="menuitem" aria-label={label} title={label} - className="group flex h-9 w-9 items-center justify-center rounded-full text-fg transition-colors hover:bg-surface-strong focus-visible:bg-surface-strong focus-visible:outline-none" + className="group flex h-9 items-center gap-1.5 rounded-full px-2.5 text-fg transition-colors hover:bg-surface-strong focus-visible:bg-surface-strong focus-visible:outline-none" > {icon} + {name} ); } @@ -112,6 +115,7 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o } label={m.watch_share_typetype_link({}, { locale })} + name="TypeType" onClick={() => { onClose(); onShare(typetypeUrl, title); @@ -129,6 +133,7 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o /> } label={m.watch_share_source_link({ provider: source.label }, { locale })} + name={source.label} onClick={() => { onClose(); onShare(source.url, title); From 1f8a168bd11c74dd4a424bf4e8e013d33456b8cf Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 18:30:51 +0200 Subject: [PATCH 022/118] fix: align share dropdown with trigger --- apps/web/src/components/share-sheet.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/share-sheet.tsx b/apps/web/src/components/share-sheet.tsx index 08a1810c..4639b4ba 100644 --- a/apps/web/src/components/share-sheet.tsx +++ b/apps/web/src/components/share-sheet.tsx @@ -67,7 +67,8 @@ export function ShareSheet({ anchorEl, sourceUrl, typetypeUrl, title, onShare, o const panel = panelRef.current.getBoundingClientRect(); const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; - let left = anchor.right - panel.width; + let left = anchor.left; + if (left + panel.width > vw - MARGIN) left = anchor.right - panel.width; left = Math.max(MARGIN, Math.min(left, vw - panel.width - MARGIN)); const spaceBelow = vh - anchor.bottom - MARGIN; const spaceAbove = anchor.top - MARGIN; From 158da3d5e3a1117082986d6e6459aceb847dabf8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 18:34:47 +0200 Subject: [PATCH 023/118] feat: keep watch player visible on scroll --- apps/web/src/components/watch-layout-classes.ts | 2 +- apps/web/src/styles/watch-mobile-landscape.css | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/watch-layout-classes.ts b/apps/web/src/components/watch-layout-classes.ts index 3af42c93..07d9371f 100644 --- a/apps/web/src/components/watch-layout-classes.ts +++ b/apps/web/src/components/watch-layout-classes.ts @@ -16,7 +16,7 @@ export function getWatchLayoutClasses(cinemaMode: boolean, hasSecondaryContent: }`, playerBoxClass: cinemaMode ? "watch-player-box relative mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]" - : "watch-player-box relative overflow-hidden rounded-lg", + : "watch-player-box watch-sticky-player relative overflow-hidden rounded-lg", playerClassName: cinemaMode ? "watch-player-surface w-full h-full dark [--video-aspect-ratio:16/9]" : "watch-player-surface", diff --git a/apps/web/src/styles/watch-mobile-landscape.css b/apps/web/src/styles/watch-mobile-landscape.css index 4b127d9d..0a4a568e 100644 --- a/apps/web/src/styles/watch-mobile-landscape.css +++ b/apps/web/src/styles/watch-mobile-landscape.css @@ -2,6 +2,12 @@ padding-top: calc(3.5rem + env(safe-area-inset-top, 0px)); } +.watch-sticky-player { + position: sticky; + top: calc(3.5rem + env(safe-area-inset-top, 0px)); + z-index: 30; +} + @media (orientation: landscape) and (max-height: 500px) and (hover: none) and (pointer: coarse) { .watch-page-shell .watch-page-chrome { display: none; @@ -29,6 +35,10 @@ border-radius: 0; } + .watch-page-shell .watch-sticky-player { + top: 0; + } + .watch-page-shell .watch-player-box > .aspect-video, .watch-page-shell .watch-player-surface { width: 100%; From b634c13e0074481c9f4552270e8ac69aaa3c5d0f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 18:40:34 +0200 Subject: [PATCH 024/118] fix: keep sticky player with related videos --- .../src/components/watch-layout-classes.ts | 2 +- apps/web/src/components/watch-layout.tsx | 24 ++++++++++++------- apps/web/src/components/watch-stage.tsx | 3 +++ apps/web/tests/watch-layout-classes.test.ts | 2 ++ 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/watch-layout-classes.ts b/apps/web/src/components/watch-layout-classes.ts index 07d9371f..ba83daf1 100644 --- a/apps/web/src/components/watch-layout-classes.ts +++ b/apps/web/src/components/watch-layout-classes.ts @@ -3,7 +3,7 @@ export type WatchLayoutClasses = ReturnType; export function getWatchLayoutClasses(cinemaMode: boolean, hasSecondaryContent: boolean) { const anim = "[animation:page-fade-in_0.2s_ease-out]"; const standardLayout = hasSecondaryContent - ? "pt-2 sm:pt-3 lg:flex-row lg:items-start" + ? "pt-2 sm:pt-3 lg:flex-row lg:items-stretch" : "pt-2 sm:pt-3 lg:items-center"; return { containerClass: `watch-layout-container flex flex-col gap-6 ${ diff --git a/apps/web/src/components/watch-layout.tsx b/apps/web/src/components/watch-layout.tsx index 6c44d6cc..d33cbe8a 100644 --- a/apps/web/src/components/watch-layout.tsx +++ b/apps/web/src/components/watch-layout.tsx @@ -122,6 +122,17 @@ export function WatchLayout({ cinemaMode, Boolean(!isMobile && (playlist.panel || relatedStreams.length > 0)), ); + const secondaryContent = ( + seekRef.current?.(seconds)} + audioOnly={audioOnly.controls} + /> + ); return (
0 ? secondaryContent : null + } seekRef={seekRef} audioOnlyControls={audioOnly.controls} onCaptionStylesChange={(captionStyles) => update.mutate({ captionStyles })} @@ -183,15 +197,7 @@ export function WatchLayout({ } onReset={player.reset} /> - seekRef.current?.(seconds)} - audioOnly={audioOnly.controls} - /> + {(!isMobile || cinemaMode) && secondaryContent}
); diff --git a/apps/web/src/components/watch-stage.tsx b/apps/web/src/components/watch-stage.tsx index f6b04d3b..57e229d6 100644 --- a/apps/web/src/components/watch-stage.tsx +++ b/apps/web/src/components/watch-stage.tsx @@ -39,6 +39,7 @@ type Props = { cinemaMode: boolean; hideComments: boolean; mobilePanel: ReactNode; + mobileSecondaryContent: ReactNode | null; seekRef: MutableRefObject<((seconds: number) => void) | null>; audioOnlyControls: WatchAudioOnlyControls; onCaptionStylesChange: (styles: CaptionStyles) => void; @@ -85,6 +86,7 @@ export function WatchStage({ cinemaMode, hideComments, mobilePanel, + mobileSecondaryContent, seekRef, audioOnlyControls, onCaptionStylesChange, @@ -180,6 +182,7 @@ export function WatchStage({ audioOnly={audioOnlyControls} /> )} + {mobileSecondaryContent ?
{mobileSecondaryContent}
: null}
); } diff --git a/apps/web/tests/watch-layout-classes.test.ts b/apps/web/tests/watch-layout-classes.test.ts index 1b70bfdd..260a52b8 100644 --- a/apps/web/tests/watch-layout-classes.test.ts +++ b/apps/web/tests/watch-layout-classes.test.ts @@ -7,6 +7,7 @@ test("exposes stable watch hooks without changing the player identity", () => { expect(classes.containerClass).toContain("watch-layout-container"); expect(classes.playerWrapClass).toContain("watch-player-wrap"); expect(classes.playerBoxClass).toContain("watch-player-box"); + expect(classes.playerBoxClass).toContain("watch-sticky-player"); expect(classes.playerClassName).toBe("watch-player-surface"); }); @@ -14,6 +15,7 @@ test("keeps cinema sizing alongside the mobile landscape hooks", () => { const classes = getWatchLayoutClasses(true, false); expect(classes.playerBoxClass).toContain("aspect-video"); + expect(classes.playerBoxClass).not.toContain("watch-sticky-player"); expect(classes.playerClassName).toContain("[--video-aspect-ratio:16/9]"); expect(classes.playerClassName).toContain("watch-player-surface"); }); From 213ecc4ad16727434b1795cf6e6fb1dcf435d182 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 18:47:28 +0200 Subject: [PATCH 025/118] fix: disable sticky player in landscape --- apps/web/src/styles/watch-mobile-landscape.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/styles/watch-mobile-landscape.css b/apps/web/src/styles/watch-mobile-landscape.css index 0a4a568e..f2cdc864 100644 --- a/apps/web/src/styles/watch-mobile-landscape.css +++ b/apps/web/src/styles/watch-mobile-landscape.css @@ -36,7 +36,9 @@ } .watch-page-shell .watch-sticky-player { - top: 0; + position: static; + top: auto; + z-index: auto; } .watch-page-shell .watch-player-box > .aspect-video, From 3b004e5cd5da7f1a13183ea53e12dac67d02272d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 19:37:10 +0200 Subject: [PATCH 026/118] fix: make sticky watch player compact --- apps/web/src/components/watch-stage.tsx | 7 ++++- apps/web/src/hooks/use-watch-player-sticky.ts | 28 +++++++++++++++++++ .../web/src/styles/watch-mobile-landscape.css | 27 ++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/hooks/use-watch-player-sticky.ts diff --git a/apps/web/src/components/watch-stage.tsx b/apps/web/src/components/watch-stage.tsx index 57e229d6..c62888fe 100644 --- a/apps/web/src/components/watch-stage.tsx +++ b/apps/web/src/components/watch-stage.tsx @@ -1,6 +1,7 @@ import type { MutableRefObject, ReactNode } from "react"; import type { WatchAudioOnlyControls } from "../hooks/use-watch-audio-only-playback"; import type { AutoplayState } from "../hooks/use-watch-ended-navigation"; +import { useWatchPlayerSticky } from "../hooks/use-watch-player-sticky"; import type { SabrPlaybackConfig } from "../lib/sabr-source"; import type { MediaSrc } from "../lib/vidstack"; import type { SponsorBlockSegmentItem } from "../types/api"; @@ -106,6 +107,9 @@ export function WatchStage({ onError, onReset, }: Props) { + const { compact: stickyCompact, sentinelRef: stickySentinelRef } = useWatchPlayerSticky( + !cinemaMode, + ); const playerOverlay = ( <> {overlay} @@ -124,7 +128,7 @@ export function WatchStage({ return (
-
+
{navigating ? (
@@ -173,6 +177,7 @@ export function WatchStage({ /> )}
+