From b8e49850f41e05b558bcd256a634940e6eed3220 Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Mon, 23 Jan 2023 01:55:57 +0100 Subject: [PATCH] episode selection Co-authored-by: James Hawkins Co-authored-by: Jip Frijlink --- .eslintrc.js | 21 +- src/components/Icon.tsx | 4 + src/components/video/DecoratedVideoPlayer.tsx | 2 + .../video/controls/SeriesSelectionControl.tsx | 208 ++++++++++++++++++ src/components/video/controls/ShowControl.tsx | 37 +++- .../video/controls/ShowTitleControl.tsx | 25 ++- src/components/video/hooks/controlVideo.ts | 24 ++ src/components/video/hooks/useVideoPlayer.ts | 6 + src/views/media/MediaView.tsx | 47 +++- 9 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 src/components/video/controls/SeriesSelectionControl.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 7710c4fd..5feb5ad4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,25 +8,25 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce( module.exports = { env: { - browser: true, + browser: true }, extends: [ "airbnb", "airbnb/hooks", "plugin:@typescript-eslint/recommended", "prettier", - "plugin:prettier/recommended", + "plugin:prettier/recommended" ], ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"], parser: "@typescript-eslint/parser", parserOptions: { project: "./tsconfig.json", - tsconfigRootDir: "./", + tsconfigRootDir: "./" }, settings: { "import/resolver": { - typescript: {}, - }, + typescript: {} + } }, plugins: ["@typescript-eslint", "import"], rules: { @@ -48,18 +48,19 @@ module.exports = { "no-continue": "off", "no-eval": "off", "no-await-in-loop": "off", + "no-nested-ternary": "off", "react/jsx-filename-extension": [ "error", - { extensions: [".js", ".tsx", ".jsx"] }, + { extensions: [".js", ".tsx", ".jsx"] } ], "import/extensions": [ "error", "ignorePackages", { ts: "never", - tsx: "never", - }, + tsx: "never" + } ], - ...a11yOff, - }, + ...a11yOff + } }; diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 54287d0c..92455784 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -10,6 +10,7 @@ export enum Icons { ARROW_RIGHT = "arrowRight", CHEVRON_DOWN = "chevronDown", CHEVRON_RIGHT = "chevronRight", + CHEVRON_LEFT = "chevronLeft", CLAPPER_BOARD = "clapperBoard", FILM = "film", DRAGON = "dragon", @@ -26,6 +27,7 @@ export enum Icons { X = "x", EDIT = "edit", AIRPLAY = "airplay", + EPISODES = "episodes", } export interface IconProps { @@ -41,6 +43,7 @@ const iconList: Record = { arrowLeft: ``, chevronDown: ``, chevronRight: ``, + chevronLeft: ``, clapperBoard: ``, film: ``, dragon: ``, @@ -59,6 +62,7 @@ const iconList: Record = { edit: ``, bookmark_outline: ``, airplay: ``, + episodes: ``, }; export const Icon = memo((props: IconProps) => { diff --git a/src/components/video/DecoratedVideoPlayer.tsx b/src/components/video/DecoratedVideoPlayer.tsx index 702496d8..a7b7601d 100644 --- a/src/components/video/DecoratedVideoPlayer.tsx +++ b/src/components/video/DecoratedVideoPlayer.tsx @@ -9,6 +9,7 @@ import { LoadingControl } from "./controls/LoadingControl"; import { MiddlePauseControl } from "./controls/MiddlePauseControl"; import { PauseControl } from "./controls/PauseControl"; import { ProgressControl } from "./controls/ProgressControl"; +import { SeriesSelectionControl } from "./controls/SeriesSelectionControl"; import { ShowTitleControl } from "./controls/ShowTitleControl"; import { TimeControl } from "./controls/TimeControl"; import { VolumeControl } from "./controls/VolumeControl"; @@ -93,6 +94,7 @@ export function DecoratedVideoPlayer(
+ diff --git a/src/components/video/controls/SeriesSelectionControl.tsx b/src/components/video/controls/SeriesSelectionControl.tsx new file mode 100644 index 00000000..efb3e675 --- /dev/null +++ b/src/components/video/controls/SeriesSelectionControl.tsx @@ -0,0 +1,208 @@ +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 { useVideoPlayerState } from "../VideoContext"; +import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; + +interface Props { + className?: string; +} + +export function PopupThingy(props: { + children?: React.ReactNode; + containerClassName?: string; +}) { + return ( +
+
+
+ {props.children} +
+
+
+ ); +} + +function PopupSection(props: { + children?: React.ReactNode; + className?: string; +}) { + return ( +
+ {props.children} +
+ ); +} + +function PopupEpisodeSelect() { + const params = useParams<{ + media: string; + }>(); + const { videoState } = useVideoPlayerState(); + const [isPickingSeason, setIsPickingSeason] = useState(false); + const { current, seasons } = videoState.seasonData; + 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 ?? current?.seasonId; + + const setCurrent = useCallback( + (seasonId: string, episodeId: string) => { + videoState.setCurrentEpisode(seasonId, episodeId); + }, + [videoState] + ); + + const currentSeasonInfo = useMemo(() => { + return seasons?.find((season) => season.id === currentSeasonId); + }, [seasons, currentSeasonId]); + + const currentSeasonEpisodes = useMemo(() => { + if (currentVisibleSeason?.season) { + return currentVisibleSeason?.season?.episodes; + } + return videoState?.seasonData.seasons?.find?.( + (season) => season && season.id === currentSeasonId + )?.episodes; + }, [videoState, currentSeasonId, currentVisibleSeason]); + + const toggleIsPickingSeason = () => { + setIsPickingSeason(!isPickingSeason); + }; + + const setSeason = (id: string) => { + requestSeason(id); + setCurrentVisibleSeason({ seasonId: id }); + }; + + if (isPickingSeason) + return ( + <> + + Pick a season + + +
+ {currentSeasonInfo + ? videoState?.seasonData?.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 SeriesSelectionControl(props: Props) { + const { videoState } = useVideoPlayerState(); + const [open, setOpen] = useState(false); + + if (!videoState.seasonData.isSeries) return null; + + return ( +
+
+ {open ? ( + + + + ) : null} + setOpen((s) => !s)} + /> +
+
+ ); +} diff --git a/src/components/video/controls/ShowControl.tsx b/src/components/video/controls/ShowControl.tsx index 5dafdf45..162870b7 100644 --- a/src/components/video/controls/ShowControl.tsx +++ b/src/components/video/controls/ShowControl.tsx @@ -1,4 +1,9 @@ +import { + MWSeasonMeta, + MWSeasonWithEpisodeMeta, +} from "@/backend/metadata/types"; import { useEffect, useRef } from "react"; +import { PlayerContext } from "../hooks/useVideoPlayer"; import { useVideoPlayerState } from "../VideoContext"; interface ShowControlProps { @@ -6,9 +11,28 @@ interface ShowControlProps { episodeId: string; seasonId: string; }; + seasons: MWSeasonMeta[]; + seasonData: MWSeasonWithEpisodeMeta; onSelect?: (state: { episodeId?: string; seasonId?: string }) => void; } +function setVideoShowState(videoState: PlayerContext, props: ShowControlProps) { + const seasonsWithEpisodes = props.seasons.map((v) => { + if (v.id === props.seasonData.id) + return { + ...v, + episodes: props.seasonData.episodes, + }; + return v; + }); + + videoState.setShowData({ + current: props.series, + isSeries: !!props.series, + seasons: seasonsWithEpisodes, + }); +} + export function ShowControl(props: ShowControlProps) { const { videoState } = useVideoPlayerState(); const lastState = useRef<{ @@ -19,14 +43,13 @@ export function ShowControl(props: ShowControlProps) { seasonId: props.series?.seasonId, }); + const hasInitialized = useRef(false); useEffect(() => { - videoState.setShowData({ - current: props.series, - isSeries: !!props.series, - }); - // we only want it to run when props change, not when videoState changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props]); + if (hasInitialized.current) return; + if (!videoState.hasInitialized) return; + setVideoShowState(videoState, props); + hasInitialized.current = true; + }, [props, videoState]); useEffect(() => { const currentState = { diff --git a/src/components/video/controls/ShowTitleControl.tsx b/src/components/video/controls/ShowTitleControl.tsx index 06cc7f7b..2cb08420 100644 --- a/src/components/video/controls/ShowTitleControl.tsx +++ b/src/components/video/controls/ShowTitleControl.tsx @@ -1,19 +1,30 @@ +import { useMemo } from "react"; import { useVideoPlayerState } from "../VideoContext"; export function ShowTitleControl() { const { videoState } = useVideoPlayerState(); - if (!videoState.seasonData.isSeries) return null; - if (!videoState.seasonData.title || !videoState.seasonData.current) - return null; + const { current, seasons } = videoState.seasonData; - const cur = videoState.seasonData.current; - const selectedText = `S${cur.season} E${cur.episode}`; + const currentSeasonInfo = useMemo(() => { + return seasons?.find((season) => season.id === current?.seasonId); + }, [seasons, current]); + + const currentEpisodeInfo = useMemo(() => { + return currentSeasonInfo?.episodes?.find( + (episode) => episode.id === current?.episodeId + ); + }, [currentSeasonInfo, current]); + + if (!videoState.seasonData.isSeries) return null; + if (!videoState.seasonData.current) return null; + + const selectedText = `S${currentSeasonInfo?.number} E${currentEpisodeInfo?.number}`; return ( -

+

{selectedText} - {videoState.seasonData.title} + {currentEpisodeInfo?.title}

); } diff --git a/src/components/video/hooks/controlVideo.ts b/src/components/video/hooks/controlVideo.ts index 531e94c3..57d7c130 100644 --- a/src/components/video/hooks/controlVideo.ts +++ b/src/components/video/hooks/controlVideo.ts @@ -17,6 +17,16 @@ interface ShowData { seasonId: string; }; isSeries: boolean; + seasons?: { + id: string; + number: number; + title: string; + episodes?: { + id: string; + number: number; + title: string; + }[]; + }[]; } export interface PlayerControls { @@ -30,6 +40,7 @@ export interface PlayerControls { setLeftControlsHover(hovering: boolean): void; initPlayer(sourceUrl: string, sourceType: MWStreamType): void; setShowData(data: ShowData): void; + setCurrentEpisode(sId: string, eId: string): void; startAirplay(): void; } @@ -45,6 +56,7 @@ export const initialControls: PlayerControls = { initPlayer: () => null, setShowData: () => null, startAirplay: () => null, + setCurrentEpisode: () => null, }; export function populateControls( @@ -120,6 +132,18 @@ export function populateControls( setShowData(data) { update((s) => ({ ...s, seasonData: data })); }, + setCurrentEpisode(sId: string, eId: string) { + update((s) => ({ + ...s, + seasonData: { + ...s.seasonData, + current: { + seasonId: sId, + episodeId: eId, + }, + }, + })); + }, startAirplay() { const videoPlayer = player as any; if (videoPlayer.webkitShowPlaybackTargetPicker) diff --git a/src/components/video/hooks/useVideoPlayer.ts b/src/components/video/hooks/useVideoPlayer.ts index dfb929c7..b1099d08 100644 --- a/src/components/video/hooks/useVideoPlayer.ts +++ b/src/components/video/hooks/useVideoPlayer.ts @@ -29,6 +29,12 @@ export type PlayerState = { episodeId: string; seasonId: string; }; + seasons?: { + id: string; + number: number; + title: string; + episodes?: { id: string; number: number; title: string }[]; + }[]; }; error: null | { name: string; diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index d8014674..2ff5305b 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -93,6 +93,7 @@ interface MediaViewPlayerProps { meta: DetailedMeta; stream: MWStream; selected: SelectedMediaData; + onChangeStream: (sId: string, eId: string) => void; } export function MediaViewPlayer(props: MediaViewPlayerProps) { const goBack = useGoBack(); @@ -120,13 +121,20 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) { startAt={firstStartTime.current} onProgress={updateProgress} /> - {props.selected.type === MWMediaType.SERIES ? ( + {props.selected.type === MWMediaType.SERIES && + props.meta.meta.type === MWMediaType.SERIES ? ( console.log("selected stuff", d)} + onSelect={(d) => + d.seasonId && + d.episodeId && + props.onChangeStream?.(d.seasonId, d.episodeId) + } + seasonData={props.meta.meta.seasonData} + seasons={props.meta.meta.seasons} /> ) : null} @@ -154,9 +162,25 @@ export function MediaView() { ); const [stream, setStream] = useState(null); + const lastSearchValue = useRef<(string | undefined)[] | null>(null); useEffect(() => { + const newValue = [params.media, params.season, params.episode]; + const lastVal = lastSearchValue.current; + + const isSame = + lastVal?.[0] === newValue[0] && + (lastVal?.[1] === newValue[1] || !lastVal?.[1]) && + (lastVal?.[2] === newValue[2] || !lastVal?.[2]); + + lastSearchValue.current = newValue; + if (isSame && lastVal !== null) return; + + setMeta(null); + setStream(null); + setSelected(null); exec(params.media, params.season).then((v) => { setMeta(v ?? null); + setStream(null); if (v) { if (v.meta.type !== MWMediaType.SERIES) { setSelected({ @@ -181,9 +205,7 @@ export function MediaView() { } } else setSelected(null); }); - // dont rerender when params changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [exec, history]); + }, [exec, history, params]); if (loading) return ; if (error) return ; @@ -206,5 +228,18 @@ export function MediaView() { ); // show stream once we have a stream - return ; + return ( + { + history.replace( + `/media/${encodeURIComponent(params.media)}/${encodeURIComponent( + sId + )}/${encodeURIComponent(eId)}` + ); + }} + /> + ); }