1
0
Fork 0
mirror of https://github.com/sussy-code/smov.git synced 2024-12-20 14:37:43 +01:00

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 {
media: MWMediaMeta;
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 (
<div
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
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={{
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">
<span>{media.title}</span>
</h1>

View file

@ -1,4 +1,6 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { useWatchedContext } from "@/state/watched";
import { useMemo } from "react";
import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps {
@ -6,5 +8,17 @@ export interface 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 clickareaRef = useRef<HTMLDivElement>(null);
// TODO fix infinite loop
const handleMouseMove = useCallback(() => {
setMoved(true);
if (timeout.current) clearTimeout(timeout.current);

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,25 @@ import {
} from "react";
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 {
meta: MWMediaMeta;
series?: {
@ -66,8 +85,12 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
const contextValue = useMemo(
() => ({
updateProgress(media: MediaItem, progress: number, total: number): void {
// TODO series support
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) {
item = {
item: {
@ -78,12 +101,20 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
progress: 0,
percentage: 0,
};
data.items.push(item);
newData.items.push(item);
}
// update actual item
item.progress = progress;
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() {