source selection

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
Jelle van Snik 2023-02-09 22:03:40 +01:00
parent 056f837dcb
commit e448c0b5a8
12 changed files with 304 additions and 9 deletions

View File

@ -163,8 +163,6 @@ registerProvider({
const subtitleRes = (await get(subtitleApiQuery)).data;
console.log(subtitleRes);
const mappedCaptions = subtitleRes.list.map(
(subtitle: any): MWCaption => {
return {

View File

@ -97,7 +97,13 @@ function MediaCardContent({
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
<span>{media.title}</span>
</h1>
<DotList className="text-xs" content={[media.type, media.year]} />
<DotList
className="text-xs"
content={[
media.type.slice(0, 1).toUpperCase() + media.type.slice(1),
media.year,
]}
/>
</article>
</div>
);

View File

@ -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

View File

@ -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 (
<VideoPlayerBase
autoPlay={props.autoPlay}
@ -148,6 +148,7 @@ export function VideoPlayer(props: Props) {
<div className="flex-1" />
<QualityDisplayAction />
<SeriesSelectionAction />
<SourceSelectionAction />
{/* <SourceSelectionControl media={props.media} /> */}
<div className="mx-2 h-6 w-px bg-white opacity-50" />
{/* <ChromeCastControl /> */}

View File

@ -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 (
<div className={props.className}>
@ -18,6 +20,8 @@ export function CaptionsSelectionAction(props: Props) {
<PopoutAnchor for="captions">
<VideoPlayerIconButton
className={props.className}
text={isMobile ? "Captions" : ""}
wide={isMobile}
onClick={() => controls.openPopout("captions")}
icon={Icons.CAPTIONS}
/>

View File

@ -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 (
<div className={props.className}>
<div className="relative">
<PopoutAnchor for="source">
<VideoPlayerIconButton
active={videoInterface.popout === "source"}
icon={Icons.FILE}
text="Source"
wide
onClick={() => controls.openPopout("source")}
/>
</PopoutAnchor>
</div>
</div>
);
}

View File

@ -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 <EpisodeSelectionPopout />;
if (popoutId === "source") return <SourceSelectionPopout />;
if (popoutId === "captions") return <CaptionSelectionPopout />;
return null;
return (
<div className="flex w-full items-center justify-center p-10">
Unknown popout
</div>
);
}
function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {

View File

@ -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<string | null>(null);
const [scrapeResult, setScrapeResult] =
useState<MWProviderScrapeResult | null>(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<string | null>(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 (
<>
<PopoutSection className="bg-ash-100 font-bold text-white">
<div className="relative flex items-center">
<button
className={[
"-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200",
!showingProvider ? "pointer-events-none opacity-0" : "opacity-1",
].join(" ")}
onClick={() => selectProvider()}
type="button"
>
<Icon icon={Icons.CHEVRON_LEFT} />
</button>
<span
className={[
titlePositionClass,
showingProvider ? "opacity-1" : "opacity-0",
].join(" ")}
>
{selectedProviderPopulated?.displayName ?? ""}
</span>
<span
className={[
titlePositionClass,
!showingProvider ? "opacity-1" : "opacity-0",
].join(" ")}
>
Sources
</span>
</div>
</PopoutSection>
<div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
<PopoutSection
className={[
"absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
showingProvider
? "max-h-full border-t"
: "max-h-0 overflow-hidden py-0",
].join(" ")}
>
{loading ? (
<div className="flex h-full w-full items-center justify-center">
<Loading />
</div>
) : error ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col flex-wrap items-center text-slate-400">
<IconPatch
icon={Icons.EYE_SLASH}
className="text-xl text-bink-600"
/>
<p className="mt-6 w-full text-center">
Something went wrong loading the embeds for this thing that
you like
</p>
</div>
</div>
) : (
<>
{scrapeResult?.stream ? (
<PopoutListEntry
isOnDarkBackground
onClick={() => {
if (scrapeResult.stream) selectSource(scrapeResult.stream);
}}
>
Native source
</PopoutListEntry>
) : null}
{scrapeResult?.embeds.map((v) => (
<PopoutListEntry
isOnDarkBackground
key={v.url}
onClick={() => {
console.log("EMBED CHOSEN");
}}
>
{v.type}
</PopoutListEntry>
))}
</>
)}
</PopoutSection>
<PopoutSection className="relative h-full overflow-y-auto">
<div>
{providers.map((v) => (
<PopoutListEntry
key={v.id}
onClick={() => {
selectProvider(v.id);
}}
>
{v.displayName}
</PopoutListEntry>
))}
</div>
</PopoutSection>
</div>
</>
);
}

View File

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

View File

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

View File

@ -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) {

View File

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