diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts index 15aceb06..c0861452 100644 --- a/src/backend/providers/superstream/index.ts +++ b/src/backend/providers/superstream/index.ts @@ -163,8 +163,6 @@ registerProvider({ const subtitleRes = (await get(subtitleApiQuery)).data; - console.log(subtitleRes); - const mappedCaptions = subtitleRes.list.map( (subtitle: any): MWCaption => { return { diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index cc30312a..6a19d88b 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -97,7 +97,13 @@ function MediaCardContent({

{media.title}

- + ); diff --git a/src/index.tsx b/src/index.tsx index 279c4048..eed53571 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,13 +19,11 @@ if (key) { initializeChromecast(); // TODO video todos: -// - finish captions // - chrome cast support -// - bug: mobile controls start showing when resizing -// - bug: popouts sometimes stop working when selecting different episode // - bug: unmounting player throws errors in console // - bug: safari fullscreen will make video overlap player controls -// - bug: safari progress bar is fucked (video doesnt change time but video.currentTime does change) +// - improvement: make scrapers use fuzzy matching on normalized titles +// - bug: source selection doesnt work with HLS // TODO stuff to test: // - browser: firefox, chrome, edge, safari desktop diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx index 7efca5fa..4a369926 100644 --- a/src/video/components/VideoPlayer.tsx +++ b/src/video/components/VideoPlayer.tsx @@ -12,6 +12,7 @@ import { PauseAction } from "@/video/components/actions/PauseAction"; import { ProgressAction } from "@/video/components/actions/ProgressAction"; import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction"; import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction"; +import { SourceSelectionAction } from "@/video/components/actions/SourceSelectionAction"; import { CaptionsSelectionAction } from "@/video/components/actions/CaptionsSelectionAction"; import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction"; import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction"; @@ -77,7 +78,6 @@ export function VideoPlayer(props: Props) { [setShow] ); - // TODO source selection return ( + {/* */}
{/* */} diff --git a/src/video/components/actions/CaptionsSelectionAction.tsx b/src/video/components/actions/CaptionsSelectionAction.tsx index 79f6582c..d2bc588a 100644 --- a/src/video/components/actions/CaptionsSelectionAction.tsx +++ b/src/video/components/actions/CaptionsSelectionAction.tsx @@ -3,6 +3,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; import { useControls } from "@/video/state/logic/controls"; import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; +import { useIsMobile } from "@/hooks/useIsMobile"; interface Props { className?: string; @@ -11,6 +12,7 @@ interface Props { export function CaptionsSelectionAction(props: Props) { const descriptor = useVideoPlayerDescriptor(); const controls = useControls(descriptor); + const { isMobile } = useIsMobile(); return (
@@ -18,6 +20,8 @@ export function CaptionsSelectionAction(props: Props) { controls.openPopout("captions")} icon={Icons.CAPTIONS} /> diff --git a/src/video/components/actions/SourceSelectionAction.tsx b/src/video/components/actions/SourceSelectionAction.tsx new file mode 100644 index 00000000..6434bd0a --- /dev/null +++ b/src/video/components/actions/SourceSelectionAction.tsx @@ -0,0 +1,32 @@ +import { Icons } from "@/components/Icon"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; +import { useControls } from "@/video/state/logic/controls"; +import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; +import { useInterface } from "@/video/state/logic/interface"; + +interface Props { + className?: string; +} + +export function SourceSelectionAction(props: Props) { + const descriptor = useVideoPlayerDescriptor(); + const videoInterface = useInterface(descriptor); + const controls = useControls(descriptor); + + return ( +
+
+ + controls.openPopout("source")} + /> + +
+
+ ); +} diff --git a/src/video/components/popouts/PopoutProviderAction.tsx b/src/video/components/popouts/PopoutProviderAction.tsx index de88057e..8898bf26 100644 --- a/src/video/components/popouts/PopoutProviderAction.tsx +++ b/src/video/components/popouts/PopoutProviderAction.tsx @@ -1,6 +1,7 @@ import { Transition } from "@/components/Transition"; import { useSyncPopouts } from "@/video/components/hooks/useSyncPopouts"; import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout"; +import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectionPopout"; import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; @@ -21,8 +22,13 @@ function ShowPopout(props: { popoutId: string | null }) { }, [props]); if (popoutId === "episodes") return ; + if (popoutId === "source") return ; if (popoutId === "captions") return ; - return null; + return ( +
+ Unknown popout +
+ ); } function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) { diff --git a/src/video/components/popouts/SourceSelectionPopout.tsx b/src/video/components/popouts/SourceSelectionPopout.tsx new file mode 100644 index 00000000..b5e9d62a --- /dev/null +++ b/src/video/components/popouts/SourceSelectionPopout.tsx @@ -0,0 +1,205 @@ +import { useMemo, useRef, useState } from "react"; +import { Icon, Icons } from "@/components/Icon"; +import { useLoading } from "@/hooks/useLoading"; +import { Loading } from "@/components/layout/Loading"; +import { IconPatch } from "@/components/buttons/IconPatch"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMeta } from "@/video/state/logic/meta"; +import { useControls } from "@/video/state/logic/controls"; +import { MWStream } from "@/backend/helpers/streams"; +import { getProviders } from "@/backend/helpers/register"; +import { runProvider } from "@/backend/helpers/run"; +import { MWProviderScrapeResult } from "@/backend/helpers/provider"; +import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; + +// TODO HLS does not work +export function SourceSelectionPopout() { + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + const meta = useMeta(descriptor); + const providers = useMemo( + () => + meta ? getProviders().filter((v) => v.type.includes(meta.meta.type)) : [], + [meta] + ); + + const [selectedProvider, setSelectedProvider] = useState(null); + const [scrapeResult, setScrapeResult] = + useState(null); + const showingProvider = !!selectedProvider; + const selectedProviderPopulated = useMemo( + () => providers.find((v) => v.id === selectedProvider) ?? null, + [providers, selectedProvider] + ); + const [runScraper, loading, error] = useLoading( + async (providerId: string) => { + const theProvider = providers.find((v) => v.id === providerId); + if (!theProvider) throw new Error("Invalid provider"); + if (!meta) throw new Error("need meta"); + return runProvider(theProvider, { + media: { + imdbId: "", // TODO get actual ids + tmdbId: "", + meta: meta.meta, + }, + progress: () => {}, + type: meta.meta.type, + episode: meta.episode?.episodeId as any, + season: meta.episode?.seasonId as any, + }); + } + ); + + function selectSource(stream: MWStream) { + controls.setSource({ + quality: stream.quality, + source: stream.streamUrl, + type: stream.type, + }); + if (meta) { + controls.setMeta({ + ...meta, + captions: stream.captions, + }); + } + controls.closePopout(); + } + + const providerRef = useRef(null); + const selectProvider = (providerId?: string) => { + if (!providerId) { + providerRef.current = null; + setSelectedProvider(null); + return; + } + + runScraper(providerId).then((v) => { + if (!providerRef.current) return; + if (v) { + const len = v.embeds.length + (v.stream ? 1 : 0); + if (len === 1) { + const realStream = v.stream; + if (!realStream) { + // TODO scrape embed + throw new Error("no embed scraper configured"); + } + selectSource(realStream); + return; + } + } + setScrapeResult(v ?? null); + }); + providerRef.current = providerId; + setSelectedProvider(providerId); + }; + + const titlePositionClass = useMemo(() => { + const offset = !showingProvider ? "left-0" : "left-10"; + return [ + "absolute w-full transition-[left,opacity] duration-200", + offset, + ].join(" "); + }, [showingProvider]); + + return ( + <> + +
+ + + {selectedProviderPopulated?.displayName ?? ""} + + + Sources + +
+
+
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+
+ +

+ Something went wrong loading the embeds for this thing that + you like +

+
+
+ ) : ( + <> + {scrapeResult?.stream ? ( + { + if (scrapeResult.stream) selectSource(scrapeResult.stream); + }} + > + Native source + + ) : null} + {scrapeResult?.embeds.map((v) => ( + { + console.log("EMBED CHOSEN"); + }} + > + {v.type} + + ))} + + )} +
+ +
+ {providers.map((v) => ( + { + selectProvider(v.id); + }} + > + {v.displayName} + + ))} +
+
+
+ + ); +} diff --git a/src/video/state/init.ts b/src/video/state/init.ts index 6914befa..438841e4 100644 --- a/src/video/state/init.ts +++ b/src/video/state/init.ts @@ -2,6 +2,27 @@ import { nanoid } from "nanoid"; import { _players } from "./cache"; import { VideoPlayerState } from "./types"; +export function resetForSource(s: VideoPlayerState) { + const state = s; + state.mediaPlaying = { + isPlaying: false, + isPaused: true, + isLoading: false, + isSeeking: false, + isDragSeeking: false, + isFirstLoading: true, + hasPlayedOnce: false, + volume: 0, + }; + state.progress = { + time: 0, + duration: 0, + buffered: 0, + draggingTime: 0, + }; + state.initalized = false; +} + function initPlayer(): VideoPlayerState { return { interface: { @@ -38,6 +59,7 @@ function initPlayer(): VideoPlayerState { initalized: false, pausedWhenSeeking: false, + hlsInstance: null, stateProvider: null, wrapperElement: null, }; diff --git a/src/video/state/providers/helpers.ts b/src/video/state/providers/helpers.ts new file mode 100644 index 00000000..f21d7131 --- /dev/null +++ b/src/video/state/providers/helpers.ts @@ -0,0 +1,17 @@ +import { resetForSource } from "@/video/state/init"; +import { updateMediaPlaying } from "@/video/state/logic/mediaplaying"; +import { updateMisc } from "@/video/state/logic/misc"; +import { updateProgress } from "@/video/state/logic/progress"; +import { VideoPlayerState } from "@/video/state/types"; + +export function resetStateForSource(descriptor: string, s: VideoPlayerState) { + const state = s; + if (state.hlsInstance) { + state.hlsInstance.destroy(); + state.hlsInstance = null; + } + resetForSource(state); + updateMediaPlaying(descriptor, state); + updateProgress(descriptor, state); + updateMisc(descriptor, state); +} diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index b7cb395f..eb88f5df 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -15,6 +15,7 @@ import { } from "@/video/components/hooks/volumeStore"; import { updateError } from "@/video/state/logic/error"; import { updateMisc } from "@/video/state/logic/misc"; +import { resetStateForSource } from "@/video/state/providers/helpers"; import { getPlayerState } from "../cache"; import { updateMediaPlaying } from "../logic/mediaplaying"; import { VideoPlayerStateProvider } from "./providerTypes"; @@ -130,6 +131,7 @@ export function createVideoStateProvider( if (!source) { player.src = ""; state.source = null; + resetStateForSource(descriptor, state); updateSource(descriptor, state); return; } @@ -149,6 +151,7 @@ export function createVideoStateProvider( } const hls = new Hls({ enableWorker: false }); + state.hlsInstance = hls; hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { @@ -175,6 +178,7 @@ export function createVideoStateProvider( url: source.source, caption: null, }; + resetStateForSource(descriptor, state); updateSource(descriptor, state); }, setCaption(id, url) { diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 41de0c72..3ca48aa9 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -4,6 +4,7 @@ import { MWStreamType, } from "@/backend/helpers/streams"; import { MWMediaMeta } from "@/backend/metadata/types"; +import Hls from "hls.js"; import { VideoPlayerStateProvider } from "./providers/providerTypes"; export type VideoPlayerMeta = { @@ -73,6 +74,7 @@ export type VideoPlayerState = { // backing fields pausedWhenSeeking: boolean; // when seeking, used to store if paused when started to seek + hlsInstance: null | Hls; // HLS video player instance storage stateProvider: VideoPlayerStateProvider | null; wrapperElement: HTMLDivElement | null; };