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;
};