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
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/router-plugin": "^1.167.32",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.4",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.3",
"vite": "^8.0.10"
}
Expand Down
74 changes: 63 additions & 11 deletions apps/web/src/components/format-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type * as dashjs from "dashjs";
import { useRef } from "react";
import { activeFamily, groupByFamily } from "../lib/quality-utils";
import { useDashPlayerSnapshot } from "../lib/dash-player-store";
import { dashTrackGroups, maxTrackHeight, selectDashTrack } from "../lib/dash-video";
import { activeFamily, type CodecFamily, codecFamily, groupByFamily } from "../lib/quality-utils";
import type { MenuInstance } from "../lib/vidstack";
import {
DefaultMenuButton,
Expand All @@ -8,35 +11,84 @@ import {
useVideoQualityOptions,
} from "../lib/vidstack";

const FORMAT_OPTIONS: { label: string; value: "H.264" | "VP9" }[] = [
{ label: "H.264", value: "H.264" },
{ label: "VP9", value: "VP9" },
];
const FORMAT_ORDER: CodecFamily[] = ["H.264", "VP9", "AV1"];
const MENU_ITEMS_CLASS =
"vds-menu-items overflow-y-auto overscroll-y-contain pr-0.5 [scrollbar-width:thin] [scrollbar-color:var(--color-zinc-500)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-surface-soft/80 [&::-webkit-scrollbar-thumb:hover]:bg-surface-soft [&::-webkit-scrollbar-track]:bg-transparent";

function isCodecFamily(value: string): value is CodecFamily {
return value === "H.264" || value === "VP9" || value === "AV1";
}

function formatLabel(family: CodecFamily, track: dashjs.MediaInfo): string {
const maxHeight = maxTrackHeight(track);
return maxHeight > 0 ? `${family} ${maxHeight}p` : family;
}

export function FormatSelector() {
const menuRef = useRef<MenuInstance>(null);
const { player, selectedVideoTrack } = useDashPlayerSnapshot();
const options = useVideoQualityOptions({ sort: "descending" });

const videoOptions = options.filter((o) => o.quality !== null);
const dashGroups = player ? dashTrackGroups(player) : null;

if (player && dashGroups && dashGroups.size > 1) {
const dashPlayer = player;
const current = codecFamily(
selectedVideoTrack?.codec ?? player.getCurrentTrackFor("video")?.codec ?? null,
);
const selected = current ?? FORMAT_ORDER.find((family) => dashGroups.has(family));
const availableOptions = FORMAT_ORDER.flatMap((family) => {
const track = dashGroups.get(family);
if (!track) return [];
return [{ label: formatLabel(family, track), value: family }];
});

if (!selected) return null;

function onDashChange(value: string) {
if (!isCodecFamily(value)) return;
const track = dashGroups?.get(value);
if (!track) return;
selectDashTrack(dashPlayer, track);
menuRef.current?.close();
}

return (
<Menu.Root ref={menuRef} className="vds-format-menu vds-menu">
<DefaultMenuButton label="Format" hint={selected} />
<Menu.Items className={MENU_ITEMS_CLASS}>
<DefaultMenuRadioGroup
value={selected}
options={availableOptions}
onChange={onDashChange}
/>
</Menu.Items>
</Menu.Root>
);
}

const groups = groupByFamily(videoOptions);
const current = activeFamily(videoOptions) ?? "H.264";
const availableOptions = FORMAT_OPTIONS.filter((f) => groups.has(f.value));
const selected = activeFamily(videoOptions) ?? FORMAT_ORDER.find((family) => groups.has(family));
const availableOptions = FORMAT_ORDER.filter((family) => groups.has(family)).map((family) => ({
label: family,
value: family,
}));

if (groups.size <= 1) return null;
if (!selected) return null;

function onChange(value: string) {
const best = groups.get(value as "H.264" | "VP9");
if (best) best.select();
if (!isCodecFamily(value)) return;
groups.get(value)?.select();
menuRef.current?.close();
}

return (
<Menu.Root ref={menuRef} className="vds-format-menu vds-menu">
<DefaultMenuButton label="Format" hint={current} />
<DefaultMenuButton label="Format" hint={selected} />
<Menu.Items className={MENU_ITEMS_CLASS}>
<DefaultMenuRadioGroup value={current} options={availableOptions} onChange={onChange} />
<DefaultMenuRadioGroup value={selected} options={availableOptions} onChange={onChange} />
</Menu.Items>
</Menu.Root>
);
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/components/quality-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type * as dashjs from "dashjs";
import { useRef } from "react";
import { useDashPlayerSnapshot } from "../lib/dash-player-store";
import { dashQualityOptions, selectDashTrack, selectedDashHeight } from "../lib/dash-video";
import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack";
import {
ClipIcon,
Expand Down Expand Up @@ -29,10 +32,52 @@ function collectResolutionOptions(options: QualityOption[]): QualityOption[] {
return [...grouped.values()];
}

function activeDashTrack(
player: dashjs.MediaPlayerClass,
selectedVideoTrack: dashjs.MediaInfo | null,
): dashjs.MediaInfo | null {
return selectedVideoTrack ?? player.getCurrentTrackFor("video");
}

export function QualitySelector() {
const menuRef = useRef<MenuInstance>(null);
const { player, selectedVideoTrack } = useDashPlayerSnapshot();
const options = useVideoQualityOptions({ sort: "descending" });

const dashTrack = player ? activeDashTrack(player, selectedVideoTrack) : null;
if (player && dashTrack) {
const dashPlayer = player;
const activeTrack = dashTrack;
const selectedHeight = selectedDashHeight(dashPlayer, activeTrack);
const dashOptions = dashQualityOptions(activeTrack, selectedHeight);
const selected = dashOptions.find((option) => option.selected) ?? dashOptions[0];

if (dashOptions.length > 1 && selected) {
function onDashChange(value: string) {
const height = Number(value);
if (!Number.isFinite(height)) return;
selectDashTrack(dashPlayer, activeTrack, height);
menuRef.current?.close();
}

return (
<Menu.Root ref={menuRef} className="vds-quality-menu vds-menu">
<DefaultMenuButton label="Quality" hint={selected.label} Icon={qualityIcon} />
<Menu.Items className={MENU_ITEMS_CLASS}>
<DefaultMenuRadioGroup
value={selected.value}
options={dashOptions.map((option) => ({
label: option.label,
value: option.value,
}))}
onChange={onDashChange}
/>
</Menu.Items>
</Menu.Root>
);
}
}

const videoOptions = options.filter((o) => o.quality !== null);
const filteredOptions = collectResolutionOptions(videoOptions);
const selected = filteredOptions.find((o) => o.selected) ?? filteredOptions[0];
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/video-player-core.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as dashjs from "dashjs";
import { notifyDashPlayer, setDashPlayer } from "../lib/dash-player-store";
import type { MediaProviderAdapter } from "../lib/vidstack";
import { isDASHProvider, Track, useMediaState } from "../lib/vidstack";

Expand All @@ -19,9 +20,18 @@ export function ChaptersTrack({ src }: { src: string }) {
}

export function onProviderChange(provider: MediaProviderAdapter | null) {
if (!isDASHProvider(provider)) return;
const dashProvider = isDASHProvider(provider);
if (!dashProvider) {
if (provider === null) setDashPlayer(null);
return;
}
provider.library = dashjs.MediaPlayer;
provider.onInstance((player) => {
setDashPlayer(player);
const onDashUpdate = () => notifyDashPlayer();
player.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, onDashUpdate);
player.on(dashjs.MediaPlayer.events.TRACK_CHANGE_RENDERED, onDashUpdate);
player.on(dashjs.MediaPlayer.events.QUALITY_CHANGE_RENDERED, onDashUpdate);
player.updateSettings({ streaming: { cmcd: { enabled: false } } });
shimDashjsQualityApi(player);
});
Expand Down
74 changes: 41 additions & 33 deletions apps/web/src/components/watch-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,12 @@ type Props = {
export function WatchLayout({ stream, startTime }: Props) {
const navigate = useNavigate();
const save = useSaveProgress(stream.id);
const { settings, update, query: settingsQuery } = useSettings();
const settingsReady =
(settingsQuery.isSuccess && !settingsQuery.isPlaceholderData) || settingsQuery.isError;
const { settings, update, settingsReady } = useSettings();
const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream";
const { manifestSrc, playerFailed, qualityFailed, handleError, reset, retryKey } = usePlayerError(
stream,
isLive,
settings.enableHighQualityPlayback,
);
const { on: bulletCommentsOn } = useDanmakuStore();
const isNicoNico = detectProvider(stream.id) === "nicovideo";
Expand All @@ -52,6 +51,7 @@ export function WatchLayout({ stream, startTime }: Props) {
const [toast, setToast] = useState<string | null>(null);
const handleVolumeChange = useVolumeSync(update.mutate);
const { thumbnailVtt, chaptersVtt } = useWatchVttAssets(stream);
const playerKey = `${stream.id}:${retryKey}:${settings.enableHighQualityPlayback ? "hq" : "std"}`;

useEffect(() => {
if (!toast) return;
Expand Down Expand Up @@ -109,36 +109,44 @@ export function WatchLayout({ stream, startTime }: Props) {
<div className={containerClass}>
<div className={playerWrapClass}>
<div className={playerBoxClass}>
<VideoPlayer
key={`${stream.id}:${retryKey}`}
src={manifestSrc}
title={stream.title}
poster={stream.thumbnail}
streamType={isLive ? "live" : "on-demand"}
startTime={startTime}
subtitles={stream.subtitles}
sponsorBlockSegments={stream.sponsorBlockSegments}
thumbnailVtt={thumbnailVtt}
chaptersVtt={chaptersVtt}
initialVolume={settings.volume}
initialMuted={settings.muted}
settingsReady={settingsReady}
autoplay={settingsReady}
originalAudioLocale={originalLocale}
overlay={overlay}
onVolumeChange={handleVolumeChange}
onTimeUpdate={playerEvents.handleTimeUpdate}
onPause={playerEvents.handlePause}
onSeeked={playerEvents.handleSeeked}
onError={handleError}
onEnded={playerEvents.handleEnded}
onSeekReady={(s) => {
seekRef.current = s;
}}
className={cinemaMode ? "w-full h-full dark [--video-aspect-ratio:16/9]" : undefined}
mediaClassName={cinemaMode ? "object-cover" : undefined}
/>
{playerFailed && <PlayerError onRetry={reset} />}
{settingsReady ? (
<>
<VideoPlayer
key={playerKey}
src={manifestSrc}
title={stream.title}
poster={stream.thumbnail}
streamType={isLive ? "live" : "on-demand"}
startTime={startTime}
subtitles={stream.subtitles}
sponsorBlockSegments={stream.sponsorBlockSegments}
thumbnailVtt={thumbnailVtt}
chaptersVtt={chaptersVtt}
initialVolume={settings.volume}
initialMuted={settings.muted}
settingsReady={settingsReady}
autoplay={settingsReady}
originalAudioLocale={originalLocale}
overlay={overlay}
onVolumeChange={handleVolumeChange}
onTimeUpdate={playerEvents.handleTimeUpdate}
onPause={playerEvents.handlePause}
onSeeked={playerEvents.handleSeeked}
onError={handleError}
onEnded={playerEvents.handleEnded}
onSeekReady={(s) => {
seekRef.current = s;
}}
className={
cinemaMode ? "w-full h-full dark [--video-aspect-ratio:16/9]" : undefined
}
mediaClassName={cinemaMode ? "object-cover" : undefined}
/>
{playerFailed && <PlayerError onRetry={reset} />}
</>
) : (
<div className="aspect-video w-full bg-black" />
)}
</div>
{!cinemaMode && (
<WatchMeta stream={stream} onSeekTimestamp={(seconds) => seekRef.current?.(seconds)} />
Expand Down
Loading