mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
series support for continue watching
This commit is contained in:
parent
a077417761
commit
177860aed4
6 changed files with 104 additions and 48 deletions
|
@ -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>;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return [...filtered, ...Object.values(highestWatchedItem)];
|
||||
// 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;
|
||||
},
|
||||
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 };
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue