mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-25 20:11:52 +01:00
series support for continue watching
This commit is contained in:
parent
a077417761
commit
177860aed4
@ -11,6 +11,8 @@ export interface MediaCardProps {
|
|||||||
series?: {
|
series?: {
|
||||||
episode: number;
|
episode: number;
|
||||||
season: number;
|
season: number;
|
||||||
|
episodeId: string;
|
||||||
|
seasonId: string;
|
||||||
};
|
};
|
||||||
percentage?: number;
|
percentage?: number;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
@ -106,9 +108,13 @@ export function MediaCard(props: MediaCardProps) {
|
|||||||
|
|
||||||
const canLink = props.linkable && !props.closable;
|
const canLink = props.linkable && !props.closable;
|
||||||
|
|
||||||
const link = canLink
|
let link = canLink
|
||||||
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
|
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
|
||||||
: "#";
|
: "#";
|
||||||
|
if (canLink && props.series)
|
||||||
|
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent(
|
||||||
|
props.series.episodeId
|
||||||
|
)}`;
|
||||||
|
|
||||||
if (!props.linkable) return <span>{content}</span>;
|
if (!props.linkable) return <span>{content}</span>;
|
||||||
return <Link to={link}>{content}</Link>;
|
return <Link to={link}>{content}</Link>;
|
||||||
|
@ -9,16 +9,32 @@ export interface WatchedMediaCardProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSeries(
|
||||||
|
obj:
|
||||||
|
| { episodeId: string; seasonId: string; episode: number; season: number }
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
if (!obj) return undefined;
|
||||||
|
return {
|
||||||
|
season: obj.season,
|
||||||
|
episode: obj.episode,
|
||||||
|
episodeId: obj.episodeId,
|
||||||
|
seasonId: obj.seasonId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||||
const { watched } = useWatchedContext();
|
const { watched } = useWatchedContext();
|
||||||
const watchedMedia = useMemo(() => {
|
const watchedMedia = useMemo(() => {
|
||||||
return watched.items.find((v) => v.item.meta.id === props.media.id);
|
return watched.items
|
||||||
|
.sort((a, b) => b.watchedAt - a.watchedAt)
|
||||||
|
.find((v) => v.item.meta.id === props.media.id);
|
||||||
}, [watched, props.media]);
|
}, [watched, props.media]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaCard
|
<MediaCard
|
||||||
media={props.media}
|
media={props.media}
|
||||||
series={watchedMedia?.item?.series}
|
series={formatSeries(watchedMedia?.item?.series)}
|
||||||
linkable
|
linkable
|
||||||
percentage={watchedMedia?.percentage}
|
percentage={watchedMedia?.percentage}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
@ -19,9 +19,7 @@ if (key) {
|
|||||||
// TODO video todos:
|
// TODO video todos:
|
||||||
// - captions
|
// - captions
|
||||||
// - mobile UI
|
// - mobile UI
|
||||||
// - season/episode select
|
|
||||||
// - chrome cast support
|
// - chrome cast support
|
||||||
// - airplay support
|
|
||||||
// - source selection
|
// - source selection
|
||||||
// - safari fullscreen will make video overlap player controls
|
// - safari fullscreen will make video overlap player controls
|
||||||
// - safari progress bar is fucked (video doesnt change time but video.currentTime does change)
|
// - safari progress bar is fucked (video doesnt change time but video.currentTime does change)
|
||||||
@ -43,7 +41,6 @@ if (key) {
|
|||||||
|
|
||||||
// TODO general todos:
|
// TODO general todos:
|
||||||
// - localize everything (fix loading screen text (series vs movies))
|
// - localize everything (fix loading screen text (series vs movies))
|
||||||
// - make mobile friendly
|
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
@ -9,6 +9,10 @@ body {
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-full], html[data-full] body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
padding: 0.05px;
|
padding: 0.05px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
@ -33,6 +33,8 @@ function shouldSave(time: number, duration: number): boolean {
|
|||||||
interface MediaItem {
|
interface MediaItem {
|
||||||
meta: MWMediaMeta;
|
meta: MWMediaMeta;
|
||||||
series?: {
|
series?: {
|
||||||
|
episodeId: string;
|
||||||
|
seasonId: string;
|
||||||
episode: number;
|
episode: number;
|
||||||
season: number;
|
season: number;
|
||||||
};
|
};
|
||||||
@ -42,6 +44,7 @@ interface WatchedStoreItem {
|
|||||||
item: MediaItem;
|
item: MediaItem;
|
||||||
progress: number;
|
progress: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
watchedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WatchedStoreData {
|
export interface WatchedStoreData {
|
||||||
@ -65,6 +68,15 @@ const WatchedContext = createContext<WatchedStoreDataWrapper>({
|
|||||||
});
|
});
|
||||||
WatchedContext.displayName = "WatchedContext";
|
WatchedContext.displayName = "WatchedContext";
|
||||||
|
|
||||||
|
function isSameEpisode(media: MediaItem, v: MediaItem) {
|
||||||
|
return (
|
||||||
|
media.meta.id === v.meta.id &&
|
||||||
|
(!media.series ||
|
||||||
|
(media.series.seasonId === v.series?.seasonId &&
|
||||||
|
media.series.episodeId === v.series?.episodeId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WatchedContextProvider(props: { children: ReactNode }) {
|
export function WatchedContextProvider(props: { children: ReactNode }) {
|
||||||
const watchedLocalstorage = VideoProgressStore.get();
|
const watchedLocalstorage = VideoProgressStore.get();
|
||||||
const [watched, setWatchedReal] = useState<WatchedStoreData>(
|
const [watched, setWatchedReal] = useState<WatchedStoreData>(
|
||||||
@ -95,12 +107,9 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
updateProgress(media: MediaItem, progress: number, total: number): void {
|
updateProgress(media: MediaItem, progress: number, total: number): void {
|
||||||
// TODO series support
|
|
||||||
setWatched((data: WatchedStoreData) => {
|
setWatched((data: WatchedStoreData) => {
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let item = newData.items.find(
|
let item = newData.items.find((v) => isSameEpisode(media, v.item));
|
||||||
(v) => v.item.meta.id === media.meta.id
|
|
||||||
);
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
item = {
|
item = {
|
||||||
item: {
|
item: {
|
||||||
@ -110,6 +119,7 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
progress: 0,
|
progress: 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
|
watchedAt: Date.now(),
|
||||||
};
|
};
|
||||||
newData.items.push(item);
|
newData.items.push(item);
|
||||||
}
|
}
|
||||||
@ -120,7 +130,7 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
|||||||
// remove item if shouldnt save
|
// remove item if shouldnt save
|
||||||
if (!shouldSave(progress, total)) {
|
if (!shouldSave(progress, total)) {
|
||||||
newData.items = data.items.filter(
|
newData.items = data.items.filter(
|
||||||
(v) => v.item.meta.id !== media.meta.id
|
(v) => !isSameEpisode(v.item, media)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,34 +140,19 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
|||||||
getFilteredWatched() {
|
getFilteredWatched() {
|
||||||
let filtered = watched.items;
|
let filtered = watched.items;
|
||||||
|
|
||||||
// get highest episode number for every anime/season
|
// get most recently watched for every single item
|
||||||
const highestEpisode: Record<string, [number, number]> = {};
|
const alreadyFoundMedia: string[] = [];
|
||||||
const highestWatchedItem: Record<string, WatchedStoreItem> = {};
|
filtered = filtered
|
||||||
filtered = filtered.filter((item) => {
|
.sort((a, b) => {
|
||||||
if (item.item.series) {
|
return b.watchedAt - a.watchedAt;
|
||||||
const key = item.item.meta.id;
|
})
|
||||||
const current: [number, number] = [
|
.filter((item) => {
|
||||||
item.item.series.episode,
|
const mediaId = item.item.meta.id;
|
||||||
item.item.series.season,
|
if (alreadyFoundMedia.includes(mediaId)) return false;
|
||||||
];
|
alreadyFoundMedia.push(mediaId);
|
||||||
let existing = highestEpisode[key];
|
|
||||||
if (!existing) {
|
|
||||||
existing = current;
|
|
||||||
highestEpisode[key] = current;
|
|
||||||
highestWatchedItem[key] = item;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
current[0] > existing[0] ||
|
|
||||||
(current[0] === existing[0] && current[1] > existing[1])
|
|
||||||
) {
|
|
||||||
highestEpisode[key] = current;
|
|
||||||
highestWatchedItem[key] = item;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return [...filtered, ...Object.values(highestWatchedItem)];
|
return filtered;
|
||||||
},
|
},
|
||||||
watched,
|
watched,
|
||||||
}),
|
}),
|
||||||
@ -175,26 +170,60 @@ export function useWatchedContext() {
|
|||||||
return useContext(WatchedContext);
|
return useContext(WatchedContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWatchedItem(meta: DetailedMeta | null) {
|
function isSameEpisodeMeta(
|
||||||
|
media: MediaItem,
|
||||||
|
mediaTwo: DetailedMeta | null,
|
||||||
|
episodeId?: string
|
||||||
|
) {
|
||||||
|
if (mediaTwo?.meta.type === MWMediaType.SERIES && episodeId) {
|
||||||
|
return isSameEpisode(media, {
|
||||||
|
meta: mediaTwo.meta,
|
||||||
|
series: {
|
||||||
|
season: 0,
|
||||||
|
episode: 0,
|
||||||
|
episodeId,
|
||||||
|
seasonId: mediaTwo.meta.seasonData.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!mediaTwo) return () => false;
|
||||||
|
return isSameEpisode(media, { meta: mediaTwo.meta });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWatchedItem(meta: DetailedMeta | null, episodeId?: string) {
|
||||||
const { watched, updateProgress } = useContext(WatchedContext);
|
const { watched, updateProgress } = useContext(WatchedContext);
|
||||||
const item = useMemo(
|
const item = useMemo(
|
||||||
() => watched.items.find((v) => meta && v.item.meta.id === meta?.meta.id),
|
() => watched.items.find((v) => isSameEpisodeMeta(v.item, meta, episodeId)),
|
||||||
[watched, meta]
|
[watched, meta, episodeId]
|
||||||
);
|
);
|
||||||
const lastCommitedTime = useRef([0, 0]);
|
const lastCommitedTime = useRef([0, 0]);
|
||||||
|
|
||||||
const callback = useCallback(
|
const callback = useCallback(
|
||||||
(progress: number, total: number) => {
|
(progress: number, total: number) => {
|
||||||
// TODO add series support
|
|
||||||
const hasChanged =
|
const hasChanged =
|
||||||
lastCommitedTime.current[0] !== progress ||
|
lastCommitedTime.current[0] !== progress ||
|
||||||
lastCommitedTime.current[1] !== total;
|
lastCommitedTime.current[1] !== total;
|
||||||
if (meta && hasChanged) {
|
if (meta && hasChanged) {
|
||||||
lastCommitedTime.current = [progress, total];
|
lastCommitedTime.current = [progress, total];
|
||||||
updateProgress({ meta: meta.meta }, progress, total);
|
const obj = {
|
||||||
|
meta: meta.meta,
|
||||||
|
series:
|
||||||
|
meta.meta.type === MWMediaType.SERIES && episodeId
|
||||||
|
? {
|
||||||
|
seasonId: meta.meta.seasonData.id,
|
||||||
|
episodeId,
|
||||||
|
season: meta.meta.seasonData.number,
|
||||||
|
episode:
|
||||||
|
meta.meta.seasonData.episodes.find(
|
||||||
|
(ep) => ep.id === episodeId
|
||||||
|
)?.number || 0,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
updateProgress(obj, progress, total);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[meta, updateProgress]
|
[meta, updateProgress, episodeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { updateProgress: callback, watchedItem: item };
|
return { updateProgress: callback, watchedItem: item };
|
||||||
|
@ -97,7 +97,10 @@ interface MediaViewPlayerProps {
|
|||||||
}
|
}
|
||||||
export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const { updateProgress, watchedItem } = useWatchedItem(props.meta);
|
const { updateProgress, watchedItem } = useWatchedItem(
|
||||||
|
props.meta,
|
||||||
|
props.selected.episode
|
||||||
|
);
|
||||||
const firstStartTime = useRef(watchedItem?.progress);
|
const firstStartTime = useRef(watchedItem?.progress);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
firstStartTime.current = watchedItem?.progress;
|
firstStartTime.current = watchedItem?.progress;
|
||||||
@ -106,9 +109,10 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
|||||||
}, [props.stream]);
|
}, [props.stream]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[100dvh] w-screen">
|
<div className="fixed top-0 left-0 h-[100dvh] w-screen">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{props.meta.meta.title}</title>
|
<title>{props.meta.meta.title}</title>
|
||||||
|
<html data-full="true" />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<DecoratedVideoPlayer media={props.meta.meta} onGoBack={goBack} autoPlay>
|
<DecoratedVideoPlayer media={props.meta.meta} onGoBack={goBack} autoPlay>
|
||||||
<SourceControl
|
<SourceControl
|
||||||
|
Loading…
Reference in New Issue
Block a user