mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-20 19:12:10 +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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 = {
|
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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue