mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 19:11:49 +01:00
source selection
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
056f837dcb
commit
e448c0b5a8
@ -163,8 +163,6 @@ registerProvider({
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
|
||||
console.log(subtitleRes);
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map(
|
||||
(subtitle: any): MWCaption => {
|
||||
return {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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 /> */}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
32
src/video/components/actions/SourceSelectionAction.tsx
Normal file
32
src/video/components/actions/SourceSelectionAction.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 }) {
|
||||
|
205
src/video/components/popouts/SourceSelectionPopout.tsx
Normal file
205
src/video/components/popouts/SourceSelectionPopout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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,
|
||||
};
|
||||
|
17
src/video/state/providers/helpers.ts
Normal file
17
src/video/state/providers/helpers.ts
Normal 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);
|
||||
}
|
@ -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) {
|
||||
|
@ -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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user