add resume action page

asks what you want to do if your progress is greater than 80%
This commit is contained in:
Pas 2025-08-06 11:42:10 -06:00
parent 1a95c052a9
commit 5bb28c02c7
6 changed files with 184 additions and 6 deletions

View file

@ -740,6 +740,13 @@
"next": "Next episode", "next": "Next episode",
"nextSeason": "Next season" "nextSeason": "Next season"
}, },
"resume": {
"badge": "Resume",
"title": "Continue watching?",
"description": "This episode is {{percentage}}% watched",
"resume": "Resume",
"restart": "Restart"
},
"playbackError": { "playbackError": {
"badge": "Playback error", "badge": "Playback error",
"errors": { "errors": {

View file

@ -5,6 +5,7 @@ import { useAsync } from "react-use";
import { getMetaFromId } from "@/backend/metadata/getmeta"; import { getMetaFromId } from "@/backend/metadata/getmeta";
import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw"; import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { Transition } from "@/components/utils/Transition"; import { Transition } from "@/components/utils/Transition";
@ -27,7 +28,7 @@ function shouldShowNextEpisodeButton(
return "none"; return "none";
} }
function Button(props: { function ActionButton(props: {
className: string; className: string;
onClick?: () => void; onClick?: () => void;
children: React.ReactNode; children: React.ReactNode;
@ -94,6 +95,7 @@ export function NextEpisodeButton(props: {
controlsShowing: boolean; controlsShowing: boolean;
onChange?: (meta: PlayerMeta) => void; onChange?: (meta: PlayerMeta) => void;
inControl: boolean; inControl: boolean;
showAsButton?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const duration = usePlayerStore((s) => s.progress.duration); const duration = usePlayerStore((s) => s.progress.duration);
@ -213,6 +215,22 @@ export function NextEpisodeButton(props: {
if (!meta?.episode || !nextEp) return null; if (!meta?.episode || !nextEp) return null;
if (metaType !== "show") 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 ( return (
<Transition <Transition
animation={animation} animation={animation}
@ -225,13 +243,13 @@ export function NextEpisodeButton(props: {
bottom, bottom,
])} ])}
> >
<Button <ActionButton
className="py-px box-content bg-buttons-secondary hover:bg-buttons-secondaryHover bg-opacity-90 text-buttons-secondaryText justify-center items-center" className="py-px box-content bg-buttons-secondary hover:bg-buttons-secondaryHover bg-opacity-90 text-buttons-secondaryText justify-center items-center"
onClick={() => startCurrentEpisodeFromBeginning()} onClick={() => startCurrentEpisodeFromBeginning()}
> >
{t("player.nextEpisode.replay")} {t("player.nextEpisode.replay")}
</Button> </ActionButton>
<Button <ActionButton
onClick={() => loadNextEpisode()} onClick={() => loadNextEpisode()}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center" className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
> >
@ -239,7 +257,7 @@ export function NextEpisodeButton(props: {
{isLastEpisode && nextEp {isLastEpisode && nextEp
? t("player.nextEpisode.nextSeason") ? t("player.nextEpisode.nextSeason")
: t("player.nextEpisode.next")} : t("player.nextEpisode.next")}
</Button> </ActionButton>
</div> </div>
</Transition> </Transition>
); );

View file

@ -54,6 +54,7 @@ export function usePlayer() {
status, status,
shouldStartFromBeginning, shouldStartFromBeginning,
setShouldStartFromBeginning, setShouldStartFromBeginning,
setStatus,
setMeta(m: PlayerMeta, newStatus?: PlayerStatus) { setMeta(m: PlayerMeta, newStatus?: PlayerStatus) {
setMeta(m, newStatus); setMeta(m, newStatus);
}, },

View file

@ -8,6 +8,7 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { useAsync } from "react-use"; import { useAsync } from "react-use";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { usePlayer } from "@/components/player/hooks/usePlayer"; import { usePlayer } from "@/components/player/hooks/usePlayer";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { convertProviderCaption } from "@/components/player/utils/captions"; import { convertProviderCaption } from "@/components/player/utils/captions";
@ -18,10 +19,12 @@ import { useQueryParam } from "@/hooks/useQueryParams";
import { MetaPart } from "@/pages/parts/player/MetaPart"; import { MetaPart } from "@/pages/parts/player/MetaPart";
import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart";
import { ResumePart } from "@/pages/parts/player/ResumePart";
import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart"; import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
import { useLastNonPlayerLink } from "@/stores/history"; import { useLastNonPlayerLink } from "@/stores/history";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { useProgressStore } from "@/stores/progress";
import { needsOnboarding } from "@/utils/onboarding"; import { needsOnboarding } from "@/utils/onboarding";
import { parseTimestamp } from "@/utils/timestamp"; import { parseTimestamp } from "@/utils/timestamp";
@ -44,11 +47,13 @@ export function RealPlayerView() {
setScrapeNotFound, setScrapeNotFound,
shouldStartFromBeginning, shouldStartFromBeginning,
setShouldStartFromBeginning, setShouldStartFromBeginning,
setStatus,
} = usePlayer(); } = usePlayer();
const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); const { setPlayerMeta, scrapeMedia } = usePlayerMeta();
const backUrl = useLastNonPlayerLink(); const backUrl = useLastNonPlayerLink();
const router = useOverlayRouter("settings"); const router = useOverlayRouter("settings");
const openedWatchPartyRef = useRef<boolean>(false); const openedWatchPartyRef = useRef<boolean>(false);
const progressItems = useProgressStore((s) => s.items);
const paramsData = JSON.stringify({ const paramsData = JSON.stringify({
media: params.media, media: params.media,
@ -87,6 +92,53 @@ export function RealPlayerView() {
[navigate, params], [navigate, params],
); );
// Check if episode is more than 80% watched
const shouldShowResumeScreen = useCallback(
(meta: PlayerMeta) => {
if (!meta?.tmdbId) return false;
const item = progressItems[meta.tmdbId];
if (!item) return false;
if (meta.type === "movie") {
if (!item.progress) return false;
const percentage =
(item.progress.watched / item.progress.duration) * 100;
return percentage > 80;
}
if (meta.type === "show" && meta.episode?.tmdbId) {
const episode = item.episodes?.[meta.episode.tmdbId];
if (!episode) return false;
const percentage =
(episode.progress.watched / episode.progress.duration) * 100;
return percentage > 80;
}
return false;
},
[progressItems],
);
const handleMetaReceived = useCallback(
(detailedMeta: DetailedMeta, episodeId?: string) => {
const playerMeta = setPlayerMeta(detailedMeta, episodeId);
if (playerMeta && shouldShowResumeScreen(playerMeta)) {
setStatus(playerStatus.RESUME);
}
},
[shouldShowResumeScreen, setStatus, setPlayerMeta],
);
const handleResume = useCallback(() => {
setStatus(playerStatus.SCRAPING);
}, [setStatus]);
const handleRestart = useCallback(() => {
setShouldStartFromBeginning(true);
setStatus(playerStatus.SCRAPING);
}, [setShouldStartFromBeginning, setStatus]);
const playAfterScrape = useCallback( const playAfterScrape = useCallback(
(out: RunOutput | null) => { (out: RunOutput | null) => {
if (!out) return; if (!out) return;
@ -113,7 +165,14 @@ export function RealPlayerView() {
return ( return (
<PlayerPart backUrl={backUrl} onMetaChange={metaChange}> <PlayerPart backUrl={backUrl} onMetaChange={metaChange}>
{status === playerStatus.IDLE ? ( {status === playerStatus.IDLE ? (
<MetaPart onGetMeta={setPlayerMeta} /> <MetaPart onGetMeta={handleMetaReceived} />
) : null}
{status === playerStatus.RESUME ? (
<ResumePart
onResume={handleResume}
onRestart={handleRestart}
onMetaChange={metaChange}
/>
) : null} ) : null}
{status === playerStatus.SCRAPING && scrapeMedia ? ( {status === playerStatus.SCRAPING && scrapeMedia ? (
<ScrapingPart <ScrapingPart

View file

@ -0,0 +1,92 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill";
import { NextEpisodeButton } from "@/components/player/atoms/NextEpisodeButton";
import { Paragraph } from "@/components/text/Paragraph";
import { Title } from "@/components/text/Title";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { PlayerMeta } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { useProgressStore } from "@/stores/progress";
export interface ResumePartProps {
onResume: () => void;
onRestart: () => void;
onMetaChange?: (meta: PlayerMeta) => void;
}
export function ResumePart(props: ResumePartProps) {
const { t } = useTranslation();
const meta = usePlayerStore((s) => s.meta);
const progressItems = useProgressStore((s) => s.items);
// Calculate watch percentage
const watchPercentage = (() => {
if (!meta?.tmdbId) return 0;
const item = progressItems[meta.tmdbId];
if (!item) return 0;
if (meta.type === "movie") {
if (!item.progress) return 0;
return (item.progress.watched / item.progress.duration) * 100;
}
if (meta.type === "show" && meta.episode?.tmdbId) {
const episode = item.episodes?.[meta.episode.tmdbId];
if (!episode) return 0;
return (episode.progress.watched / episode.progress.duration) * 100;
}
return 0;
})();
const roundedPercentage = Math.round(watchPercentage);
return (
<ErrorLayout>
<ErrorContainer>
{/* <IconPill icon={Icons.PLAY}>{t("player.resume.badge")}</IconPill> */}
<Title>{t("player.resume.title")}</Title>
<Paragraph>
{t("player.resume.description", { percentage: roundedPercentage })}
</Paragraph>
<div className="flex flex-col space-y-3 mt-6 w-full max-w-sm">
<Button
onClick={props.onResume}
theme="purple"
padding="md:px-12 p-2.5"
className="w-full"
>
<Icon icon={Icons.PLAY} className="mr-2" />
{t("player.resume.resume")}
</Button>
<Button
onClick={props.onRestart}
theme="secondary"
padding="md:px-12 p-2.5"
className="w-full"
>
{/* <Icon icon={Icons.ARROW_LEFT} className="mr-2" /> */}
{t("player.resume.restart")}
</Button>
{meta?.type === "show" && (
<div className="flex justify-center">
<NextEpisodeButton
controlsShowing={false}
onChange={props.onMetaChange}
inControl
showAsButton
/>
</div>
)}
</div>
</ErrorContainer>
</ErrorLayout>
);
}

View file

@ -12,6 +12,7 @@ import { ValuesOf } from "@/utils/typeguard";
export const playerStatus = { export const playerStatus = {
IDLE: "idle", IDLE: "idle",
RESUME: "resume",
SCRAPING: "scraping", SCRAPING: "scraping",
PLAYING: "playing", PLAYING: "playing",
SCRAPE_NOT_FOUND: "scrapeNotFound", SCRAPE_NOT_FOUND: "scrapeNotFound",