series support for continue watching

This commit is contained in:
Jelle van Snik 2023-01-23 23:51:40 +01:00
parent a077417761
commit 177860aed4
6 changed files with 104 additions and 48 deletions

View File

@ -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>;

View File

@ -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}

View File

@ -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>

View File

@ -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;

View File

@ -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 };

View File

@ -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