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",
"nextSeason": "Next season"
},
"resume": {
"badge": "Resume",
"title": "Continue watching?",
"description": "This episode is {{percentage}}% watched",
"resume": "Resume",
"restart": "Restart"
},
"playbackError": {
"badge": "Playback error",
"errors": {

View file

@ -5,6 +5,7 @@ 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";
@ -27,7 +28,7 @@ function shouldShowNextEpisodeButton(
return "none";
}
function Button(props: {
function ActionButton(props: {
className: string;
onClick?: () => void;
children: React.ReactNode;
@ -94,6 +95,7 @@ export function NextEpisodeButton(props: {
controlsShowing: boolean;
onChange?: (meta: PlayerMeta) => void;
inControl: boolean;
showAsButton?: boolean;
}) {
const { t } = useTranslation();
const duration = usePlayerStore((s) => s.progress.duration);
@ -213,6 +215,22 @@ export function NextEpisodeButton(props: {
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}
@ -225,13 +243,13 @@ export function NextEpisodeButton(props: {
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"
onClick={() => startCurrentEpisodeFromBeginning()}
>
{t("player.nextEpisode.replay")}
</Button>
<Button
</ActionButton>
<ActionButton
onClick={() => loadNextEpisode()}
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
? t("player.nextEpisode.nextSeason")
: t("player.nextEpisode.next")}
</Button>
</ActionButton>
</div>
</Transition>
);

View file

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

View file

@ -8,6 +8,7 @@ import {
} from "react-router-dom";
import { useAsync } from "react-use";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { convertProviderCaption } from "@/components/player/utils/captions";
@ -18,10 +19,12 @@ import { useQueryParam } from "@/hooks/useQueryParams";
import { MetaPart } from "@/pages/parts/player/MetaPart";
import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
import { PlayerPart } from "@/pages/parts/player/PlayerPart";
import { ResumePart } from "@/pages/parts/player/ResumePart";
import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
import { useLastNonPlayerLink } from "@/stores/history";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { useProgressStore } from "@/stores/progress";
import { needsOnboarding } from "@/utils/onboarding";
import { parseTimestamp } from "@/utils/timestamp";
@ -44,11 +47,13 @@ export function RealPlayerView() {
setScrapeNotFound,
shouldStartFromBeginning,
setShouldStartFromBeginning,
setStatus,
} = usePlayer();
const { setPlayerMeta, scrapeMedia } = usePlayerMeta();
const backUrl = useLastNonPlayerLink();
const router = useOverlayRouter("settings");
const openedWatchPartyRef = useRef<boolean>(false);
const progressItems = useProgressStore((s) => s.items);
const paramsData = JSON.stringify({
media: params.media,
@ -87,6 +92,53 @@ export function RealPlayerView() {
[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(
(out: RunOutput | null) => {
if (!out) return;
@ -113,7 +165,14 @@ export function RealPlayerView() {
return (
<PlayerPart backUrl={backUrl} onMetaChange={metaChange}>
{status === playerStatus.IDLE ? (
<MetaPart onGetMeta={setPlayerMeta} />
<MetaPart onGetMeta={handleMetaReceived} />
) : null}
{status === playerStatus.RESUME ? (
<ResumePart
onResume={handleResume}
onRestart={handleRestart}
onMetaChange={metaChange}
/>
) : null}
{status === playerStatus.SCRAPING && scrapeMedia ? (
<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 = {
IDLE: "idle",
RESUME: "resume",
SCRAPING: "scraping",
PLAYING: "playing",
SCRAPE_NOT_FOUND: "scrapeNotFound",