Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@typetype/mse": "0.1.35",
"@typetype/mse": "0.1.38",
"@vidstack/react": "1.12.13",
"dashjs": "^5.2.0",
"hls.js": "1.6.16",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/sabr-mse-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ export function SabrMsePlayer({
const offError = engine.on("error", (event) => {
if (event.type === "error") reportError(event.error, event.recoveryPositionMs);
});
const volumeChange = () => latestHandlers().onVolumeChange?.(video.volume, video.muted);
const volumeChange = () => {
if (engine.isApplyingTransientMediaState()) return;
latestHandlers().onVolumeChange?.(video.volume, video.muted);
};
video.addEventListener("volumechange", volumeChange);
let autoplayStartTime = 0;
let engineLoaded = false;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/shorts-video-player.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { isIosDevice } from "../lib/ios-device";
import { PLAYBACK_RATES } from "../lib/playback-rates";
import type { MediaSrc } from "../lib/vidstack";
import {
DefaultVideoLayout,
Expand Down Expand Up @@ -121,6 +122,7 @@ export function ShortsVideoPlayer({
</MediaProvider>
<DefaultVideoLayout
icons={defaultLayoutIcons}
playbackRates={PLAYBACK_RATES}
translations={{ Captions: "Subtitles" }}
smallLayoutWhen
noModal
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/video-player-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PLAYBACK_RATES } from "../lib/playback-rates";
import { DefaultAudioLayout, DefaultVideoLayout, defaultLayoutIcons, Time } from "../lib/vidstack";
import { AudioPlayButton } from "./audio-play-button";
import { AudioSeekButton } from "./audio-seek-button";
Expand Down Expand Up @@ -55,6 +56,7 @@ export function VideoPlayerLayout({
<DefaultVideoLayout
className="typetype-adaptive-audio-layout"
icons={defaultLayoutIcons}
playbackRates={PLAYBACK_RATES}
smallLayoutWhen={false}
translations={{ Captions: "Subtitles" }}
slots={{
Expand Down Expand Up @@ -82,6 +84,7 @@ export function VideoPlayerLayout({
return (
<DefaultAudioLayout
icons={defaultLayoutIcons}
playbackRates={PLAYBACK_RATES}
smallLayoutWhen={false}
translations={{ Captions: "Subtitles" }}
slots={{
Expand All @@ -102,6 +105,7 @@ export function VideoPlayerLayout({
return (
<DefaultVideoLayout
icons={defaultLayoutIcons}
playbackRates={PLAYBACK_RATES}
thumbnails={thumbnailVtt}
smallLayoutWhen={false}
translations={{ Captions: "Subtitles" }}
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/hooks/use-subscription-feed.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import { ApiError } from "../lib/api";
import { fetchSubscriptionFeed } from "../lib/api-user";
import { mapVideoItem } from "../lib/mappers";
import { proxyImage } from "../lib/proxy";
Expand All @@ -20,20 +21,30 @@ type Result = {
export function useSubscriptionFeed(): Result {
const { authReady, isAuthed } = useAuth();
const { query: subsQuery } = useSubscriptions();
const queryClient = useQueryClient();
const avatarMap = useMemo(
() => new Map((subsQuery.data ?? []).map((s) => [s.channelUrl, proxyImage(s.avatarUrl)])),
[subsQuery.data],
);

const query = useInfiniteQuery({
queryKey: SUBSCRIPTION_FEED_KEY,
queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam as number),
initialPageParam: 0,
getNextPageParam: (last, pages) => (last.nextpage !== null ? pages.length : undefined),
queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextpage ?? undefined,
staleTime: 5 * 60 * 1000,
enabled: authReady && isAuthed,
});

useEffect(() => {
if (
query.error instanceof ApiError &&
query.error.code === "subscription_feed_stale_generation"
) {
void queryClient.resetQueries({ queryKey: SUBSCRIPTION_FEED_KEY, exact: true });
}
}, [query.error, queryClient]);

const streams = useMemo(
() =>
(query.data?.pages ?? [])
Expand Down
30 changes: 24 additions & 6 deletions apps/web/src/hooks/use-volume-sync.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import { useCallback, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { SettingsItem } from "../types/user";

type MutateFn = (patch: Partial<SettingsItem>) => void;

export function createDebouncedVolumeSync(mutate: MutateFn, delayMs = 1000) {
let timer: ReturnType<typeof setTimeout> | null = null;
return {
schedule(volume: number, muted: boolean) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
mutate({ volume, muted });
}, delayMs);
},
cancel() {
if (timer) clearTimeout(timer);
timer = null;
},
};
}

export function useVolumeSync(mutate: MutateFn): (volume: number, muted: boolean) => void {
const mutateRef = useRef(mutate);
mutateRef.current = mutate;
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncRef = useRef<ReturnType<typeof createDebouncedVolumeSync> | null>(null);
if (!syncRef.current) {
syncRef.current = createDebouncedVolumeSync((patch) => mutateRef.current(patch));
}
useEffect(() => () => syncRef.current?.cancel(), []);

return useCallback((volume: number, muted: boolean) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
mutateRef.current({ volume, muted });
}, 1000);
syncRef.current?.schedule(volume, muted);
}, []);
}
53 changes: 50 additions & 3 deletions apps/web/src/lib/api-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,56 @@ export async function clearSearchHistory(): Promise<void> {
await authed(`${BASE}/search-history`, { method: "DELETE" });
}

export async function fetchSubscriptionFeed(page: number): Promise<SubscriptionFeedPage> {
const search = new URLSearchParams({ page: String(page), limit: "30" });
return authedJson(`${BASE}/subscriptions/feed?${search.toString()}`);
export async function fetchSubscriptionFeed(
cursor: string | null = null,
signal?: AbortSignal,
): Promise<SubscriptionFeedPage> {
const search = new URLSearchParams({ limit: "30" });
if (cursor !== null) search.set("cursor", cursor);
const url = `${BASE}/subscriptions/feed?${search.toString()}`;
while (true) {
const res = await authed(url, { signal });
const body = normalizeApiPayload(await res.json());
if (res.status === 202 && isSubscriptionFeedPreparing(body)) {
await waitForSubscriptionFeed(body.retryAfterMs, signal);
continue;
}
if (!res.ok) {
const error = body as { code?: string; error?: string };
throw new ApiError(
error.error ?? "Subscription feed request failed",
res.status,
error.code ?? null,
);
}
return body as SubscriptionFeedPage;
}
}

function isSubscriptionFeedPreparing(
body: unknown,
): body is { code: string; retryAfterMs: number } {
if (typeof body !== "object" || body === null) return false;
const preparing = body as { code?: unknown; retryAfterMs?: unknown };
return (
preparing.code === "subscription_feed_preparing" && typeof preparing.retryAfterMs === "number"
);
}

function waitForSubscriptionFeed(retryAfterMs: number, signal?: AbortSignal): Promise<void> {
const delayMs = Math.min(Math.max(retryAfterMs, 100), 5_000);
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(timeout);
reject(signal?.reason);
};
const timeout = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delayMs);
signal?.addEventListener("abort", onAbort, { once: true });
});
}

export async function fetchSubscriptionShorts(
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/playback-rates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const PLAYBACK_RATES = {
min: 0,
max: 4,
step: 0.25,
} as const;
57 changes: 9 additions & 48 deletions apps/web/src/lib/sabr-player-seek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,7 @@ import { isAbortError } from "./sabr-playback-retry";

type SeekFlag = { current: boolean };

type PendingSeek = {
player: TypeTypeMsePlayer;
position: number;
onError: (error: unknown) => void;
onSeekingChange?: (seeking: boolean) => void;
timer: ReturnType<typeof setTimeout>;
};

const pendingSeeks = new WeakMap<SeekFlag, PendingSeek>();
const SEEK_RETRY_MS = 100;
const seekRevisions = new WeakMap<SeekFlag, number>();

export function positionMs(video: HTMLVideoElement): number {
return Math.max(0, Math.round(video.currentTime * 1000));
Expand Down Expand Up @@ -47,54 +38,24 @@ export function runSabrSeek(
onSeekingChange?: (seeking: boolean) => void,
) {
if (!player) return;
if (flag.current) {
queueSabrSeek(player, position, flag, onError, onSeekingChange);
return;
const revision = (seekRevisions.get(flag) ?? 0) + 1;
seekRevisions.set(flag, revision);
if (!flag.current) {
flag.current = true;
onSeekingChange?.(true);
}
cancelPendingSabrSeek(flag);
flag.current = true;
onSeekingChange?.(true);
void player
.seek(position)
.catch((error: unknown) => {
if (!isAbortError(error)) onError(error);
if (seekRevisions.get(flag) === revision && !isAbortError(error)) onError(error);
})
.finally(() => {
if (seekRevisions.get(flag) !== revision) return;
flag.current = false;
onSeekingChange?.(false);
});
}

function queueSabrSeek(
player: TypeTypeMsePlayer,
position: number,
flag: SeekFlag,
onError: (error: unknown) => void,
onSeekingChange?: (seeking: boolean) => void,
): void {
cancelPendingSabrSeek(flag);
const retry = () => {
const pending = pendingSeeks.get(flag);
if (!pending) return;
if (flag.current) {
pending.timer = setTimeout(retry, SEEK_RETRY_MS);
return;
}
pendingSeeks.delete(flag);
runSabrSeek(pending.player, pending.position, flag, pending.onError, pending.onSeekingChange);
};
pendingSeeks.set(flag, {
player,
position,
onError,
onSeekingChange,
timer: setTimeout(retry, SEEK_RETRY_MS),
});
}

export function cancelPendingSabrSeek(flag: SeekFlag): void {
const pending = pendingSeeks.get(flag);
if (!pending) return;
clearTimeout(pending.timer);
pendingSeeks.delete(flag);
seekRevisions.set(flag, (seekRevisions.get(flag) ?? 0) + 1);
}
11 changes: 7 additions & 4 deletions apps/web/src/lib/sabr-source.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isMseTypeSupported } from "@typetype/mse";
import type { SabrQualityOption } from "../stores/sabr-quality-store";
import type { AudioStreamItem, VideoStreamItem } from "../types/api";
import type { VideoStream } from "../types/stream";
Expand Down Expand Up @@ -27,13 +28,15 @@ function isSabrCandidate(item: SabrCandidate): boolean {

function playableVideos(stream: VideoStream): VideoStreamItem[] {
const videos = [...(stream.videoOnlyStreams ?? []), ...(stream.videoStreams ?? [])];
return videos.filter((video) => isSabrCandidate(video) && supportedVideo(video));
return videos.filter((video) => isSabrCandidate(video) && isSabrVideoSupported(video));
}

function supportedVideo(video: VideoStreamItem): boolean {
export function isSabrVideoSupported(
video: VideoStreamItem,
supportsType = isMseTypeSupported,
): boolean {
if (!video.codec || !codecFamily(video.codec)) return false;
if (typeof MediaSource === "undefined") return true;
return MediaSource.isTypeSupported(`${video.mimeType}; codecs="${video.codec}"`);
return supportsType(`${video.mimeType}; codecs="${video.codec}"`);
}

function qualityLabel(video: VideoStreamItem): string {
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/watch-resume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
type WatchResumeInput = {
authenticated: boolean;
progressPending: boolean;
savedPositionMs?: number;
serverPositionSeconds?: number;
durationSeconds: number;
};

export function resolveWatchStartTime(input: WatchResumeInput): number | null {
if (input.authenticated && input.progressPending) return null;

const savedPositionMs = input.savedPositionMs ?? 0;
const serverPositionMs = (input.serverPositionSeconds ?? 0) * 1000;
const resumeMs = savedPositionMs > 0 ? savedPositionMs : serverPositionMs;
const durationMs = input.durationSeconds * 1000;
return resumeMs >= 5000 && resumeMs < durationMs * 0.95 ? resumeMs : 0;
}
11 changes: 4 additions & 7 deletions apps/web/src/routes/subscriptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@ import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user";

const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000;

function nextSubscriptionPage(
last: Awaited<ReturnType<typeof fetchSubscriptionFeed>>,
pages: unknown[],
) {
return last.nextpage !== null ? pages.length : undefined;
function nextSubscriptionPage(last: Awaited<ReturnType<typeof fetchSubscriptionFeed>>) {
return last.nextpage ?? undefined;
}

function SubscriptionsPage() {
Expand All @@ -42,8 +39,8 @@ function SubscriptionsPage() {
function prefetchVideos() {
void queryClient.prefetchInfiniteQuery({
queryKey: SUBSCRIPTION_FEED_KEY,
queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam as number),
initialPageParam: 0,
queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal),
initialPageParam: null as string | null,
getNextPageParam: nextSubscriptionPage,
staleTime: SUBSCRIPTION_STALE_MS,
});
Expand Down
11 changes: 4 additions & 7 deletions apps/web/src/routes/subscriptions_.channels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@ import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user";

const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000;

function nextSubscriptionPage(
last: Awaited<ReturnType<typeof fetchSubscriptionFeed>>,
pages: unknown[],
) {
return last.nextpage !== null ? pages.length : undefined;
function nextSubscriptionPage(last: Awaited<ReturnType<typeof fetchSubscriptionFeed>>) {
return last.nextpage ?? undefined;
}

function SubscriptionChannelsPage() {
Expand All @@ -32,8 +29,8 @@ function SubscriptionChannelsPage() {
function prefetchVideos() {
void queryClient.prefetchInfiniteQuery({
queryKey: SUBSCRIPTION_FEED_KEY,
queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam as number),
initialPageParam: 0,
queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam as string | null, signal),
initialPageParam: null as string | null,
getNextPageParam: nextSubscriptionPage,
staleTime: SUBSCRIPTION_STALE_MS,
});
Expand Down
Loading