Something went wrong. Try again.
pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng
Something went wrong. Try again.
8.8 kB · 279 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280import classNames from "classnames";import { useCallback, useEffect, useRef } from "react";import { useTranslation } from "react-i18next";import { useAsync } from "react-use";
import { getMetaFromId } from "@/backend/metadata/getmeta";import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw";import { Button } from "@/components/buttons/Button";import { Icon, Icons } from "@/components/Icon";import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";import { Transition } from "@/components/utils/Transition";import { PlayerMeta } from "@/stores/player/slices/source";import { usePlayerStore } from "@/stores/player/store";import { usePreferencesStore } from "@/stores/preferences";import { useProgressStore } from "@/stores/progress";import { isAutoplayAllowed } from "@/utils/autoplay";
import { hasAired } from "../utils/aired";
function shouldShowNextEpisodeButton( time: number, duration: number,): "always" | "hover" | "none" { const percentage = time / duration; const secondsFromEnd = duration - time; if (secondsFromEnd <= 30) return "always"; if (percentage >= 0.93) return "hover"; return "none";}
function ActionButton(props: { className: string; onClick?: () => void; children: React.ReactNode;}) { return ( <button className={classNames( "font-bold rounded h-10 w-40 scale-95 hover:scale-100 transition-all duration-200", props.className, )} type="button" onClick={props.onClick} > {props.children} </button> );}
function useSeasons( mediaId: string | undefined, isLastEpisode: boolean = false,) { const state = useAsync(async () => { if (isLastEpisode) { if (!mediaId) return null; const data = await getMetaFromId(MWMediaType.SERIES, mediaId); if (data?.meta.type !== MWMediaType.SERIES) return null; return data.meta.seasons; } }, [mediaId, isLastEpisode]);
return state;}
function useNextSeasonEpisode( nextSeason: MWSeasonMeta | undefined, mediaId: string | undefined,) { const state = useAsync(async () => { if (nextSeason) { if (!mediaId) return null; const data = await getMetaFromId( MWMediaType.SERIES, mediaId, nextSeason?.id, ); if (data?.meta.type !== MWMediaType.SERIES) return null;
const nextSeasonEpisodes = data?.meta?.seasonData?.episodes .filter((episode) => hasAired(episode.air_date)) .map((episode) => ({ number: episode.number, title: episode.title, tmdbId: episode.id, }));
if (nextSeasonEpisodes.length > 0) return nextSeasonEpisodes[0]; } }, [mediaId, nextSeason?.id]); return state;}
export function NextEpisodeButton(props: { controlsShowing: boolean; onChange?: (meta: PlayerMeta) => void; inControl: boolean; showAsButton?: boolean; /** When true (e.g. in credits-to-end segment), show regardless of time/duration. */ forceShow?: boolean;}) { const { t } = useTranslation(); const duration = usePlayerStore((s) => s.progress.duration); const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn); const meta = usePlayerStore((s) => s.meta); const { setDirectMeta } = usePlayerMeta(); const metaType = usePlayerStore((s) => s.meta?.type); const time = usePlayerStore((s) => s.progress.time); const enableAutoplay = usePreferencesStore((s) => s.enableAutoplay); const enableSkipCredits = usePreferencesStore((s) => s.enableSkipCredits); const setLastSuccessfulSource = usePreferencesStore( (s) => s.setLastSuccessfulSource, ); const timeBasedState = shouldShowNextEpisodeButton(time, duration); const showingState = props.forceShow ? "always" : timeBasedState; const status = usePlayerStore((s) => s.status); const setShouldStartFromBeginning = usePlayerStore( (s) => s.setShouldStartFromBeginning, ); const updateItem = useProgressStore((s) => s.updateItem); const sourceId = usePlayerStore((s) => s.sourceId);
const isLastEpisode = !meta?.episode?.number || !meta?.episodes?.at(-1)?.number ? false : meta.episode.number === meta.episodes.at(-1)!.number;
const seasons = useSeasons(meta?.tmdbId, isLastEpisode);
const nextSeason = seasons.value?.find( (season) => season.number === (meta?.season?.number ?? 0) + 1, );
const nextSeasonEpisode = useNextSeasonEpisode(nextSeason, meta?.tmdbId);
let show = false; const hasAutoplayed = useRef(false); if (showingState === "always") show = true; else if (showingState === "hover" && props.controlsShowing) show = true; if (isHidden || status !== "playing" || duration === 0) show = false;
const animation = showingState === "hover" ? "slide-up" : "fade"; let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]"; if (showingState === "always") bottom = props.controlsShowing ? bottom : "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
const nextEp = isLastEpisode ? nextSeasonEpisode.value : meta?.episodes?.find( (v) => v.number === (meta?.episode?.number ?? 0) + 1, );
const loadNextEpisode = useCallback(() => { if (!meta || !nextEp) return;
// Store the current source as the last successful source if (sourceId) { setLastSuccessfulSource(sourceId); }
const metaCopy = { ...meta }; metaCopy.episode = nextEp; metaCopy.season = isLastEpisode && nextSeason ? { ...nextSeason, tmdbId: nextSeason.id, } : metaCopy.season; setShouldStartFromBeginning(true); setDirectMeta(metaCopy); props.onChange?.(metaCopy); const defaultProgress = { duration: 0, watched: 0 }; updateItem({ meta: metaCopy, progress: defaultProgress, }); }, [ setDirectMeta, nextEp, meta, props, setShouldStartFromBeginning, updateItem, isLastEpisode, nextSeason, sourceId, setLastSuccessfulSource, ]);
const startCurrentEpisodeFromBeginning = useCallback(() => { if (!meta || !meta.episode) return; const metaCopy = { ...meta }; setShouldStartFromBeginning(true); setDirectMeta(metaCopy); props.onChange?.(metaCopy); const defaultProgress = { duration: 0, watched: 0 }; updateItem({ meta: metaCopy, progress: defaultProgress, }); }, [setDirectMeta, meta, props, setShouldStartFromBeginning, updateItem]);
useEffect(() => { if (!enableAutoplay || metaType !== "show") return; const onePercent = duration / 100;
// When skipCredits is enabled, use the 99% threshold; otherwise require 100% completion const isEnding = enableSkipCredits ? time >= duration - onePercent && duration !== 0 // 99% completion : time >= duration && duration !== 0; // 100% completion
if (duration === 0) hasAutoplayed.current = false; if (isEnding && isAutoplayAllowed() && !hasAutoplayed.current) { hasAutoplayed.current = true; loadNextEpisode(); } }, [ duration, enableAutoplay, enableSkipCredits, loadNextEpisode, metaType, time, ]);
if (!props.inControl) return null; if (!meta?.episode || !nextEp) return null; if (metaType !== "show") return null;
if (props.showAsButton) { return ( <Button onClick={() => loadNextEpisode()} theme="secondary" padding="md:px-12 p-2.5" className="w-full" > <Icon className="mr-2" icon={Icons.SKIP_EPISODE} /> {isLastEpisode && nextEp ? t("player.nextEpisode.nextSeason") : t("player.nextEpisode.next")} </Button> ); }
return ( <Transition animation={animation} show={show} className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0" > <div className={classNames([ "absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3", bottom, ])} > <ActionButton className="py-px box-content bg-buttons-secondary hover:bg-buttons-secondaryHover bg-opacity-90 text-buttons-secondaryText justify-center items-center" onClick={() => startCurrentEpisodeFromBeginning()} > {t("player.nextEpisode.replay")} </ActionButton> <ActionButton onClick={() => loadNextEpisode()} className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" > <Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} /> {isLastEpisode && nextEp ? t("player.nextEpisode.nextSeason") : t("player.nextEpisode.next")} </ActionButton> </div> </Transition> );}