mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-26 16:31:50 +01:00
progress saving, progress restoring, toggle in caption settings
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
f8ec45bf13
commit
accc13ab0e
23
src/components/buttons/Toggle.tsx
Normal file
23
src/components/buttons/Toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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("/")}>
|
||||
|
@ -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 />
|
||||
|
@ -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();
|
||||
},
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
39
src/components/player/internals/ProgressSaver.tsx
Normal file
39
src/components/player/internals/ProgressSaver.tsx
Normal 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;
|
||||
}
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
100
src/stores/progress/index.ts
Normal file
100
src/stores/progress/index.ts
Normal 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",
|
||||
}
|
||||
)
|
||||
);
|
@ -66,6 +66,12 @@ module.exports = {
|
||||
light: "#2A2A71"
|
||||
},
|
||||
|
||||
// Buttons
|
||||
buttons: {
|
||||
toggle: "#8D44D6",
|
||||
toggleDisabled: "#202836"
|
||||
},
|
||||
|
||||
// only used for body colors/textures
|
||||
background: {
|
||||
main: "#0A0A10",
|
||||
|
Loading…
Reference in New Issue
Block a user