continue watching and progress bars

This commit is contained in:
Jelle van Snik 2023-01-17 21:12:39 +01:00
parent 6353bf3799
commit fb96026195
8 changed files with 100 additions and 33 deletions

View File

@ -6,9 +6,21 @@ import { mediaTypeToJW } from "@/backend/metadata/justwatch";
export interface MediaCardProps { export interface MediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
linkable?: boolean; linkable?: boolean;
series?: {
episode: number;
season: number;
};
percentage?: number;
} }
function MediaCardContent({ media, linkable }: MediaCardProps) { function MediaCardContent({
media,
linkable,
series,
percentage,
}: MediaCardProps) {
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
return ( return (
<div <div
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${ className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
@ -21,11 +33,36 @@ function MediaCardContent({ media, linkable }: MediaCardProps) {
}`} }`}
> >
<div <div
className="mb-4 aspect-[2/3] w-full rounded-xl bg-denim-500 bg-cover" className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover"
style={{ style={{
backgroundImage: media.poster ? `url(${media.poster})` : undefined, backgroundImage: media.poster ? `url(${media.poster})` : undefined,
}} }}
>
{series ? (
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500">
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
S{series.season} E{series.episode}
</p>
</div>
) : null}
{percentage !== undefined ? (
<>
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors group-hover:from-denim-100" />
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors group-hover:from-denim-100" />
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
<div
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
style={{
width: percentageString,
}}
/> />
</div>
</div>
</>
) : null}
</div>
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3"> <h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
<span>{media.title}</span> <span>{media.title}</span>
</h1> </h1>

View File

@ -1,4 +1,6 @@
import { MWMediaMeta } from "@/backend/metadata/types"; import { MWMediaMeta } from "@/backend/metadata/types";
import { useWatchedContext } from "@/state/watched";
import { useMemo } from "react";
import { MediaCard } from "./MediaCard"; import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps { export interface WatchedMediaCardProps {
@ -6,5 +8,17 @@ export interface WatchedMediaCardProps {
} }
export function WatchedMediaCard(props: WatchedMediaCardProps) { export function WatchedMediaCard(props: WatchedMediaCardProps) {
return <MediaCard media={props.media} linkable />; const { watched } = useWatchedContext();
const watchedMedia = useMemo(() => {
return watched.items.find((v) => v.item.meta.id === props.media.id);
}, [watched, props.media]);
return (
<MediaCard
media={props.media}
series={watchedMedia?.item?.series}
linkable
percentage={watchedMedia?.percentage}
/>
);
} }

View File

@ -12,6 +12,7 @@ export function BackdropControl(props: BackdropControlProps) {
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null); const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const clickareaRef = useRef<HTMLDivElement>(null); const clickareaRef = useRef<HTMLDivElement>(null);
// TODO fix infinite loop
const handleMouseMove = useCallback(() => { const handleMouseMove = useCallback(() => {
setMoved(true); setMoved(true);
if (timeout.current) clearTimeout(timeout.current); if (timeout.current) clearTimeout(timeout.current);

View File

@ -21,6 +21,8 @@ export function ProgressControl() {
ref, ref,
commitTime commitTime
); );
// TODO make dragging update timer
useEffect(() => { useEffect(() => {
if (dragRef.current === dragging) return; if (dragRef.current === dragging) return;
dragRef.current = dragging; dragRef.current = dragging;

View File

@ -7,25 +7,7 @@ interface Props {
onProgress?: (time: number, duration: number) => void; onProgress?: (time: number, duration: number) => void;
} }
const FIVETEEN_MINUTES = 15 * 60; // TODO fix infinite loops
const FIVE_MINUTES = 5 * 60;
function shouldRestoreTime(time: number, duration: number): boolean {
const timeFromEnd = Math.max(0, duration - time);
// short movie
if (duration < FIVETEEN_MINUTES) {
if (time < 5) return false;
if (timeFromEnd < 60) return false;
return true;
}
// long movie
if (time < 30) return false;
if (timeFromEnd < FIVE_MINUTES) return false;
return true;
}
export function ProgressListenerControl(props: Props) { export function ProgressListenerControl(props: Props) {
const { videoState } = useVideoPlayerState(); const { videoState } = useVideoPlayerState();
const didInitialize = useRef<true | null>(null); const didInitialize = useRef<true | null>(null);
@ -50,14 +32,11 @@ export function ProgressListenerControl(props: Props) {
useEffect(() => { useEffect(() => {
if (didInitialize.current) return; if (didInitialize.current) return;
if (!videoState.hasInitialized || Number.isNaN(videoState.duration)) return; if (!videoState.hasInitialized || Number.isNaN(videoState.duration)) return;
if ( if (props.startAt !== undefined) {
props.startAt !== undefined &&
shouldRestoreTime(props.startAt, videoState.duration)
) {
videoState.setTime(props.startAt); videoState.setTime(props.startAt);
} }
didInitialize.current = true; didInitialize.current = true;
}, [didInitialize, videoState, props]); }, [didInitialize, props, videoState]);
return null; return null;
} }

View File

@ -1,5 +1,5 @@
import { MWStreamType } from "@/backend/helpers/streams"; import { MWStreamType } from "@/backend/helpers/streams";
import { useContext, useEffect } from "react"; import { useContext, useEffect, useRef } from "react";
import { VideoPlayerDispatchContext } from "../VideoContext"; import { VideoPlayerDispatchContext } from "../VideoContext";
interface SourceControlProps { interface SourceControlProps {
@ -9,13 +9,16 @@ interface SourceControlProps {
export function SourceControl(props: SourceControlProps) { export function SourceControl(props: SourceControlProps) {
const dispatch = useContext(VideoPlayerDispatchContext); const dispatch = useContext(VideoPlayerDispatchContext);
const didInitialize = useRef(false);
useEffect(() => { useEffect(() => {
if (didInitialize.current) return;
dispatch({ dispatch({
type: "SET_SOURCE", type: "SET_SOURCE",
url: props.source, url: props.source,
sourceType: props.type, sourceType: props.type,
}); });
didInitialize.current = true;
}, [props, dispatch]); }, [props, dispatch]);
return null; return null;

View File

@ -21,6 +21,7 @@ if (key) {
// - mobile UI // - mobile UI
// - season/episode select // - 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)
@ -47,7 +48,6 @@ if (key) {
// - localize everything // - localize everything
// - add titles to pages // - add titles to pages
// - find place for bookmark button // - find place for bookmark button
// - find place for progress bar for "continue watching" section
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>

View File

@ -10,6 +10,25 @@ import {
} from "react"; } from "react";
import { VideoProgressStore } from "./store"; import { VideoProgressStore } from "./store";
const FIVETEEN_MINUTES = 15 * 60;
const FIVE_MINUTES = 5 * 60;
function shouldSave(time: number, duration: number): boolean {
const timeFromEnd = Math.max(0, duration - time);
// short movie
if (duration < FIVETEEN_MINUTES) {
if (time < 5) return false;
if (timeFromEnd < 60) return false;
return true;
}
// long movie
if (time < 30) return false;
if (timeFromEnd < FIVE_MINUTES) return false;
return true;
}
interface MediaItem { interface MediaItem {
meta: MWMediaMeta; meta: MWMediaMeta;
series?: { series?: {
@ -66,8 +85,12 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
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) => {
let item = data.items.find((v) => v.item.meta.id === media.meta.id); const newData = { ...data };
let item = newData.items.find(
(v) => v.item.meta.id === media.meta.id
);
if (!item) { if (!item) {
item = { item = {
item: { item: {
@ -78,12 +101,20 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
progress: 0, progress: 0,
percentage: 0, percentage: 0,
}; };
data.items.push(item); newData.items.push(item);
} }
// update actual item // update actual item
item.progress = progress; item.progress = progress;
item.percentage = Math.round((progress / total) * 100); item.percentage = Math.round((progress / total) * 100);
return data;
// remove item if shouldnt save
if (!shouldSave(progress, total)) {
newData.items = data.items.filter(
(v) => v.item.meta.id !== media.meta.id
);
}
return newData;
}); });
}, },
getFilteredWatched() { getFilteredWatched() {