diff --git a/src/components/buttons/Toggle.tsx b/src/components/buttons/Toggle.tsx new file mode 100644 index 00000000..7f477056 --- /dev/null +++ b/src/components/buttons/Toggle.tsx @@ -0,0 +1,23 @@ +import classNames from "classnames"; + +export function Toggle(props: { onClick: () => void; enabled?: boolean }) { + return ( + + ); +} diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 713d3fdb..44126ebe 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -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 ( + <> + router.navigate("/captions")}> + Custom captions + + + Hello! + + + ); +} + 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 ( @@ -121,11 +143,11 @@ function SettingsOverlay({ id }: { id: string }) { Viewing Experience - router.navigate("/quality")}> - Enable Captions - - + Enable Captions + toggleBool()} /> + + router.navigate("/captions")}> Caption settings English @@ -141,6 +163,24 @@ function SettingsOverlay({ id }: { id: string }) { + + + router.navigate("/")}> + Captions + + + + + + + + + router.navigate("/")}> diff --git a/src/components/player/base/Container.tsx b/src/components/player/base/Container.tsx index 8e2aa67f..927be3f0 100644 --- a/src/components/player/base/Container.tsx +++ b/src/components/player/base/Container.tsx @@ -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) {
+
diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 859caf35..a2f23e2b 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -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(); }, diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index 5890d937..6ebdddb3 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -17,7 +17,7 @@ export type DisplayInterfaceEvents = { export interface DisplayInterface extends Listener { 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; diff --git a/src/components/player/hooks/usePlayer.ts b/src/components/player/hooks/usePlayer.ts index 13428a9b..6f7abad9 100644 --- a/src/components/player/hooks/usePlayer.ts +++ b/src/components/player/hooks/usePlayer.ts @@ -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, + 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(); }, diff --git a/src/components/player/internals/ContextUtils.tsx b/src/components/player/internals/ContextUtils.tsx index d7a80653..71a7683b 100644 --- a/src/components/player/internals/ContextUtils.tsx +++ b/src/components/player/internals/ContextUtils.tsx @@ -119,7 +119,7 @@ function LinkChevron(props: { children?: React.ReactNode }) { return ( {props.children} - + ); } diff --git a/src/components/player/internals/ProgressSaver.tsx b/src/components/player/internals/ProgressSaver.tsx new file mode 100644 index 00000000..a8d85895 --- /dev/null +++ b/src/components/player/internals/ProgressSaver.tsx @@ -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; +} diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 5ea537c3..9d8196a0 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -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 ( {props.children} - - - - + {status === "playing" ? ( + + + + + ) : null} = (set, get) => ({ }); }, reset() { - get().display?.load(null); + get().display?.load(null, 0); set((s) => { s.status = playerStatus.IDLE; s.meta = null; diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 23857e02..8c6143a4 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -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 = (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 = (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 = (set, get) => ({ set((s) => { s.currentQuality = quality; }); - store.display?.load(selectedQuality); + store.display?.load(selectedQuality, store.progress.time); } }, }); diff --git a/src/stores/progress/index.ts b/src/stores/progress/index.ts new file mode 100644 index 00000000..b84be1ca --- /dev/null +++ b/src/stores/progress/index.ts @@ -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; + episodes: Record; +} + +export interface UpdateItemOptions { + meta: PlayerMeta; + progress: ProgressItem; +} + +export interface ProgressStore { + items: Record; + updateItem(ops: UpdateItemOptions): void; +} + +// TODO add migration from previous progress store +export const useProgressStore = create( + persist( + immer((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", + } + ) +); diff --git a/tailwind.config.js b/tailwind.config.js index b3f15e19..68c60438 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -66,6 +66,12 @@ module.exports = { light: "#2A2A71" }, + // Buttons + buttons: { + toggle: "#8D44D6", + toggleDisabled: "#202836" + }, + // only used for body colors/textures background: { main: "#0A0A10",