diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx
index cb441ce8..1ede8c5d 100644
--- a/src/video/components/VideoPlayer.tsx
+++ b/src/video/components/VideoPlayer.tsx
@@ -10,6 +10,7 @@ import { PageTitleAction } from "@/video/components/actions/PageTitleAction";
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 { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
import { TimeAction } from "@/video/components/actions/TimeAction";
@@ -118,7 +119,7 @@ export function VideoPlayer(props: Props) {
- {/* */}
+
{/* */}
@@ -128,8 +129,8 @@ export function VideoPlayer(props: Props) {
- {/*
-
+
+ {/*
*/}
diff --git a/src/video/components/actions/SeriesSelectionAction.tsx b/src/video/components/actions/SeriesSelectionAction.tsx
new file mode 100644
index 00000000..7229e7dd
--- /dev/null
+++ b/src/video/components/actions/SeriesSelectionAction.tsx
@@ -0,0 +1,201 @@
+import React, { useCallback, useMemo, useState } from "react";
+import { useParams } from "react-router-dom";
+import { Icon, Icons } from "@/components/Icon";
+import { useLoading } from "@/hooks/useLoading";
+import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
+import { getMetaFromId } from "@/backend/metadata/getmeta";
+import { decodeJWId } from "@/backend/metadata/justwatch";
+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 { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
+import { useControls } from "@/video/state/logic/controls";
+import { VideoPopout } from "@/video/components/parts/VideoPopout";
+
+interface Props {
+ className?: string;
+}
+
+function PopupSection(props: {
+ children?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+function PopupEpisodeSelect() {
+ const params = useParams<{
+ media: string;
+ }>();
+ const descriptor = useVideoPlayerDescriptor();
+ const meta = useMeta(descriptor);
+ const controls = useControls(descriptor);
+
+ const [isPickingSeason, setIsPickingSeason] = useState
(false);
+ const [currentVisibleSeason, setCurrentVisibleSeason] = useState<{
+ seasonId: string;
+ season?: MWSeasonWithEpisodeMeta;
+ } | null>(null);
+ const [reqSeasonMeta, loading, error] = useLoading(
+ (id: string, seasonId: string) => {
+ return getMetaFromId(MWMediaType.SERIES, id, seasonId);
+ }
+ );
+ const requestSeason = useCallback(
+ (sId: string) => {
+ setCurrentVisibleSeason({
+ seasonId: sId,
+ season: undefined,
+ });
+ setIsPickingSeason(false);
+ reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => {
+ if (v?.meta.type !== MWMediaType.SERIES) return;
+ setCurrentVisibleSeason({
+ seasonId: sId,
+ season: v?.meta.seasonData,
+ });
+ });
+ },
+ [reqSeasonMeta, params.media]
+ );
+
+ const currentSeasonId =
+ currentVisibleSeason?.seasonId ?? meta?.episode?.seasonId;
+
+ const setCurrent = useCallback(
+ (seasonId: string, episodeId: string) => {
+ controls.setCurrentEpisode(seasonId, episodeId);
+ },
+ [controls]
+ );
+
+ const currentSeasonInfo = useMemo(() => {
+ return meta?.seasons?.find((season) => season.id === currentSeasonId);
+ }, [meta, currentSeasonId]);
+
+ const currentSeasonEpisodes = useMemo(() => {
+ if (currentVisibleSeason?.season) {
+ return currentVisibleSeason?.season?.episodes;
+ }
+ return meta?.seasons?.find?.(
+ (season) => season && season.id === currentSeasonId
+ )?.episodes;
+ }, [meta, currentSeasonId, currentVisibleSeason]);
+
+ const toggleIsPickingSeason = () => {
+ setIsPickingSeason(!isPickingSeason);
+ };
+
+ const setSeason = (id: string) => {
+ requestSeason(id);
+ setCurrentVisibleSeason({ seasonId: id });
+ };
+
+ if (isPickingSeason)
+ return (
+ <>
+
+ Pick a season
+
+
+
+ {currentSeasonInfo
+ ? meta?.seasons?.map?.((season) => (
+
setSeason(season.id)}
+ >
+ {season.title}
+
+ ))
+ : "No season"}
+
+
+ >
+ );
+
+ return (
+ <>
+
+
+ {currentSeasonInfo?.title || ""}
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+
+
+ Something went wrong loading the episodes for{" "}
+ {currentSeasonInfo?.title?.toLowerCase()}
+
+
+
+ ) : (
+
+ {currentSeasonEpisodes && currentSeasonInfo
+ ? currentSeasonEpisodes.map((e) => (
+
setCurrent(currentSeasonInfo.id, e.id)}
+ key={e.id}
+ >
+ {e.number}. {e.title}
+
+ ))
+ : "No episodes"}
+
+ )}
+
+ >
+ );
+}
+
+export function SeriesSelectionAction(props: Props) {
+ const descriptor = useVideoPlayerDescriptor();
+ const meta = useMeta(descriptor);
+ const controls = useControls(descriptor);
+
+ if (meta?.meta.type !== MWMediaType.SERIES) return null;
+
+ return (
+
+
+
+
+
+
controls.openPopout("episodes")}
+ />
+
+
+ );
+}
diff --git a/src/video/components/controllers/MetaController.tsx b/src/video/components/controllers/MetaController.tsx
index be35a702..103ef0ab 100644
--- a/src/video/components/controllers/MetaController.tsx
+++ b/src/video/components/controllers/MetaController.tsx
@@ -1,10 +1,33 @@
-import { MWMediaMeta } from "@/backend/metadata/types";
+import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
+import { VideoPlayerMeta } from "@/video/state/types";
import { useEffect } from "react";
interface MetaControllerProps {
- meta?: MWMediaMeta;
+ data?: VideoPlayerMeta;
+ seasonData?: MWSeasonWithEpisodeMeta;
+}
+
+function formatMetadata(
+ props: MetaControllerProps
+): VideoPlayerMeta | undefined {
+ const seasonsWithEpisodes = props.data?.seasons?.map((v) => {
+ if (v.id === props.seasonData?.id)
+ return {
+ ...v,
+ episodes: props.seasonData.episodes,
+ };
+ return v;
+ });
+
+ if (!props.data) return undefined;
+
+ return {
+ meta: props.data.meta,
+ episode: props.data.episode,
+ seasons: seasonsWithEpisodes,
+ };
}
export function MetaController(props: MetaControllerProps) {
@@ -12,7 +35,7 @@ export function MetaController(props: MetaControllerProps) {
const controls = useControls(descriptor);
useEffect(() => {
- controls.setMeta(props.meta);
+ controls.setMeta(formatMetadata(props));
}, [props, controls]);
return null;
diff --git a/src/video/components/controllers/SeriesController.tsx b/src/video/components/controllers/SeriesController.tsx
new file mode 100644
index 00000000..f38701d5
--- /dev/null
+++ b/src/video/components/controllers/SeriesController.tsx
@@ -0,0 +1,39 @@
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useMeta } from "@/video/state/logic/meta";
+import { useEffect, useRef } from "react";
+
+interface SeriesControllerProps {
+ onSelect?: (state: { episodeId?: string; seasonId?: string }) => void;
+}
+
+export function SeriesController(props: SeriesControllerProps) {
+ const descriptor = useVideoPlayerDescriptor();
+ const meta = useMeta(descriptor);
+
+ const lastState = useRef<{
+ episodeId?: string;
+ seasonId?: string;
+ } | null>(null);
+
+ useEffect(() => {
+ const currentState = {
+ episodeId: meta?.episode?.episodeId,
+ seasonId: meta?.episode?.seasonId,
+ };
+ if (lastState.current === null) {
+ lastState.current = currentState;
+ return;
+ }
+
+ // when changes are detected, trigger event handler
+ if (
+ currentState.episodeId !== lastState.current?.episodeId ||
+ currentState.seasonId !== lastState.current?.seasonId
+ ) {
+ lastState.current = currentState;
+ props.onSelect?.(currentState);
+ }
+ }, [meta, props]);
+
+ return null;
+}
diff --git a/src/video/components/parts/VideoPopout.tsx b/src/video/components/parts/VideoPopout.tsx
new file mode 100644
index 00000000..f59fa969
--- /dev/null
+++ b/src/video/components/parts/VideoPopout.tsx
@@ -0,0 +1,65 @@
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useControls } from "@/video/state/logic/controls";
+import { useInterface } from "@/video/state/logic/interface";
+import { useEffect, useRef } from "react";
+
+interface Props {
+ children?: React.ReactNode;
+ id?: string;
+ className?: string;
+}
+
+// TODO store popout in router history so you can press back to yeet
+// TODO add transition
+export function VideoPopout(props: Props) {
+ const descriptor = useVideoPlayerDescriptor();
+ const videoInterface = useInterface(descriptor);
+ const controls = useControls(descriptor);
+
+ const popoutRef = useRef(null);
+ const isOpen = videoInterface.popout === props.id;
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const popoutEl = popoutRef.current;
+ function windowClick(e: MouseEvent) {
+ const rect = popoutEl?.getBoundingClientRect();
+ if (rect) {
+ if (
+ e.pageX >= rect.x &&
+ e.pageX <= rect.x + rect.width &&
+ e.pageY >= rect.y &&
+ e.pageY <= rect.y + rect.height
+ ) {
+ // inside bounding box of popout
+ return;
+ }
+ }
+
+ controls.closePopout();
+ }
+
+ window.addEventListener("click", windowClick);
+ return () => {
+ window.removeEventListener("click", windowClick);
+ };
+ }, [isOpen, controls]);
+
+ return (
+
+
+
+ {isOpen ? props.children : null}
+
+
+
+ );
+}
diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts
index d9378f4d..a5396326 100644
--- a/src/video/state/logic/controls.ts
+++ b/src/video/state/logic/controls.ts
@@ -1,15 +1,16 @@
-import { MWMediaMeta } from "@/backend/metadata/types";
import { updateInterface } from "@/video/state/logic/interface";
import { updateMeta } from "@/video/state/logic/meta";
+import { VideoPlayerMeta } from "@/video/state/types";
import { getPlayerState } from "../cache";
import { VideoPlayerStateController } from "../providers/providerTypes";
-type ControlMethods = {
+export type ControlMethods = {
openPopout(id: string): void;
closePopout(): void;
setLeftControlsHover(hovering: boolean): void;
setFocused(focused: boolean): void;
- setMeta(meta?: MWMediaMeta): void;
+ setMeta(data?: VideoPlayerMeta): void;
+ setCurrentEpisode(sId: string, eId: string): void;
};
export function useControls(
@@ -65,11 +66,18 @@ export function useControls(
if (!meta) {
state.meta = null;
} else {
- state.meta = {
- meta,
- };
+ state.meta = meta;
}
updateMeta(descriptor, state);
},
+ setCurrentEpisode(sId, eId) {
+ if (state.meta) {
+ state.meta.episode = {
+ seasonId: sId,
+ episodeId: eId,
+ };
+ updateMeta(descriptor, state);
+ }
+ },
};
}
diff --git a/src/views/TestView.tsx b/src/views/TestView.tsx
index cc3d00da..5436207b 100644
--- a/src/views/TestView.tsx
+++ b/src/views/TestView.tsx
@@ -5,8 +5,8 @@
// import { useEffect, useRef } from "react";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
-import { MWMediaType } from "@/backend/metadata/types";
-import { MetaController } from "@/video/components/controllers/MetaController";
+// import { MWMediaType } from "@/backend/metadata/types";
+// import { MetaController } from "@/video/components/controllers/MetaController";
import { SourceController } from "@/video/components/controllers/SourceController";
import { VideoPlayer } from "@/video/components/VideoPlayer";
@@ -32,15 +32,15 @@ export function TestView() {
source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4"
type={MWStreamType.MP4}
/>
-
+ }} */}
+ {/* /> */}
);
}
diff --git a/src/views/media/MediaErrorView.tsx b/src/views/media/MediaErrorView.tsx
index 0e26a434..47b4863c 100644
--- a/src/views/media/MediaErrorView.tsx
+++ b/src/views/media/MediaErrorView.tsx
@@ -1,9 +1,9 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link";
-import { VideoPlayerHeader } from "@/../__old/parts/VideoPlayerHeader";
import { useGoBack } from "@/hooks/useGoBack";
import { conf } from "@/setup/config";
+import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader";
import { Helmet } from "react-helmet";
export function MediaFetchErrorView() {
diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx
index a3110af3..9b5747c4 100644
--- a/src/views/media/MediaView.tsx
+++ b/src/views/media/MediaView.tsx
@@ -7,7 +7,7 @@ import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { decodeJWId } from "@/backend/metadata/justwatch";
import { Loading } from "@/components/layout/Loading";
import { useLoading } from "@/hooks/useLoading";
-import { MWMediaType } from "@/backend/metadata/types";
+import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
import { useGoBack } from "@/hooks/useGoBack";
import { IconPatch } from "@/components/buttons/IconPatch";
import { VideoPlayer } from "@/video/components/VideoPlayer";
@@ -16,6 +16,8 @@ import { SourceController } from "@/video/components/controllers/SourceControlle
import { Icons } from "@/components/Icon";
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader";
import { ProgressListenerController } from "@/video/components/controllers/ProgressListenerController";
+import { VideoPlayerMeta } from "@/video/state/types";
+import { SeriesController } from "@/video/components/controllers/SeriesController";
import { useWatchedItem } from "@/state/watched";
import { MediaFetchErrorView } from "./MediaErrorView";
import { MediaScrapeLog } from "./MediaScrapeLog";
@@ -108,13 +110,29 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.stream]);
+ const metaProps: VideoPlayerMeta = {
+ meta: props.meta.meta,
+ };
+ let metaSeasonData: MWSeasonWithEpisodeMeta | undefined;
+ if (
+ props.selected.type === MWMediaType.SERIES &&
+ props.meta.meta.type === MWMediaType.SERIES
+ ) {
+ metaProps.episode = {
+ seasonId: props.selected.season,
+ episodeId: props.selected.episode,
+ };
+ metaProps.seasons = props.meta.meta.seasons;
+ metaSeasonData = props.meta.meta.seasonData;
+ }
+
return (
-
+
- {/* {props.selected.type === MWMediaType.SERIES &&
- props.meta.meta.type === MWMediaType.SERIES ? (
-
- d.seasonId &&
- d.episodeId &&
- props.onChangeStream?.(d.seasonId, d.episodeId)
- }
- seasonData={props.meta.meta.seasonData}
- seasons={props.meta.meta.seasons}
- />
- ) : null} */}
+
+ d.seasonId &&
+ d.episodeId &&
+ props.onChangeStream?.(d.seasonId, d.episodeId)
+ }
+ />
);
diff --git a/src/views/notfound/NotFoundView.tsx b/src/views/notfound/NotFoundView.tsx
index 55dfed7c..83890e63 100644
--- a/src/views/notfound/NotFoundView.tsx
+++ b/src/views/notfound/NotFoundView.tsx
@@ -6,8 +6,8 @@ import { Navigation } from "@/components/layout/Navigation";
import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title";
import { useGoBack } from "@/hooks/useGoBack";
-import { VideoPlayerHeader } from "@/../__old/parts/VideoPlayerHeader";
import { Helmet } from "react-helmet";
+import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader";
export function NotFoundWrapper(props: {
children?: ReactNode;