diff --git a/apps/app/package.json b/apps/app/package.json index c3a8355..abaed27 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -63,7 +63,7 @@ "@radix-ui/react-visually-hidden": "^1.0.3", "@radix-ui/rect": "1.0.1", "@tanstack/react-virtual": "^3.2.0", - "@vidstack/react": "npm:@aidenlx/vidstack-react@1.10.9-mod.1", + "@vidstack/react": "npm:@aidenlx/vidstack-react@1.10.9-mod.4", "ahooks": "^3.7.8", "arktype": "1.0.29-alpha", "assert-never": "^1.2.1", diff --git a/apps/app/src/components/context.tsx b/apps/app/src/components/context.tsx index db1c6a7..4650669 100644 --- a/apps/app/src/components/context.tsx +++ b/apps/app/src/components/context.tsx @@ -45,7 +45,7 @@ export interface SourceFacet { export interface MediaViewState { player: MediaPlayerInstance | null; - playerRef: React.RefCallback; + setPlayer: React.RefCallback; source: | { url: MediaInfo; @@ -73,7 +73,9 @@ export interface MediaViewState { export function createMediaViewStore(plugin: MxPlugin) { const store = createStore((set, get, store) => ({ player: null, - playerRef: (inst) => set({ player: inst }), + setPlayer: (inst) => { + set({ player: inst }); + }, source: undefined, hash: { autoplay: undefined, diff --git a/apps/app/src/components/player.tsx b/apps/app/src/components/player.tsx index 7d2c098..7ac37e4 100644 --- a/apps/app/src/components/player.tsx +++ b/apps/app/src/components/player.tsx @@ -16,7 +16,7 @@ import { AudioLayout } from "./player/layouts/audio-layout"; import { VideoLayout } from "./player/layouts/video-layout"; import { MediaProviderEnhanced } from "./provider"; import { useSource } from "./use-source"; -import { useRemoteTextTracks } from "./use-tracks"; +import { useTextTracks } from "./use-tracks"; function HookLoader({ onViewTypeChange, @@ -24,9 +24,9 @@ function HookLoader({ onViewTypeChange: (viewType: "audio" | "unknown") => any; }) { useViewTypeDetect(onViewTypeChange); - useRemoteTextTracks(); useTempFragHandler(); useDefaultVolume(); + useTextTracks(); return <>; } @@ -40,7 +40,7 @@ function PlayerLayout() { } export function Player() { - const playerRef = useMediaViewStore((s) => s.playerRef); + const setPlayer = useMediaViewStore((s) => s.setPlayer); const { onEnded } = useAutoContinuePlay(); const source = useSource(); const isWebm = useMediaViewStore(({ source }) => { @@ -69,7 +69,7 @@ export function Player() { playsInline title={title} viewType={viewType} - ref={playerRef} + ref={setPlayer} onEnded={onEnded} onError={(e) => handleError(e, source.src)} {...hashProps} diff --git a/apps/app/src/components/player/caption-menu.tsx b/apps/app/src/components/player/caption-menu.tsx index 1f3216a..6e69aff 100644 --- a/apps/app/src/components/player/caption-menu.tsx +++ b/apps/app/src/components/player/caption-menu.tsx @@ -1,15 +1,24 @@ import { useCaptionOptions, useMediaState } from "@vidstack/react"; import { SubtitlesIcon } from "@/components/icon"; +import { toTrackLabel, useLastSelectedTrack } from "../use-tracks"; import { dataLpPassthrough } from "./buttons"; import { useMenu } from "./menus"; export function Captions() { const options = useCaptionOptions(); const tracks = useMediaState("textTracks"); + const [, cacheSelectedTrack] = useLastSelectedTrack(); const onClick = useMenu((menu) => { - options.forEach(({ label, select, selected }) => { + options.forEach(({ label, select, selected, track }, i) => { menu.addItem((item) => { - item.setTitle(label).setChecked(selected).onClick(select); + item + .setTitle(track ? toTrackLabel(track, i) : label) + .setChecked(selected) + .onClick(() => { + select(); + console.log("cacheSelectedTrack", track?.id ?? null, track?.mode); + cacheSelectedTrack(track?.id ?? null); + }); }); }); return true; diff --git a/apps/app/src/components/provider.tsx b/apps/app/src/components/provider.tsx index 99e1e0e..35c82b3 100644 --- a/apps/app/src/components/provider.tsx +++ b/apps/app/src/components/provider.tsx @@ -5,7 +5,6 @@ import { type MediaProviderInstance, type MediaProviderLoader, type MediaProviderProps, - Track, } from "@vidstack/react"; import { useCallback } from "react"; import { getPartition } from "@/lib/remote-player/const"; @@ -15,7 +14,6 @@ import { channelId } from "@/web/bili-req/channel"; import { BILI_REQ_STORE } from "@/web/bili-req/const"; import { useApp, useMediaViewStore } from "./context"; import { useControls } from "./hook/use-hash"; -import { useTextTracks } from "./use-tracks"; import { WebView } from "./webview"; const { preload } = channelId(BILI_REQ_STORE); @@ -44,7 +42,6 @@ export function MediaProviderEnhanced({ } }); const controls = useControls(); - const tracks = useTextTracks(); return ( - {tracks.map((props) => ( - - ))} - + > ); } diff --git a/apps/app/src/components/use-tracks.tsx b/apps/app/src/components/use-tracks.tsx index 22ac0e9..dd5c228 100644 --- a/apps/app/src/components/use-tracks.tsx +++ b/apps/app/src/components/use-tracks.tsx @@ -1,122 +1,167 @@ -import { - useMediaPlayer, - useMediaProvider, - useMediaState, -} from "@vidstack/react"; -import type { - TextTrackInit, - TextTrackListModeChangeEvent, - VTTContent, -} from "@vidstack/react"; +import { useMediaPlayer, useMediaProvider, TextTrack } from "@vidstack/react"; import { upperFirst } from "lodash-es"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getMediaInfoID } from "@/info/media-info"; import type { TextTrackInfo, WebsiteTextTrack } from "@/info/track-info"; import { getTrackInfoID } from "@/info/track-info"; -import { setDefaultLang } from "@/lib/lang/default-lang"; +import { getDefaultLang } from "@/lib/lang/default-lang"; import { langCodeToLabel } from "@/lib/lang/lang"; import { WebiviewMediaProvider } from "@/lib/remote-player/provider"; import { useMediaViewStore, useSettings } from "./context"; -export function useRemoteTextTracks() { - // const externalTextTracks = useMediaViewStore(({ textTracks }) => textTracks); - const player = useMediaPlayer(); - const loaded = useMediaState("canPlay"); - +export function useLastSelectedTrack() { + const key = useMediaViewStore((s) => + s.source?.url ? `mx-last-track:${getMediaInfoID(s.source.url)}` : null, + ); + const [lastSelectedTrack, setTrack] = useState(() => + key && window ? window.localStorage.getItem(key) : null, + ); useEffect(() => { - if (!player) return; - const updateTrack = async (evt: TextTrackListModeChangeEvent) => { - const track = evt.detail; - const provider = player.provider; - - if ( - !( - track.mode === "showing" && - track.content === dummyVTTContent && - track.id.startsWith(webpageTrackPrefix) && - provider instanceof WebiviewMediaProvider - ) - ) - return; - const id = (track.id as string).slice(webpageTrackPrefix.length); - if (!loaded) { - console.warn("Cannot load remote captions before media is loaded"); - return; - } - const vtt = await provider.media.methods.getTrack(id); - if (!vtt) return; - player.textTracks.remove(track); - player.textTracks.add({ - content: vtt, - kind: track.kind, - default: track.default, - encoding: track.encoding, - id: track.id, - label: track.label, - language: track.language, - type: track.type, - }); - player.textTracks.getById(id)?.setMode("showing"); - }; - player.textTracks.addEventListener("mode-change", updateTrack); - return () => { - player.textTracks.removeEventListener("mode-change", updateTrack); + () => { + if (!key || !window) return; + setTrack(window?.localStorage.getItem(key)); }; - }, [player, loaded]); + }, [key]); + return [ + lastSelectedTrack === "" ? false : lastSelectedTrack, + useCallback( + (trackID: string | null) => { + if (!key) return; + if (!trackID) window?.localStorage.setItem(key, ""); + else window?.localStorage.setItem(key, trackID); + }, + [key], + ), + ] as const; } -export function useTextTracks() { - const localTextTracks = useMediaViewStore((s) => s.textTracks.local); - const remoteTextTracks = useMediaViewStore((s) => s.textTracks.remote); - const setRemoteTracks = useMediaViewStore((s) => s.updateWebsiteTracks); +function useDefaultLang() { const defaultLang = useSettings((s) => s.defaultLanguage); const getDefaultLang = useSettings((s) => s.getDefaultLang); + return useMemo( + () => getDefaultLang(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [defaultLang], + ); +} +function useDefaultTrack() { + // useDefaultShowingTrack(); + const [lastSelectedTrack] = useLastSelectedTrack(); + const defaultLang = useDefaultLang(); + const enableDefaultSubtitle = useSettings((s) => s.enableSubtitle); + return useCallback( + ( + ...tracks: { language?: string }[] + ): (( + track: { language?: string; id: string }, + index: number, + ) => boolean) => { + // if user has selected subtitle for this media + if (lastSelectedTrack === false) return () => false; + if (lastSelectedTrack) return ({ id }) => id === lastSelectedTrack; + // if user have enabled subtitle by default + if (enableDefaultSubtitle) { + const lang = getDefaultLang(tracks, defaultLang); + if (!lang) return (_, index) => index === 0; + return ({ language }) => language === lang; + } + return () => false; + }, + [defaultLang, enableDefaultSubtitle, lastSelectedTrack], + ); +} +export function useTextTracks() { + const localTracks = useMediaViewStore((s) => s.textTracks.local); + const remoteTracks = useMediaViewStore((s) => s.textTracks.remote); + const setRemoteTracks = useMediaViewStore((s) => s.updateWebsiteTracks); const provider = useMediaProvider(); + const genDefaultTrackPredicate = useDefaultTrack(); useEffect(() => { if (!(provider instanceof WebiviewMediaProvider)) return; + const providerCache = providerRef.current!; return provider.media.on("mx-text-tracks", ({ payload: { tracks } }) => { + providerCache.set(tracks, provider); setRemoteTracks(tracks); if (tracks.length !== 0) console.debug("Remote tracks loaded", tracks); }); }, [provider, setRemoteTracks]); - return useMemo( - () => { - const local = localTextTracks.map( - (track) => - ({ - id: getTrackInfoID(track).id, - kind: track.kind, - label: track.label, - type: track.type, - content: track.content, - } satisfies TextTrackInit), - ); - const remote = dedupeWebsiteTrack(remoteTextTracks, localTextTracks).map( - ({ wid, ...track }) => - ({ - id: webpageTrackPrefix + wid, - ...track, - content: dummyVTTContent, - } satisfies TextTrackInit), + const providerRef = + useRef>(); + providerRef.current ??= new WeakMap(); + + const player = useMediaPlayer(); + + useEffect(() => { + if (!player) return; + const provider = providerRef.current!.get(remoteTracks); + const customFetch: typeof window.fetch = async (url, init) => { + if (!(typeof url === "string" && url.startsWith("webview://"))) + return fetch(url, init); + if (!provider) return new Response(null, { status: 500 }); + const id = url.slice(`webview://${webpageTrackPrefix}`.length); + const vtt = await provider.media.methods.getTrack(id); + if (!vtt) return new Response(null, { status: 404 }); + return Response.json({ cues: vtt.cues, regions: vtt.regions }); + }; + + const defaultTrackPredicate = genDefaultTrackPredicate( + ...localTracks, + ...remoteTracks, + ); + + const local = localTracks.map((track, i) => { + const { wid, src, ...props } = track; + const id = getTrackInfoID(track).id; + const isDefault = defaultTrackPredicate( + { id, language: props.language }, + i, ); - const tracks = [...local, ...remote].sort(sortTrack).map((t, idx) => ({ - ...t, - label: toTrackLabel(t, idx), - })); - return setDefaultLang(tracks, getDefaultLang()); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [localTextTracks, remoteTextTracks, defaultLang], - ); + const out = new TextTrack({ + ...props, + id, + default: isDefault, + }); + // out.setMode(isDefault ? "showing" : "disabled"); + return out; + }); + const remote = dedupeWebsiteTrack(remoteTracks, localTracks).map( + ({ wid, ...props }, i) => { + const id = webpageTrackPrefix + wid; + const isDefault = defaultTrackPredicate( + { id, language: props.language }, + i + localTracks.length, + ); + const track = new TextTrack({ + ...props, + id, + type: "json", + src: `webview://${id}`, + default: isDefault, + }); + // track.setMode(isDefault ? "showing" : "disabled"); + track.customFetch = customFetch; + return track; + }, + ); + + player.textTracks.clear(); + // @ts-expect-error may report to vidstack/react as a bug? + player.textTracks._defaults = {}; + [...local, ...remote].sort(sortTrack).forEach((track, i) => { + // @ts-expect-error I know it's readonly + track.label = toTrackLabel(track, i); + player.textTracks.add(track); + }); + }, [genDefaultTrackPredicate, player, localTracks, remoteTracks]); + // const defaultLang = useSettings((s) => s.defaultLanguage); + // const getDefaultLang = useSettings((s) => s.getDefaultLang); + // useDefaultShowingTrack(); } const webpageTrackPrefix = "webpage:"; -export const dummyVTTContent = { - cues: [], - regions: [], -} satisfies VTTContent; export function dedupeWebsiteTrack( website: WebsiteTextTrack[], @@ -125,23 +170,17 @@ export function dedupeWebsiteTrack( return website.filter((t) => !local.some(({ wid }) => wid === t.wid)); } -export function toTrackLabel(t: LabelProps, idx: number) { +export function toTrackLabel(t: TextTrack, idx: number) { return ( - t.label || - langCodeToLabel(t.language) || - `${upperFirst(t.kind)} ${t.wid || idx + 1}` + t.label || langCodeToLabel(t.language) || `${upperFirst(t.kind)} ${idx + 1}` ); } -type LabelProps = Pick; - -export function sortTrack(a: LabelProps | null, b: LabelProps | null) { +export function sortTrack(a: TextTrack | null, b: TextTrack | null) { if (a && b) { // if with item.track.language, sort by item.track.language if (a.language && b.language) { - return (a as { language: string }).language.localeCompare( - (b as { language: string }).language, - ); + return (a.language as string).localeCompare(b.language as string); } // if not with item.track.language, sort by item.label diff --git a/apps/app/src/lib/lang/default-lang.ts b/apps/app/src/lib/lang/default-lang.ts index 5644f47..ce20926 100644 --- a/apps/app/src/lib/lang/default-lang.ts +++ b/apps/app/src/lib/lang/default-lang.ts @@ -1,9 +1,8 @@ -import type { TextTrackInit } from "@vidstack/react"; import { uniq } from "../uniq"; import { format } from "./lang"; -export function setDefaultLang( - list: TextTrackInit[], +export function getDefaultLang( + list: { language?: string }[], defaultLangCode?: string, ) { const allLanguages = uniq( @@ -27,8 +26,5 @@ export function setDefaultLang( defaultLang = defaultLangCode.split("-")[0]; return lang === defaultLang; }); - return list.map((t) => ({ - ...t, - default: format(t.language) === defaultLang, - })); + return defaultLang; } diff --git a/apps/app/src/settings/def.ts b/apps/app/src/settings/def.ts index 4aff02f..b8dffa4 100644 --- a/apps/app/src/settings/def.ts +++ b/apps/app/src/settings/def.ts @@ -22,6 +22,7 @@ type MxSettingValues = { alt: OpenLinkBehavior; }; speedStep: number; + enableSubtitle: boolean; defaultLanguage?: string; loadStrategy: "play" | "eager"; linkHandler: Record; @@ -53,6 +54,7 @@ const settingKeys = enumerate()( "biliDefaultQuality", "screenshotFormat", "screenshotQuality", + "enableSubtitle", "defaultLanguage", "screenshotFolderPath", "subtitleFolderPath", @@ -72,6 +74,7 @@ const mxSettingsDefault = { "mx-url-video": [], "mx-webpage": [], }, + enableSubtitle: false, loadStrategy: "eager", timestampTemplate: "\n- {{TIMESTAMP}} ", screenshotEmbedTemplate: "{{TITLE}}{{DURATION}}|50", @@ -119,6 +122,7 @@ export type MxSettings = { getDeviceNameWithDefault: (id?: string) => string; setDeviceName: (label: string, id?: string) => void; setSpeedStep: (step: number) => void; + setEnableSubtitle: (enable: boolean) => void; urlMapping: Map; setTemplate: ( key: "timestamp" | "screenshot" | "screenshotEmbed", @@ -166,6 +170,10 @@ export function createSettingsStore(plugin: MxPlugin) { }, 1e3); return createStore((set, get) => ({ ...omit(mxSettingsDefault, ["urlMappingData"]), + setEnableSubtitle(enable) { + set({ enableSubtitle: enable }); + save(get()); + }, setSpeedStep(step) { step = Math.abs(step); if (step === 0) return; diff --git a/apps/app/src/settings/tab.ts b/apps/app/src/settings/tab.ts index 20b0797..d858312 100644 --- a/apps/app/src/settings/tab.ts +++ b/apps/app/src/settings/tab.ts @@ -334,6 +334,14 @@ export class MxSettingTabs extends PluginSettingTab { subtitle() { const { containerEl: container } = this; + new Setting(container) + .setName("Enable subtitle by default") + .setDesc("Toggle subtitle on by default when available") + .addToggle((toggle) => + toggle + .setValue(this.state.enableSubtitle) + .onChange(this.state.setEnableSubtitle), + ); const fallback = "_follow_"; const extra = getGroupedLangExtra(); const locales = Object.fromEntries( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 330ddbd..3d072f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,8 +87,8 @@ importers: specifier: ^3.2.0 version: 3.2.0(react-dom@18.2.0)(react@18.2.0) '@vidstack/react': - specifier: npm:@aidenlx/vidstack-react@1.10.9-mod.1 - version: /@aidenlx/vidstack-react@1.10.9-mod.1(@types/react@18.2.48)(react@18.2.0) + specifier: npm:@aidenlx/vidstack-react@1.10.9-mod.4 + version: /@aidenlx/vidstack-react@1.10.9-mod.4(@types/react@18.2.48)(react@18.2.0) ahooks: specifier: ^3.7.8 version: 3.7.8(react@18.2.0) @@ -451,8 +451,8 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} - /@aidenlx/vidstack-react@1.10.9-mod.1(@types/react@18.2.48)(react@18.2.0): - resolution: {integrity: sha512-aMuCMMKz9stRfWkhLfS/HvlwjU1+68exjIjBn7PwK9x8LJ3oko85jenNQGcyjJfYRDKJVPHRvmnq99tNowVJWQ==} + /@aidenlx/vidstack-react@1.10.9-mod.4(@types/react@18.2.48)(react@18.2.0): + resolution: {integrity: sha512-EVS/xtoDoTtRZbT4UekbGiI6RBOfMlAyeGD03fAutKm9dTTczauNYs0/fMEShpzXvhcf/InbtxxZW8Nfq6ICwA==} engines: {node: '>=18'} peerDependencies: '@types/react': ^18.0.0