diff --git a/src/components/player/atoms/Episodes.tsx b/src/components/player/atoms/Episodes.tsx index 822a1463..9cca9a4d 100644 --- a/src/components/player/atoms/Episodes.tsx +++ b/src/components/player/atoms/Episodes.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { ReactNode, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAsync } from "react-use"; @@ -5,6 +6,7 @@ import { useAsync } from "react-use"; import { getMetaFromId } from "@/backend/metadata/getmeta"; import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw"; import { Icons } from "@/components/Icon"; +import { ProgressRing } from "@/components/layout/ProgressRing"; import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; import { Overlay } from "@/components/overlays/OverlayDisplay"; import { OverlayPage } from "@/components/overlays/OverlayPage"; @@ -15,6 +17,7 @@ import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { PlayerMeta } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; +import { useProgressStore } from "@/stores/progress"; function CenteredText(props: { children: React.ReactNode }) { return ( @@ -98,6 +101,7 @@ function EpisodesView({ const { setPlayerMeta } = usePlayerMeta(); const meta = usePlayerStore((s) => s.meta); const [loadingState] = useSeasonData(meta?.tmdbId ?? "", selectedSeason); + const progress = useProgressStore(); const playEpisode = useCallback( (episodeId: string) => { @@ -110,6 +114,8 @@ function EpisodesView({ [setPlayerMeta, loadingState, router, onChange] ); + if (!meta?.tmdbId) return null; + let content: ReactNode = null; if (loadingState.error) content = Error loading season; @@ -124,21 +130,47 @@ function EpisodesView({ ) : null} {loadingState.value.season.episodes.map((ep) => { + const episodeProgress = + progress.items[meta?.tmdbId]?.episodes?.[ep.id]; + + let rightSide; + if (episodeProgress) { + const percentage = + (episodeProgress.progress.watched / + episodeProgress.progress.duration) * + 100; + rightSide = ( + 90 ? 100 : percentage} + /> + ); + } + return ( - playEpisode(ep.id)} active={ep.id === meta?.episode?.tmdbId} + clickable + rightSide={rightSide} >
- + E{ep.number} {ep.title}
-
+ ); })} diff --git a/src/components/player/atoms/NextEpisodeButton.tsx b/src/components/player/atoms/NextEpisodeButton.tsx index e197300e..331f874d 100644 --- a/src/components/player/atoms/NextEpisodeButton.tsx +++ b/src/components/player/atoms/NextEpisodeButton.tsx @@ -4,7 +4,7 @@ import { useCallback } from "react"; import { Icon, Icons } from "@/components/Icon"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { Transition } from "@/components/Transition"; -import { PlayerMetaEpisode } from "@/stores/player/slices/source"; +import { PlayerMeta } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; function shouldShowNextEpisodeButton( @@ -37,7 +37,10 @@ function Button(props: { ); } -export function NextEpisodeButton(props: { controlsShowing: boolean }) { +export function NextEpisodeButton(props: { + controlsShowing: boolean; + onChange?: (meta: PlayerMeta) => void; +}) { const duration = usePlayerStore((s) => s.progress.duration); const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn); const meta = usePlayerStore((s) => s.meta); @@ -67,7 +70,8 @@ export function NextEpisodeButton(props: { controlsShowing: boolean }) { const metaCopy = { ...meta }; metaCopy.episode = nextEp; setDirectMeta(metaCopy); - }, [setDirectMeta, nextEp, meta]); + props.onChange?.(metaCopy); + }, [setDirectMeta, nextEp, meta, props]); if (!meta?.episode || !nextEp) return null; if (metaType !== "show") return null; diff --git a/src/components/player/base/BottomControls.tsx b/src/components/player/base/BottomControls.tsx index dce015e1..68d5e980 100644 --- a/src/components/player/base/BottomControls.tsx +++ b/src/components/player/base/BottomControls.tsx @@ -1,9 +1,22 @@ +import { useEffect } from "react"; + import { Transition } from "@/components/Transition"; +import { usePlayerStore } from "@/stores/player/store"; export function BottomControls(props: { show?: boolean; children: React.ReactNode; }) { + const setHoveringAnyControls = usePlayerStore( + (s) => s.setHoveringAnyControls + ); + + useEffect(() => { + return () => { + setHoveringAnyControls(false); + }; + }, [setHoveringAnyControls]); + return (
- setHoveringAnyControls(true)} + onMouseOut={() => setHoveringAnyControls(false)} className="pointer-events-auto pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pb-3 mb-[env(safe-area-inset-bottom)] absolute bottom-0 w-full" > - {props.children} - + + {props.children} + +
); } diff --git a/src/components/player/base/Container.tsx b/src/components/player/base/Container.tsx index 7ef8b652..5c05a923 100644 --- a/src/components/player/base/Container.tsx +++ b/src/components/player/base/Container.tsx @@ -13,6 +13,7 @@ import { usePlayerStore } from "@/stores/player/store"; export interface PlayerProps { children?: ReactNode; + showingControls: boolean; onLoad?: () => void; } @@ -89,7 +90,7 @@ export function Container(props: PlayerProps) {
- + {props.children}
diff --git a/src/components/player/base/TopControls.tsx b/src/components/player/base/TopControls.tsx index 60022a4e..03369b75 100644 --- a/src/components/player/base/TopControls.tsx +++ b/src/components/player/base/TopControls.tsx @@ -1,9 +1,22 @@ +import { useEffect } from "react"; + import { Transition } from "@/components/Transition"; +import { usePlayerStore } from "@/stores/player/store"; export function TopControls(props: { show?: boolean; children: React.ReactNode; }) { + const setHoveringAnyControls = usePlayerStore( + (s) => s.setHoveringAnyControls + ); + + useEffect(() => { + return () => { + setHoveringAnyControls(false); + }; + }, [setHoveringAnyControls]); + return (
- setHoveringAnyControls(true)} + onMouseOut={() => setHoveringAnyControls(false)} + className="pointer-events-auto pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pt-6 absolute top-0 w-full" > - {props.children} - + + {props.children} + +
); } diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 2b30a0f9..bbc7a55c 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -54,6 +54,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { let startAt = 0; let automaticQuality = false; let preferenceQuality: SourceQuality | null = null; + let lastVolume = 1; function reportLevels() { if (!hls) return; @@ -226,6 +227,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { destroyVideoElement(); videoElement = video; setSource(); + this.setVolume(lastVolume); }, processContainerElement(container) { containerElement = container; @@ -261,11 +263,13 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { videoElement.currentTime = time; }, async setVolume(v) { - if (!videoElement) return; - // clamp time between 0 and 1 let volume = Math.min(v, 1); volume = Math.max(0, volume); + + // actually set + lastVolume = v; + if (!videoElement) return; videoElement.muted = volume === 0; // Muted attribute is always supported // update state diff --git a/src/components/player/hooks/usePlayerMeta.ts b/src/components/player/hooks/usePlayerMeta.ts index f37bd4c4..5be2057c 100644 --- a/src/components/player/hooks/usePlayerMeta.ts +++ b/src/components/player/hooks/usePlayerMeta.ts @@ -16,8 +16,8 @@ export function usePlayerMeta() { const setDirectMeta = useCallback( (m: PlayerMeta) => { _setPlayerMeta(m); - setMeta(m); setScrapeStatus(); + setMeta(m); }, [_setPlayerMeta, setMeta, setScrapeStatus] ); diff --git a/src/components/player/hooks/useShouldShowControls.tsx b/src/components/player/hooks/useShouldShowControls.tsx index 3d76cb4e..30eac99a 100644 --- a/src/components/player/hooks/useShouldShowControls.tsx +++ b/src/components/player/hooks/useShouldShowControls.tsx @@ -8,12 +8,16 @@ export function useShouldShowControls() { ); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const hasOpenOverlay = usePlayerStore((s) => s.interface.hasOpenOverlay); + const isHoveringControls = usePlayerStore( + (s) => s.interface.isHoveringControls + ); const isUsingTouch = lastHoveringState === PlayerHoverState.MOBILE_TAPPED; const isHovering = hovering !== PlayerHoverState.NOT_HOVERING; // when using touch, pause screens can be dismissed by tapping - const showTargetsWithoutPause = isHovering || hasOpenOverlay; + const showTargetsWithoutPause = + isHovering || isHoveringControls || hasOpenOverlay; const showTargetsIncludingPause = showTargetsWithoutPause || isPaused; const showTargets = isUsingTouch ? showTargetsWithoutPause diff --git a/src/components/player/internals/ContextMenu/Links.tsx b/src/components/player/internals/ContextMenu/Links.tsx index 1bfbf63f..363a864b 100644 --- a/src/components/player/internals/ContextMenu/Links.tsx +++ b/src/components/player/internals/ContextMenu/Links.tsx @@ -58,8 +58,9 @@ export function Link(props: { }) { const classes = classNames("flex py-2 px-3 rounded w-full -ml-3", { "cursor-default": !props.clickable, - "hover:bg-video-context-border cursor-pointer": props.clickable, - "bg-video-context-border": props.active, + "hover:bg-video-context-hoverColor hover:bg-opacity-50 cursor-pointer": + props.clickable, + "bg-video-context-hoverColor bg-opacity-50": props.active, }); const styles = { width: "calc(100% + 1.5rem)" }; diff --git a/src/components/player/internals/StatusCircle.tsx b/src/components/player/internals/StatusCircle.tsx index 8276b764..9daf52ef 100644 --- a/src/components/player/internals/StatusCircle.tsx +++ b/src/components/player/internals/StatusCircle.tsx @@ -43,14 +43,14 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) { props.type === "noresult", })} > - - + + `${val} 100`)} @@ -61,8 +61,8 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) { stroke="currentColor" className="transition-[strokeDasharray]" /> - - + + s.thumbnails.addImage); + const status = usePlayerStore((s) => s.status); + const resetImages = usePlayerStore((s) => s.thumbnails.resetImages); const meta = usePlayerStore((s) => s.meta); const source = usePlayerStore((s) => s.source); const workerRef = useRef(null); - const inputStream = useMemo(() => { - if (!source) return null; - return selectQuality(source, { - automaticQuality: false, - lastChosenQuality: "360", - }); - }, [source]); + // object references dont always trigger changes, so we serialize it to detect *any* change + const sourceSeralized = JSON.stringify(source); - // start worker with the stream - useEffect(() => { + const start = useCallback(() => { + let inputStream = null; + if (source) + inputStream = selectQuality(source, { + automaticQuality: false, + lastChosenQuality: "360", + }); // dont interrupt existing working if (workerRef.current) return; + if (status !== playerStatus.PLAYING) return; if (!inputStream) return; + resetImages(); const ins = new ThumnbnailWorker({ addImage, }); workerRef.current = ins; ins.start(inputStream.stream); - }, [inputStream, addImage]); + }, [source, addImage, resetImages, status]); + const startRef = useRef(start); + useEffect(() => { + startRef.current = start; + }, [start, status]); + + // start worker with the stream + useEffect(() => { + startRef.current(); + }, [sourceSeralized]); // destroy worker on unmount useEffect(() => { @@ -157,7 +172,8 @@ export function ThumbnailScraper() { workerRef.current.destroy(); workerRef.current = null; } - }, [serializedMeta]); + startRef.current(); + }, [serializedMeta, sourceSeralized, status]); return null; } diff --git a/src/components/player/internals/VideoClickTarget.tsx b/src/components/player/internals/VideoClickTarget.tsx index f57cd704..a6d2c878 100644 --- a/src/components/player/internals/VideoClickTarget.tsx +++ b/src/components/player/internals/VideoClickTarget.tsx @@ -1,10 +1,11 @@ +import classNames from "classnames"; import { PointerEvent, useCallback } from "react"; import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer"; import { PlayerHoverState } from "@/stores/player/slices/interface"; import { usePlayerStore } from "@/stores/player/store"; -export function VideoClickTarget() { +export function VideoClickTarget(props: { showingControls: boolean }) { const show = useShouldShowVideoElement(); const display = usePlayerStore((s) => s.display); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); @@ -41,7 +42,10 @@ export function VideoClickTarget() { return (
diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 8c0723b0..2e3cdc46 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -1,5 +1,5 @@ import { RunOutput } from "@movie-web/providers"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { useHistory, useParams } from "react-router-dom"; import { usePlayer } from "@/components/player/hooks/usePlayer"; @@ -8,6 +8,7 @@ import { convertRunoutputToSource } from "@/components/player/utils/convertRunou import { MetaPart } from "@/pages/parts/player/MetaPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; +import { useLastNonPlayerLink } from "@/stores/history"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; export function PlayerView() { @@ -19,7 +20,7 @@ export function PlayerView() { }>(); const { status, playMedia, reset } = usePlayer(); const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); - const [backUrl] = useState("/"); // TODO redirect to search when needed + const backUrl = useLastNonPlayerLink(); const paramsData = JSON.stringify({ media: params.media, diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 67664948..e441a2f0 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -20,7 +20,7 @@ export function PlayerPart(props: PlayerPartProps) { const { isMobile } = useIsMobile(); return ( - + {props.children} @@ -98,7 +98,10 @@ export function PlayerPart(props: PlayerPartProps) { - + ); } diff --git a/src/pages/parts/player/ScrapingPart.tsx b/src/pages/parts/player/ScrapingPart.tsx index 681a58dd..7690ce82 100644 --- a/src/pages/parts/player/ScrapingPart.tsx +++ b/src/pages/parts/player/ScrapingPart.tsx @@ -41,9 +41,9 @@ export function ScrapingPart(props: ScrapingProps) { const currentProvider = sourceOrder.find( (s) => sources[s.id].status === "pending" ); - const currentProviderIndex = sourceOrder.findIndex( - (provider) => currentProvider?.id === provider.id - ); + const currentProviderIndex = + sourceOrder.findIndex((provider) => currentProvider?.id === provider.id) ?? + sourceOrder.length - 1; return (
diff --git a/src/setup/App.tsx b/src/setup/App.tsx index fea66130..10c46f84 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -20,6 +20,7 @@ import { Layout } from "@/setup/Layout"; import { BookmarkContextProvider } from "@/state/bookmark"; import { SettingsProvider } from "@/state/settings"; import { WatchedContextProvider } from "@/state/watched"; +import { useHistoryListener } from "@/stores/history"; function LegacyUrlView({ children }: { children: ReactElement }) { const location = useLocation(); @@ -55,6 +56,8 @@ function QuickSearch() { } function App() { + useHistoryListener(); + return ( diff --git a/src/stores/history/index.ts b/src/stores/history/index.ts new file mode 100644 index 00000000..572358cf --- /dev/null +++ b/src/stores/history/index.ts @@ -0,0 +1,53 @@ +import { useEffect, useMemo } from "react"; +import { useHistory, useLocation } from "react-router-dom"; +import { useEffectOnce } from "react-use"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; + +interface HistoryRoute { + path: string; +} + +interface HistoryStore { + routes: HistoryRoute[]; + registerRoute(route: HistoryRoute): void; +} + +export const useHistoryStore = create( + immer((set) => ({ + routes: [], + registerRoute(route) { + set((s) => { + s.routes.push(route); + }); + }, + })) +); + +export function useHistoryListener() { + const history = useHistory(); + const loc = useLocation(); + const registerRoute = useHistoryStore((s) => s.registerRoute); + useEffect( + () => + history.listen((a) => { + registerRoute({ path: a.pathname }); + }), + [history, registerRoute] + ); + + useEffectOnce(() => { + registerRoute({ path: loc.pathname }); + }); +} + +export function useLastNonPlayerLink() { + const routes = useHistoryStore((s) => s.routes); + const lastNonPlayerLink = useMemo(() => { + const reversedRoutes = [...routes]; + reversedRoutes.reverse(); + const route = reversedRoutes.find((v) => !v.path.startsWith("/media")); + return route?.path ?? "/"; + }, [routes]); + return lastNonPlayerLink; +} diff --git a/src/stores/player/slices/interface.ts b/src/stores/player/slices/interface.ts index 02334165..a4b7321e 100644 --- a/src/stores/player/slices/interface.ts +++ b/src/stores/player/slices/interface.ts @@ -27,12 +27,14 @@ export interface InterfaceSlice { volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig" leftControlHovering: boolean; // is the cursor hovered over the left side of player controls + isHoveringControls: boolean; // is the cursor hovered over any controls? timeFormat: VideoPlayerTimeFormat; // Time format of the video player }; updateInterfaceHovering(newState: PlayerHoverState): void; setSeeking(seeking: boolean): void; setTimeFormat(format: VideoPlayerTimeFormat): void; setHoveringLeftControls(state: boolean): void; + setHoveringAnyControls(state: boolean): void; setHasOpenOverlay(state: boolean): void; setLastVolume(state: number): void; hideNextEpisodeButton(): void; @@ -46,6 +48,7 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({ isSeeking: false, lastVolume: 0, leftControlHovering: false, + isHoveringControls: false, hovering: PlayerHoverState.NOT_HOVERING, lastHoveringState: PlayerHoverState.NOT_HOVERING, volumeChangedWithKeybind: false, @@ -89,6 +92,11 @@ export const createInterfaceSlice: MakeSlice = (set, get) => ({ s.interface.leftControlHovering = state; }); }, + setHoveringAnyControls(state) { + set((s) => { + s.interface.isHoveringControls = state; + }); + }, hideNextEpisodeButton() { set((s) => { s.interface.hideNextEpisodeBtn = true; diff --git a/src/stores/player/slices/thumbnails.ts b/src/stores/player/slices/thumbnails.ts index b0394d34..de166698 100644 --- a/src/stores/player/slices/thumbnails.ts +++ b/src/stores/player/slices/thumbnails.ts @@ -9,6 +9,7 @@ export interface ThumbnailSlice { thumbnails: { images: ThumbnailImage[]; addImage(img: ThumbnailImage): void; + resetImages(): void; }; } @@ -73,6 +74,11 @@ export function nearestImageAt( export const createThumbnailSlice: MakeSlice = (set, get) => ({ thumbnails: { images: [], + resetImages() { + set((s) => { + s.thumbnails.images = []; + }); + }, addImage(img) { const store = get(); const exactOrPastImageIndex = store.thumbnails.images.findIndex( diff --git a/tailwind.config.js b/tailwind.config.js index a840f76a..f03441be 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -143,7 +143,8 @@ module.exports = { context: { background: "#0C1216", light: "#4D79A8", - border: "#141D23", + border: "#1d252b", + hoverColor: "#1E2A32", buttonFocus: "#202836", flagBg: "#202836", inputBg: "#202836",