mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
add resume action page
asks what you want to do if your progress is greater than 80%
This commit is contained in:
parent
1a95c052a9
commit
5bb28c02c7
6 changed files with 184 additions and 6 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export function usePlayer() {
|
|||
status,
|
||||
shouldStartFromBeginning,
|
||||
setShouldStartFromBeginning,
|
||||
setStatus,
|
||||
setMeta(m: PlayerMeta, newStatus?: PlayerStatus) {
|
||||
setMeta(m, newStatus);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
src/pages/parts/player/ResumePart.tsx
Normal file
92
src/pages/parts/player/ResumePart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { ValuesOf } from "@/utils/typeguard";
|
|||
|
||||
export const playerStatus = {
|
||||
IDLE: "idle",
|
||||
RESUME: "resume",
|
||||
SCRAPING: "scraping",
|
||||
PLAYING: "playing",
|
||||
SCRAPE_NOT_FOUND: "scrapeNotFound",
|
||||
|
|
|
|||
Loading…
Reference in a new issue