progress saving, progress restoring, toggle in caption settings

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-15 20:25:14 +02:00
parent f8ec45bf13
commit accc13ab0e
13 changed files with 255 additions and 19 deletions

View File

@ -0,0 +1,23 @@
import classNames from "classnames";
export function Toggle(props: { onClick: () => void; enabled?: boolean }) {
return (
<button
type="button"
onClick={props.onClick}
className={classNames(
"w-11 h-6 p-1 rounded-full grid transition-colors duration-100 group/toggle",
props.enabled ? "bg-buttons-toggle" : "bg-buttons-toggleDisabled"
)}
>
<div className="relative w-full h-full">
<div
className={classNames(
"scale-90 group-hover/toggle:scale-100 h-full aspect-square rounded-full bg-white absolute transition-all duration-100",
props.enabled ? "left-full transform -translate-x-full" : "left-0"
)}
/>
</div>
</button>
);
}

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { Toggle } from "@/components/buttons/Toggle";
import { Icon, Icons } from "@/components/Icon";
import { OverlayAnchor } from "@/components/overlays/OverlayAnchor";
import { Overlay } from "@/components/overlays/OverlayDisplay";
@ -92,10 +93,31 @@ function QualityView({ id }: { id: string }) {
);
}
function CaptionSettingsView({ id }: { id: string }) {
const router = useOverlayRouter(id);
return (
<>
<Context.BackLink onClick={() => router.navigate("/captions")}>
Custom captions
</Context.BackLink>
<Context.Section>
<Context.SmallText>Hello!</Context.SmallText>
</Context.Section>
</>
);
}
function SettingsOverlay({ id }: { id: string }) {
const router = useOverlayRouter(id);
const currentQuality = usePlayerStore((s) => s.currentQuality);
const [tmpBool, setTmpBool] = useState(false);
function toggleBool() {
setTmpBool(!tmpBool);
}
return (
<Overlay id={id}>
<OverlayRouter id={id}>
@ -121,11 +143,11 @@ function SettingsOverlay({ id }: { id: string }) {
<Context.SectionTitle>Viewing Experience</Context.SectionTitle>
<Context.Section>
<Context.Link onClick={() => router.navigate("/quality")}>
<Context.LinkTitle>Enable Captions</Context.LinkTitle>
<Context.LinkChevron />
</Context.Link>
<Context.Link>
<Context.LinkTitle>Enable Captions</Context.LinkTitle>
<Toggle enabled={tmpBool} onClick={() => toggleBool()} />
</Context.Link>
<Context.Link onClick={() => router.navigate("/captions")}>
<Context.LinkTitle>Caption settings</Context.LinkTitle>
<Context.LinkChevron>English</Context.LinkChevron>
</Context.Link>
@ -141,6 +163,24 @@ function SettingsOverlay({ id }: { id: string }) {
<QualityView id={id} />
</Context.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions" width={343} height={431}>
<Context.Card>
<Context.BackLink onClick={() => router.navigate("/")}>
Captions
</Context.BackLink>
<button
type="button"
onClick={() => router.navigate("/captions/settings")}
>
Go to caption settings
</button>
</Context.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions/settings" width={343} height={431}>
<Context.Card>
<CaptionSettingsView id={id} />
</Context.Card>
</OverlayPage>
<OverlayPage id={id} path="/source" width={343} height={431}>
<Context.Card>
<Context.BackLink onClick={() => router.navigate("/")}>

View File

@ -2,6 +2,7 @@ import { ReactNode, RefObject, useEffect, useRef } from "react";
import { OverlayDisplay } from "@/components/overlays/OverlayDisplay";
import { HeadUpdater } from "@/components/player/internals/HeadUpdater";
import { ProgressSaver } from "@/components/player/internals/ProgressSaver";
import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget";
import { VideoContainer } from "@/components/player/internals/VideoContainer";
import { PlayerHoverState } from "@/stores/player/slices/interface";
@ -80,6 +81,7 @@ export function Container(props: PlayerProps) {
<div className="relative">
<BaseContainer>
<VideoContainer />
<ProgressSaver />
<div className="relative h-screen overflow-hidden">
<VideoClickTarget />
<HeadUpdater />

View File

@ -24,6 +24,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
let isFullscreen = false;
let isPausedBeforeSeeking = false;
let isSeeking = false;
let startAt = 0;
function setupSource(vid: HTMLVideoElement, src: LoadableSource) {
if (src.type === "hls") {
@ -43,10 +44,12 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
hls.attachMedia(vid);
hls.loadSource(src.url);
vid.currentTime = startAt;
return;
}
vid.src = src.url;
vid.currentTime = startAt;
}
function setSource() {
@ -108,10 +111,11 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
destroyVideoElement();
fscreen.removeEventListener("fullscreenchange", fullscreenChange);
},
load(newSource) {
load(newSource, startAtInput) {
if (!newSource) unloadSource();
source = newSource;
emit("loading", true);
startAt = startAtInput;
setSource();
},

View File

@ -17,7 +17,7 @@ export type DisplayInterfaceEvents = {
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
play(): void;
pause(): void;
load(source: LoadableSource | null): void;
load(source: LoadableSource | null, startAt: number): void;
processVideoElement(video: HTMLVideoElement): void;
processContainerElement(container: HTMLElement): void;
toggleFullscreen(): void;

View File

@ -3,20 +3,38 @@ import { useInitializePlayer } from "@/components/player/hooks/useInitializePlay
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { SourceSliceSource } from "@/stores/player/utils/qualities";
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
export interface Source {
url: string;
type: MWStreamType;
}
function getProgress(
items: Record<string, ProgressMediaItem>,
meta: PlayerMeta | null
): number {
const item = items[meta?.tmdbId ?? ""];
if (!item || !meta) return 0;
if (meta.type === "movie") {
if (!item.progress) return 0;
return item.progress.watched;
}
const ep = item.episodes[meta.episode?.tmdbId ?? ""];
if (!ep) return 0;
return ep.progress.watched;
}
export function usePlayer() {
const setStatus = usePlayerStore((s) => s.setStatus);
const setMeta = usePlayerStore((s) => s.setMeta);
const setSource = usePlayerStore((s) => s.setSource);
const status = usePlayerStore((s) => s.status);
const meta = usePlayerStore((s) => s.meta);
const reset = usePlayerStore((s) => s.reset);
const meta = usePlayerStore((s) => s.meta);
const { init } = useInitializePlayer();
const progressStore = useProgressStore();
return {
reset,
@ -25,7 +43,7 @@ export function usePlayer() {
setMeta(m);
},
playMedia(source: SourceSliceSource) {
setSource(source);
setSource(source, getProgress(progressStore.items, meta));
setStatus(playerStatus.PLAYING);
init();
},

View File

@ -119,7 +119,7 @@ function LinkChevron(props: { children?: React.ReactNode }) {
return (
<span className="text-white flex items-center font-medium">
{props.children}
<Icon className="text-xl ml-1" icon={Icons.CHEVRON_RIGHT} />
<Icon className="text-xl ml-1 -mr-1.5" icon={Icons.CHEVRON_RIGHT} />
</span>
);
}

View File

@ -0,0 +1,39 @@
import { useEffect, useRef } from "react";
import { useInterval } from "react-use";
import { usePlayerStore } from "@/stores/player/store";
import { useProgressStore } from "@/stores/progress";
export function ProgressSaver() {
const meta = usePlayerStore((s) => s.meta);
const progress = usePlayerStore((s) => s.progress);
const updateItem = useProgressStore((s) => s.updateItem);
const updateItemRef = useRef(updateItem);
useEffect(() => {
updateItemRef.current = updateItem;
}, [updateItem]);
const metaRef = useRef(meta);
useEffect(() => {
metaRef.current = meta;
}, [meta]);
const progressRef = useRef(progress);
useEffect(() => {
progressRef.current = progress;
}, [progress]);
useInterval(() => {
if (updateItemRef.current && metaRef.current && progressRef.current)
updateItemRef.current({
meta: metaRef.current,
progress: {
duration: progress.duration,
watched: progress.time,
},
});
}, 3000);
return null;
}

View File

@ -4,6 +4,7 @@ import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { PlayerMeta } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
export interface PlayerPartProps {
children?: ReactNode;
@ -14,16 +15,19 @@ export interface PlayerPartProps {
export function PlayerPart(props: PlayerPartProps) {
const { showTargets, showTouchTargets } = useShouldShowControls();
const status = usePlayerStore((s) => s.status);
return (
<Player.Container onLoad={props.onLoad}>
{props.children}
<Player.BlackOverlay show={showTargets} />
<Player.CenterControls>
<Player.LoadingSpinner />
<Player.AutoPlayStart />
</Player.CenterControls>
{status === "playing" ? (
<Player.CenterControls>
<Player.LoadingSpinner />
<Player.AutoPlayStart />
</Player.CenterControls>
) : null}
<Player.CenterMobileControls
className="text-white"

View File

@ -81,7 +81,7 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
});
},
reset() {
get().display?.load(null);
get().display?.load(null, 0);
set((s) => {
s.status = playerStatus.IDLE;
s.meta = null;

View File

@ -41,7 +41,7 @@ export interface SourceSlice {
currentQuality: SourceQuality | null;
meta: PlayerMeta | null;
setStatus(status: PlayerStatus): void;
setSource(stream: SourceSliceSource): void;
setSource(stream: SourceSliceSource, startAt: number): void;
switchQuality(quality: SourceQuality): void;
setMeta(meta: PlayerMeta): void;
}
@ -85,7 +85,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.meta = meta;
});
},
setSource(stream: SourceSliceSource) {
setSource(stream: SourceSliceSource, startAt: number) {
let qualities: string[] = [];
if (stream.type === "file") qualities = Object.keys(stream.qualities);
const store = get();
@ -97,7 +97,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.currentQuality = loadableStream.quality;
});
store.display?.load(loadableStream.stream);
store.display?.load(loadableStream.stream, startAt);
},
switchQuality(quality) {
const store = get();
@ -108,7 +108,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
set((s) => {
s.currentQuality = quality;
});
store.display?.load(selectedQuality);
store.display?.load(selectedQuality, store.progress.time);
}
},
});

View File

@ -0,0 +1,100 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { PlayerMeta } from "@/stores/player/slices/source";
export interface ProgressItem {
watched: number;
duration: number;
}
export interface ProgressSeasonItem {
title: string;
number: number;
id: string;
}
export interface ProgressEpisodeItem {
title: string;
number: number;
id: string;
seasonId: string;
progress: ProgressItem;
}
export interface ProgressMediaItem {
title: string;
year: number;
type: "show" | "movie";
progress?: ProgressItem;
seasons: Record<string, ProgressSeasonItem>;
episodes: Record<string, ProgressEpisodeItem>;
}
export interface UpdateItemOptions {
meta: PlayerMeta;
progress: ProgressItem;
}
export interface ProgressStore {
items: Record<string, ProgressMediaItem>;
updateItem(ops: UpdateItemOptions): void;
}
// TODO add migration from previous progress store
export const useProgressStore = create(
persist(
immer<ProgressStore>((set) => ({
items: {},
updateItem({ meta, progress }) {
set((s) => {
if (!s.items[meta.tmdbId])
s.items[meta.tmdbId] = {
type: meta.type,
episodes: {},
seasons: {},
title: meta.title,
year: meta.releaseYear,
};
const item = s.items[meta.tmdbId];
if (meta.type === "movie") {
if (!item.progress)
item.progress = {
duration: 0,
watched: 0,
};
item.progress = { ...progress };
return;
}
if (!meta.episode || !meta.season) return;
if (!item.seasons[meta.season.tmdbId])
item.seasons[meta.season.tmdbId] = {
id: meta.season.tmdbId,
number: meta.season.number,
title: meta.season.title,
};
if (!item.episodes[meta.episode.tmdbId])
item.episodes[meta.episode.tmdbId] = {
id: meta.episode.tmdbId,
number: meta.episode.number,
title: meta.episode.title,
seasonId: meta.season.tmdbId,
progress: {
duration: 0,
watched: 0,
},
};
item.episodes[meta.episode.tmdbId].progress = { ...progress };
});
},
})),
{
name: "__MW::progress",
}
)
);

View File

@ -66,6 +66,12 @@ module.exports = {
light: "#2A2A71"
},
// Buttons
buttons: {
toggle: "#8D44D6",
toggleDisabled: "#202836"
},
// only used for body colors/textures
background: {
main: "#0A0A10",