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 { 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 = <CenteredText>Error loading season</CenteredText>;
@ -124,21 +130,47 @@ function EpisodesView({
</Menu.TextDisplay>
) : 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 = (
<ProgressRing
className="h-[18px] w-[18px] text-white"
percentage={percentage > 90 ? 100 : percentage}
/>
);
}
return (
<Menu.ChevronLink
<Menu.Link
key={ep.id}
onClick={() => playEpisode(ep.id)}
active={ep.id === meta?.episode?.tmdbId}
clickable
rightSide={rightSide}
>
<Menu.LinkTitle>
<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}
</span>
<span className="line-clamp-1 break-all">{ep.title}</span>
</div>
</Menu.LinkTitle>
</Menu.ChevronLink>
</Menu.Link>
);
})}
</Menu.Section>

View File

@ -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;

View File

@ -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 (
<div className="w-full text-white">
<Transition
@ -11,13 +24,15 @@ export function BottomControls(props: {
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"
/>
<Transition
animation="slide-up"
show={props.show}
<div
onMouseOver={() => 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}
</Transition>
<Transition animation="slide-up" show={props.show}>
{props.children}
</Transition>
</div>
</div>
);
}

View File

@ -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) {
<ProgressSaver />
<KeyboardEvents />
<div className="relative h-screen overflow-hidden">
<VideoClickTarget />
<VideoClickTarget showingControls={props.showingControls} />
<HeadUpdater />
{props.children}
</div>

View File

@ -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 (
<div className="w-full text-white">
<Transition
@ -11,13 +24,19 @@ export function TopControls(props: {
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"
/>
<Transition
animation="slide-down"
show={props.show}
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"
<div
onMouseOver={() => 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}
</Transition>
<Transition
animation="slide-down"
show={props.show}
className="text-white"
>
{props.children}
</Transition>
</div>
</div>
);
}

View File

@ -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

View File

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

View File

@ -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

View File

@ -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)" };

View File

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

View File

@ -1,6 +1,7 @@
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 { usePlayerStore } from "@/stores/player/store";
import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities";
@ -54,12 +55,12 @@ class ThumnbnailWorker {
}
destroy() {
this.hls?.detachMedia();
this.hls?.destroy();
this.hls = null;
this.interrupted = true;
this.videoEl = null;
this.canvasEl = null;
this.hls?.detachMedia();
this.hls?.destroy();
this.hls = null;
}
private async initVideo() {
@ -91,6 +92,7 @@ class ThumnbnailWorker {
);
const imgUrl = this.canvasEl.toDataURL();
if (this.interrupted) return;
this.cb({
at,
data: imgUrl,
@ -112,29 +114,42 @@ class ThumnbnailWorker {
export function ThumbnailScraper() {
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 source = usePlayerStore((s) => s.source);
const workerRef = useRef<ThumnbnailWorker | null>(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;
}

View File

@ -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 (
<div
className="absolute inset-0"
className={classNames("absolute inset-0", {
"absolute inset-0": true,
"cursor-none": !props.showingControls,
})}
onDoubleClick={toggleFullscreen}
onPointerUp={togglePause}
/>

View File

@ -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,

View File

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

View File

@ -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 (
<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 { 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 (
<SettingsProvider>
<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"
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<InterfaceSlice> = (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<InterfaceSlice> = (set, get) => ({
s.interface.leftControlHovering = state;
});
},
setHoveringAnyControls(state) {
set((s) => {
s.interface.isHoveringControls = state;
});
},
hideNextEpisodeButton() {
set((s) => {
s.interface.hideNextEpisodeBtn = true;

View File

@ -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<ThumbnailSlice> = (set, get) => ({
thumbnails: {
images: [],
resetImages() {
set((s) => {
s.thumbnails.images = [];
});
},
addImage(img) {
const store = get();
const exactOrPastImageIndex = store.thumbnails.images.findIndex(

View File

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