thumbnail fixes + next episode fixes + cursor now hides when controls are dismissed + back link can go back to search + hovering over controls no longer dismisses controls + improved colors for context menus + progress ring shown in episode selector + scrape progress ring shows progress again

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-21 16:13:16 +02:00
parent 46cb7793c2
commit 068b7071a4
20 changed files with 234 additions and 59 deletions

View File

@ -1,3 +1,4 @@
import classNames from "classnames";
import { ReactNode, useCallback, useEffect, useState } from "react"; import { ReactNode, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsync } from "react-use"; import { useAsync } from "react-use";
@ -5,6 +6,7 @@ import { useAsync } from "react-use";
import { getMetaFromId } from "@/backend/metadata/getmeta"; import { getMetaFromId } from "@/backend/metadata/getmeta";
import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw"; import { MWMediaType, MWSeasonMeta } from "@/backend/metadata/types/mw";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { ProgressRing } from "@/components/layout/ProgressRing";
import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; import { OverlayAnchor } from "@/components/overlays/OverlayAnchor";
import { Overlay } from "@/components/overlays/OverlayDisplay"; import { Overlay } from "@/components/overlays/OverlayDisplay";
import { OverlayPage } from "@/components/overlays/OverlayPage"; import { OverlayPage } from "@/components/overlays/OverlayPage";
@ -15,6 +17,7 @@ import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { PlayerMeta } from "@/stores/player/slices/source"; import { PlayerMeta } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { useProgressStore } from "@/stores/progress";
function CenteredText(props: { children: React.ReactNode }) { function CenteredText(props: { children: React.ReactNode }) {
return ( return (
@ -98,6 +101,7 @@ function EpisodesView({
const { setPlayerMeta } = usePlayerMeta(); const { setPlayerMeta } = usePlayerMeta();
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
const [loadingState] = useSeasonData(meta?.tmdbId ?? "", selectedSeason); const [loadingState] = useSeasonData(meta?.tmdbId ?? "", selectedSeason);
const progress = useProgressStore();
const playEpisode = useCallback( const playEpisode = useCallback(
(episodeId: string) => { (episodeId: string) => {
@ -110,6 +114,8 @@ function EpisodesView({
[setPlayerMeta, loadingState, router, onChange] [setPlayerMeta, loadingState, router, onChange]
); );
if (!meta?.tmdbId) return null;
let content: ReactNode = null; let content: ReactNode = null;
if (loadingState.error) if (loadingState.error)
content = <CenteredText>Error loading season</CenteredText>; content = <CenteredText>Error loading season</CenteredText>;
@ -124,21 +130,47 @@ function EpisodesView({
</Menu.TextDisplay> </Menu.TextDisplay>
) : null} ) : null}
{loadingState.value.season.episodes.map((ep) => { {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 = (
<ProgressRing
className="h-[18px] w-[18px] text-white"
percentage={percentage > 90 ? 100 : percentage}
/>
);
}
return ( return (
<Menu.ChevronLink <Menu.Link
key={ep.id} key={ep.id}
onClick={() => playEpisode(ep.id)} onClick={() => playEpisode(ep.id)}
active={ep.id === meta?.episode?.tmdbId} active={ep.id === meta?.episode?.tmdbId}
clickable
rightSide={rightSide}
> >
<Menu.LinkTitle> <Menu.LinkTitle>
<div className="text-left flex items-center space-x-3"> <div className="text-left flex items-center space-x-3">
<span className="p-0.5 px-2 rounded inline bg-video-context-border"> <span
className={classNames(
"p-0.5 px-2 rounded inline bg-video-context-hoverColor",
ep.id === meta?.episode?.tmdbId
? "text-white bg-opacity-100"
: "bg-opacity-50"
)}
>
E{ep.number} E{ep.number}
</span> </span>
<span className="line-clamp-1 break-all">{ep.title}</span> <span className="line-clamp-1 break-all">{ep.title}</span>
</div> </div>
</Menu.LinkTitle> </Menu.LinkTitle>
</Menu.ChevronLink> </Menu.Link>
); );
})} })}
</Menu.Section> </Menu.Section>

View File

@ -4,7 +4,7 @@ import { useCallback } from "react";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { Transition } from "@/components/Transition"; 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"; import { usePlayerStore } from "@/stores/player/store";
function shouldShowNextEpisodeButton( 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 duration = usePlayerStore((s) => s.progress.duration);
const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn); const isHidden = usePlayerStore((s) => s.interface.hideNextEpisodeBtn);
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
@ -67,7 +70,8 @@ export function NextEpisodeButton(props: { controlsShowing: boolean }) {
const metaCopy = { ...meta }; const metaCopy = { ...meta };
metaCopy.episode = nextEp; metaCopy.episode = nextEp;
setDirectMeta(metaCopy); setDirectMeta(metaCopy);
}, [setDirectMeta, nextEp, meta]); props.onChange?.(metaCopy);
}, [setDirectMeta, nextEp, meta, props]);
if (!meta?.episode || !nextEp) return null; if (!meta?.episode || !nextEp) return null;
if (metaType !== "show") return null; if (metaType !== "show") return null;

View File

@ -1,9 +1,22 @@
import { useEffect } from "react";
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { usePlayerStore } from "@/stores/player/store";
export function BottomControls(props: { export function BottomControls(props: {
show?: boolean; show?: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const setHoveringAnyControls = usePlayerStore(
(s) => s.setHoveringAnyControls
);
useEffect(() => {
return () => {
setHoveringAnyControls(false);
};
}, [setHoveringAnyControls]);
return ( return (
<div className="w-full text-white"> <div className="w-full text-white">
<Transition <Transition
@ -11,13 +24,15 @@ export function BottomControls(props: {
show={props.show} show={props.show}
className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent transition-opacity duration-200 absolute bottom-0 w-full" className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent transition-opacity duration-200 absolute bottom-0 w-full"
/> />
<Transition <div
animation="slide-up" onMouseOver={() => setHoveringAnyControls(true)}
show={props.show} 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" 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} <Transition animation="slide-up" show={props.show}>
</Transition> {props.children}
</Transition>
</div>
</div> </div>
); );
} }

View File

@ -13,6 +13,7 @@ import { usePlayerStore } from "@/stores/player/store";
export interface PlayerProps { export interface PlayerProps {
children?: ReactNode; children?: ReactNode;
showingControls: boolean;
onLoad?: () => void; onLoad?: () => void;
} }
@ -89,7 +90,7 @@ export function Container(props: PlayerProps) {
<ProgressSaver /> <ProgressSaver />
<KeyboardEvents /> <KeyboardEvents />
<div className="relative h-screen overflow-hidden"> <div className="relative h-screen overflow-hidden">
<VideoClickTarget /> <VideoClickTarget showingControls={props.showingControls} />
<HeadUpdater /> <HeadUpdater />
{props.children} {props.children}
</div> </div>

View File

@ -1,9 +1,22 @@
import { useEffect } from "react";
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { usePlayerStore } from "@/stores/player/store";
export function TopControls(props: { export function TopControls(props: {
show?: boolean; show?: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const setHoveringAnyControls = usePlayerStore(
(s) => s.setHoveringAnyControls
);
useEffect(() => {
return () => {
setHoveringAnyControls(false);
};
}, [setHoveringAnyControls]);
return ( return (
<div className="w-full text-white"> <div className="w-full text-white">
<Transition <Transition
@ -11,13 +24,19 @@ export function TopControls(props: {
show={props.show} show={props.show}
className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full" className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full"
/> />
<Transition <div
animation="slide-down" onMouseOver={() => setHoveringAnyControls(true)}
show={props.show} 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 text-white" 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} <Transition
</Transition> animation="slide-down"
show={props.show}
className="text-white"
>
{props.children}
</Transition>
</div>
</div> </div>
); );
} }

View File

@ -54,6 +54,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
let startAt = 0; let startAt = 0;
let automaticQuality = false; let automaticQuality = false;
let preferenceQuality: SourceQuality | null = null; let preferenceQuality: SourceQuality | null = null;
let lastVolume = 1;
function reportLevels() { function reportLevels() {
if (!hls) return; if (!hls) return;
@ -226,6 +227,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
destroyVideoElement(); destroyVideoElement();
videoElement = video; videoElement = video;
setSource(); setSource();
this.setVolume(lastVolume);
}, },
processContainerElement(container) { processContainerElement(container) {
containerElement = container; containerElement = container;
@ -261,11 +263,13 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
videoElement.currentTime = time; videoElement.currentTime = time;
}, },
async setVolume(v) { async setVolume(v) {
if (!videoElement) return;
// clamp time between 0 and 1 // clamp time between 0 and 1
let volume = Math.min(v, 1); let volume = Math.min(v, 1);
volume = Math.max(0, volume); volume = Math.max(0, volume);
// actually set
lastVolume = v;
if (!videoElement) return;
videoElement.muted = volume === 0; // Muted attribute is always supported videoElement.muted = volume === 0; // Muted attribute is always supported
// update state // update state

View File

@ -16,8 +16,8 @@ export function usePlayerMeta() {
const setDirectMeta = useCallback( const setDirectMeta = useCallback(
(m: PlayerMeta) => { (m: PlayerMeta) => {
_setPlayerMeta(m); _setPlayerMeta(m);
setMeta(m);
setScrapeStatus(); setScrapeStatus();
setMeta(m);
}, },
[_setPlayerMeta, setMeta, setScrapeStatus] [_setPlayerMeta, setMeta, setScrapeStatus]
); );

View File

@ -8,12 +8,16 @@ export function useShouldShowControls() {
); );
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
const hasOpenOverlay = usePlayerStore((s) => s.interface.hasOpenOverlay); const hasOpenOverlay = usePlayerStore((s) => s.interface.hasOpenOverlay);
const isHoveringControls = usePlayerStore(
(s) => s.interface.isHoveringControls
);
const isUsingTouch = lastHoveringState === PlayerHoverState.MOBILE_TAPPED; const isUsingTouch = lastHoveringState === PlayerHoverState.MOBILE_TAPPED;
const isHovering = hovering !== PlayerHoverState.NOT_HOVERING; const isHovering = hovering !== PlayerHoverState.NOT_HOVERING;
// when using touch, pause screens can be dismissed by tapping // when using touch, pause screens can be dismissed by tapping
const showTargetsWithoutPause = isHovering || hasOpenOverlay; const showTargetsWithoutPause =
isHovering || isHoveringControls || hasOpenOverlay;
const showTargetsIncludingPause = showTargetsWithoutPause || isPaused; const showTargetsIncludingPause = showTargetsWithoutPause || isPaused;
const showTargets = isUsingTouch const showTargets = isUsingTouch
? showTargetsWithoutPause ? showTargetsWithoutPause

View File

@ -58,8 +58,9 @@ export function Link(props: {
}) { }) {
const classes = classNames("flex py-2 px-3 rounded w-full -ml-3", { const classes = classNames("flex py-2 px-3 rounded w-full -ml-3", {
"cursor-default": !props.clickable, "cursor-default": !props.clickable,
"hover:bg-video-context-border cursor-pointer": props.clickable, "hover:bg-video-context-hoverColor hover:bg-opacity-50 cursor-pointer":
"bg-video-context-border": props.active, props.clickable,
"bg-video-context-hoverColor bg-opacity-50": props.active,
}); });
const styles = { width: "calc(100% + 1.5rem)" }; const styles = { width: "calc(100% + 1.5rem)" };

View File

@ -43,14 +43,14 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
props.type === "noresult", props.type === "noresult",
})} })}
> >
<svg <Transition animation="fade" show={statusIsLoading(props)}>
width="100%" <svg
height="100%" width="100%"
viewBox="0 0 64 64" height="100%"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"
className="rounded-full -rotate-90" xmlns="http://www.w3.org/2000/svg"
> className="rounded-full -rotate-90"
<Transition animation="fade" show={statusIsLoading(props)}> >
<a.circle <a.circle
strokeWidth="32" strokeWidth="32"
strokeDasharray={to(spring.percentage, (val) => `${val} 100`)} strokeDasharray={to(spring.percentage, (val) => `${val} 100`)}
@ -61,8 +61,8 @@ export function StatusCircle(props: StatusCircle | StatusCircleLoading) {
stroke="currentColor" stroke="currentColor"
className="transition-[strokeDasharray]" className="transition-[strokeDasharray]"
/> />
</Transition> </svg>
</svg> </Transition>
<Transition animation="fade" show={props.type === "error"}> <Transition animation="fade" show={props.type === "error"}>
<Icon <Icon
className="absolute inset-0 flex items-center justify-center text-white" className="absolute inset-0 flex items-center justify-center text-white"

View File

@ -1,6 +1,7 @@
import Hls from "hls.js"; import Hls from "hls.js";
import { useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { playerStatus } from "@/stores/player/slices/source";
import { ThumbnailImage } from "@/stores/player/slices/thumbnails"; import { ThumbnailImage } from "@/stores/player/slices/thumbnails";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities"; import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities";
@ -54,12 +55,12 @@ class ThumnbnailWorker {
} }
destroy() { destroy() {
this.hls?.detachMedia();
this.hls?.destroy();
this.hls = null;
this.interrupted = true; this.interrupted = true;
this.videoEl = null; this.videoEl = null;
this.canvasEl = null; this.canvasEl = null;
this.hls?.detachMedia();
this.hls?.destroy();
this.hls = null;
} }
private async initVideo() { private async initVideo() {
@ -91,6 +92,7 @@ class ThumnbnailWorker {
); );
const imgUrl = this.canvasEl.toDataURL(); const imgUrl = this.canvasEl.toDataURL();
if (this.interrupted) return; if (this.interrupted) return;
this.cb({ this.cb({
at, at,
data: imgUrl, data: imgUrl,
@ -112,29 +114,42 @@ class ThumnbnailWorker {
export function ThumbnailScraper() { export function ThumbnailScraper() {
const addImage = usePlayerStore((s) => s.thumbnails.addImage); const addImage = usePlayerStore((s) => s.thumbnails.addImage);
const status = usePlayerStore((s) => s.status);
const resetImages = usePlayerStore((s) => s.thumbnails.resetImages);
const meta = usePlayerStore((s) => s.meta); const meta = usePlayerStore((s) => s.meta);
const source = usePlayerStore((s) => s.source); const source = usePlayerStore((s) => s.source);
const workerRef = useRef<ThumnbnailWorker | null>(null); const workerRef = useRef<ThumnbnailWorker | null>(null);
const inputStream = useMemo(() => { // object references dont always trigger changes, so we serialize it to detect *any* change
if (!source) return null; const sourceSeralized = JSON.stringify(source);
return selectQuality(source, {
automaticQuality: false,
lastChosenQuality: "360",
});
}, [source]);
// start worker with the stream const start = useCallback(() => {
useEffect(() => { let inputStream = null;
if (source)
inputStream = selectQuality(source, {
automaticQuality: false,
lastChosenQuality: "360",
});
// dont interrupt existing working // dont interrupt existing working
if (workerRef.current) return; if (workerRef.current) return;
if (status !== playerStatus.PLAYING) return;
if (!inputStream) return; if (!inputStream) return;
resetImages();
const ins = new ThumnbnailWorker({ const ins = new ThumnbnailWorker({
addImage, addImage,
}); });
workerRef.current = ins; workerRef.current = ins;
ins.start(inputStream.stream); 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 // destroy worker on unmount
useEffect(() => { useEffect(() => {
@ -157,7 +172,8 @@ export function ThumbnailScraper() {
workerRef.current.destroy(); workerRef.current.destroy();
workerRef.current = null; workerRef.current = null;
} }
}, [serializedMeta]); startRef.current();
}, [serializedMeta, sourceSeralized, status]);
return null; return null;
} }

View File

@ -1,10 +1,11 @@
import classNames from "classnames";
import { PointerEvent, useCallback } from "react"; import { PointerEvent, useCallback } from "react";
import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer"; import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer";
import { PlayerHoverState } from "@/stores/player/slices/interface"; import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
export function VideoClickTarget() { export function VideoClickTarget(props: { showingControls: boolean }) {
const show = useShouldShowVideoElement(); const show = useShouldShowVideoElement();
const display = usePlayerStore((s) => s.display); const display = usePlayerStore((s) => s.display);
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
@ -41,7 +42,10 @@ export function VideoClickTarget() {
return ( return (
<div <div
className="absolute inset-0" className={classNames("absolute inset-0", {
"absolute inset-0": true,
"cursor-none": !props.showingControls,
})}
onDoubleClick={toggleFullscreen} onDoubleClick={toggleFullscreen}
onPointerUp={togglePause} onPointerUp={togglePause}
/> />

View File

@ -1,5 +1,5 @@
import { RunOutput } from "@movie-web/providers"; 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 { useHistory, useParams } from "react-router-dom";
import { usePlayer } from "@/components/player/hooks/usePlayer"; 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 { MetaPart } from "@/pages/parts/player/MetaPart";
import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
import { useLastNonPlayerLink } from "@/stores/history";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
export function PlayerView() { export function PlayerView() {
@ -19,7 +20,7 @@ export function PlayerView() {
}>(); }>();
const { status, playMedia, reset } = usePlayer(); const { status, playMedia, reset } = usePlayer();
const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); const { setPlayerMeta, scrapeMedia } = usePlayerMeta();
const [backUrl] = useState("/"); // TODO redirect to search when needed const backUrl = useLastNonPlayerLink();
const paramsData = JSON.stringify({ const paramsData = JSON.stringify({
media: params.media, media: params.media,

View File

@ -20,7 +20,7 @@ export function PlayerPart(props: PlayerPartProps) {
const { isMobile } = useIsMobile(); const { isMobile } = useIsMobile();
return ( return (
<Player.Container onLoad={props.onLoad}> <Player.Container onLoad={props.onLoad} showingControls={showTargets}>
{props.children} {props.children}
<Player.BlackOverlay show={showTargets} /> <Player.BlackOverlay show={showTargets} />
<Player.EpisodesRouter onChange={props.onMetaChange} /> <Player.EpisodesRouter onChange={props.onMetaChange} />
@ -98,7 +98,10 @@ export function PlayerPart(props: PlayerPartProps) {
</Player.BottomControls> </Player.BottomControls>
<Player.VolumeChangedPopout /> <Player.VolumeChangedPopout />
<Player.NextEpisodeButton controlsShowing={showTargets} /> <Player.NextEpisodeButton
controlsShowing={showTargets}
onChange={props.onMetaChange}
/>
</Player.Container> </Player.Container>
); );
} }

View File

@ -41,9 +41,9 @@ export function ScrapingPart(props: ScrapingProps) {
const currentProvider = sourceOrder.find( const currentProvider = sourceOrder.find(
(s) => sources[s.id].status === "pending" (s) => sources[s.id].status === "pending"
); );
const currentProviderIndex = sourceOrder.findIndex( const currentProviderIndex =
(provider) => currentProvider?.id === provider.id sourceOrder.findIndex((provider) => currentProvider?.id === provider.id) ??
); sourceOrder.length - 1;
return ( return (
<div className="h-full w-full relative" ref={containerRef}> <div className="h-full w-full relative" ref={containerRef}>

View File

@ -20,6 +20,7 @@ import { Layout } from "@/setup/Layout";
import { BookmarkContextProvider } from "@/state/bookmark"; import { BookmarkContextProvider } from "@/state/bookmark";
import { SettingsProvider } from "@/state/settings"; import { SettingsProvider } from "@/state/settings";
import { WatchedContextProvider } from "@/state/watched"; import { WatchedContextProvider } from "@/state/watched";
import { useHistoryListener } from "@/stores/history";
function LegacyUrlView({ children }: { children: ReactElement }) { function LegacyUrlView({ children }: { children: ReactElement }) {
const location = useLocation(); const location = useLocation();
@ -55,6 +56,8 @@ function QuickSearch() {
} }
function App() { function App() {
useHistoryListener();
return ( return (
<SettingsProvider> <SettingsProvider>
<WatchedContextProvider> <WatchedContextProvider>

View File

@ -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<HistoryStore>((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;
}

View File

@ -27,12 +27,14 @@ export interface InterfaceSlice {
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig" 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 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 timeFormat: VideoPlayerTimeFormat; // Time format of the video player
}; };
updateInterfaceHovering(newState: PlayerHoverState): void; updateInterfaceHovering(newState: PlayerHoverState): void;
setSeeking(seeking: boolean): void; setSeeking(seeking: boolean): void;
setTimeFormat(format: VideoPlayerTimeFormat): void; setTimeFormat(format: VideoPlayerTimeFormat): void;
setHoveringLeftControls(state: boolean): void; setHoveringLeftControls(state: boolean): void;
setHoveringAnyControls(state: boolean): void;
setHasOpenOverlay(state: boolean): void; setHasOpenOverlay(state: boolean): void;
setLastVolume(state: number): void; setLastVolume(state: number): void;
hideNextEpisodeButton(): void; hideNextEpisodeButton(): void;
@ -46,6 +48,7 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
isSeeking: false, isSeeking: false,
lastVolume: 0, lastVolume: 0,
leftControlHovering: false, leftControlHovering: false,
isHoveringControls: false,
hovering: PlayerHoverState.NOT_HOVERING, hovering: PlayerHoverState.NOT_HOVERING,
lastHoveringState: PlayerHoverState.NOT_HOVERING, lastHoveringState: PlayerHoverState.NOT_HOVERING,
volumeChangedWithKeybind: false, volumeChangedWithKeybind: false,
@ -89,6 +92,11 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
s.interface.leftControlHovering = state; s.interface.leftControlHovering = state;
}); });
}, },
setHoveringAnyControls(state) {
set((s) => {
s.interface.isHoveringControls = state;
});
},
hideNextEpisodeButton() { hideNextEpisodeButton() {
set((s) => { set((s) => {
s.interface.hideNextEpisodeBtn = true; s.interface.hideNextEpisodeBtn = true;

View File

@ -9,6 +9,7 @@ export interface ThumbnailSlice {
thumbnails: { thumbnails: {
images: ThumbnailImage[]; images: ThumbnailImage[];
addImage(img: ThumbnailImage): void; addImage(img: ThumbnailImage): void;
resetImages(): void;
}; };
} }
@ -73,6 +74,11 @@ export function nearestImageAt(
export const createThumbnailSlice: MakeSlice<ThumbnailSlice> = (set, get) => ({ export const createThumbnailSlice: MakeSlice<ThumbnailSlice> = (set, get) => ({
thumbnails: { thumbnails: {
images: [], images: [],
resetImages() {
set((s) => {
s.thumbnails.images = [];
});
},
addImage(img) { addImage(img) {
const store = get(); const store = get();
const exactOrPastImageIndex = store.thumbnails.images.findIndex( const exactOrPastImageIndex = store.thumbnails.images.findIndex(

View File

@ -143,7 +143,8 @@ module.exports = {
context: { context: {
background: "#0C1216", background: "#0C1216",
light: "#4D79A8", light: "#4D79A8",
border: "#141D23", border: "#1d252b",
hoverColor: "#1E2A32",
buttonFocus: "#202836", buttonFocus: "#202836",
flagBg: "#202836", flagBg: "#202836",
inputBg: "#202836", inputBg: "#202836",