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?: {
episode: number;
season: number;
episodeId: string;
seasonId: string;
};
percentage?: number;
closable?: boolean;
@ -106,9 +108,13 @@ export function MediaCard(props: MediaCardProps) {
const canLink = props.linkable && !props.closable;
const link = canLink
let link = canLink
? `/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>;
return <Link to={link}>{content}</Link>;

View File

@ -9,16 +9,32 @@ export interface WatchedMediaCardProps {
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) {
const { watched } = useWatchedContext();
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]);
return (
<MediaCard
media={props.media}
series={watchedMedia?.item?.series}
series={formatSeries(watchedMedia?.item?.series)}
linkable
percentage={watchedMedia?.percentage}
onClose={props.onClose}

View File

@ -19,9 +19,7 @@ if (key) {
// TODO video todos:
// - captions
// - mobile UI
// - season/episode select
// - chrome cast support
// - airplay support
// - source selection
// - safari fullscreen will make video overlap player controls
// - safari progress bar is fucked (video doesnt change time but video.currentTime does change)
@ -43,7 +41,6 @@ if (key) {
// TODO general todos:
// - localize everything (fix loading screen text (series vs movies))
// - make mobile friendly
ReactDOM.render(
<React.StrictMode>

View File

@ -9,6 +9,10 @@ body {
min-height: 100dvh;
}
html[data-full], html[data-full] body {
overscroll-behavior-y: none;
}
#root {
padding: 0.05px;
min-height: 100vh;

View File

@ -1,5 +1,5 @@
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaMeta } from "@/backend/metadata/types";
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
import {
createContext,
ReactNode,
@ -33,6 +33,8 @@ function shouldSave(time: number, duration: number): boolean {
interface MediaItem {
meta: MWMediaMeta;
series?: {
episodeId: string;
seasonId: string;
episode: number;
season: number;
};
@ -42,6 +44,7 @@ interface WatchedStoreItem {
item: MediaItem;
progress: number;
percentage: number;
watchedAt: number;
}
export interface WatchedStoreData {
@ -65,6 +68,15 @@ const WatchedContext = createContext<WatchedStoreDataWrapper>({
});
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 }) {
const watchedLocalstorage = VideoProgressStore.get();
const [watched, setWatchedReal] = useState<WatchedStoreData>(
@ -95,12 +107,9 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
});
},
updateProgress(media: MediaItem, progress: number, total: number): void {
// TODO series support
setWatched((data: WatchedStoreData) => {
const newData = { ...data };
let item = newData.items.find(
(v) => v.item.meta.id === media.meta.id
);
let item = newData.items.find((v) => isSameEpisode(media, v.item));
if (!item) {
item = {
item: {
@ -110,6 +119,7 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
},
progress: 0,
percentage: 0,
watchedAt: Date.now(),
};
newData.items.push(item);
}
@ -120,7 +130,7 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
// remove item if shouldnt save
if (!shouldSave(progress, total)) {
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() {
let filtered = watched.items;
// get highest episode number for every anime/season
const highestEpisode: Record<string, [number, number]> = {};
const highestWatchedItem: Record<string, WatchedStoreItem> = {};
filtered = filtered.filter((item) => {
if (item.item.series) {
const key = item.item.meta.id;
const current: [number, number] = [
item.item.series.episode,
item.item.series.season,
];
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;
}
// get most recently watched for every single item
const alreadyFoundMedia: string[] = [];
filtered = filtered
.sort((a, b) => {
return b.watchedAt - a.watchedAt;
})
.filter((item) => {
const mediaId = item.item.meta.id;
if (alreadyFoundMedia.includes(mediaId)) return false;
alreadyFoundMedia.push(mediaId);
return true;
});
return [...filtered, ...Object.values(highestWatchedItem)];
return filtered;
},
watched,
}),
@ -175,26 +170,60 @@ export function useWatchedContext() {
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 item = useMemo(
() => watched.items.find((v) => meta && v.item.meta.id === meta?.meta.id),
[watched, meta]
() => watched.items.find((v) => isSameEpisodeMeta(v.item, meta, episodeId)),
[watched, meta, episodeId]
);
const lastCommitedTime = useRef([0, 0]);
const callback = useCallback(
(progress: number, total: number) => {
// TODO add series support
const hasChanged =
lastCommitedTime.current[0] !== progress ||
lastCommitedTime.current[1] !== total;
if (meta && hasChanged) {
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 };

View File

@ -97,7 +97,10 @@ interface MediaViewPlayerProps {
}
export function MediaViewPlayer(props: MediaViewPlayerProps) {
const goBack = useGoBack();
const { updateProgress, watchedItem } = useWatchedItem(props.meta);
const { updateProgress, watchedItem } = useWatchedItem(
props.meta,
props.selected.episode
);
const firstStartTime = useRef(watchedItem?.progress);
useEffect(() => {
firstStartTime.current = watchedItem?.progress;
@ -106,9 +109,10 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
}, [props.stream]);
return (
<div className="h-[100dvh] w-screen">
<div className="fixed top-0 left-0 h-[100dvh] w-screen">
<Helmet>
<title>{props.meta.meta.title}</title>
<html data-full="true" />
</Helmet>
<DecoratedVideoPlayer media={props.meta.meta} onGoBack={goBack} autoPlay>
<SourceControl