episode select popout styling, popout router sync & dragging to update time action

This commit is contained in:
Jelle van Snik 2023-02-07 22:34:20 +01:00
parent 3b4e9ce2ca
commit 2a3c93c24f
14 changed files with 318 additions and 100 deletions

View File

@ -35,7 +35,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />

View File

@ -0,0 +1,39 @@
interface Props {
className?: string;
radius?: number;
percentage: number;
backingRingClassname?: string;
}
export function ProgressRing(props: Props) {
const radius = props.radius ?? 40;
return (
<svg
className={`${props.className ?? ""} -rotate-90`}
viewBox="0 0 100 100"
>
<circle
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
props.backingRingClassname ?? ""
}`}
r={radius}
cx="50"
cy="50"
/>
<circle
className="fill-transparent stroke-current stroke-[15] transition-[stroke-dashoffset] duration-150"
r={radius}
cx="50"
cy="50"
style={{
strokeDasharray: `${2 * Math.PI * radius} ${2 * Math.PI * radius}`,
strokeDashoffset: `${
2 * Math.PI * radius -
(props.percentage / 100) * (2 * Math.PI * radius)
}`,
}}
/>
</svg>
);
}

View File

@ -36,3 +36,11 @@ body[data-no-select] {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.line-clamp {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@ -26,13 +26,18 @@ export function ProgressAction() {
commitTime commitTime
); );
// TODO make dragging update timer
useEffect(() => { useEffect(() => {
if (dragRef.current === dragging) return; if (dragRef.current === dragging) return;
dragRef.current = dragging; dragRef.current = dragging;
controls.setSeeking(dragging); controls.setSeeking(dragging);
}, [dragRef, dragging, controls]); }, [dragRef, dragging, controls]);
useEffect(() => {
if (dragging) {
controls.setDraggingTime(videoTime.duration * (dragPercentage / 100));
}
}, [videoTime, dragging, dragPercentage, controls]);
let watchProgress = makePercentageString( let watchProgress = makePercentageString(
makePercentage((videoTime.time / videoTime.duration) * 100) makePercentage((videoTime.time / videoTime.duration) * 100)
); );

View File

@ -1,4 +1,5 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useProgress } from "@/video/state/logic/progress"; import { useProgress } from "@/video/state/logic/progress";
function durationExceedsHour(secs: number): boolean { function durationExceedsHour(secs: number): boolean {
@ -35,9 +36,13 @@ interface Props {
export function TimeAction(props: Props) { export function TimeAction(props: Props) {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const videoTime = useProgress(descriptor); const videoTime = useProgress(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
const hasHours = durationExceedsHour(videoTime.duration); const hasHours = durationExceedsHour(videoTime.duration);
const time = formatSeconds(videoTime.time, hasHours); const time = formatSeconds(
mediaPlaying.isSeeking ? videoTime.draggingTime : videoTime.time,
hasHours
);
const duration = formatSeconds(videoTime.duration, hasHours); const duration = formatSeconds(videoTime.duration, hasHours);
return ( return (

View File

@ -0,0 +1,70 @@
import { useInitialized } from "@/video/components/hooks/useInitialized";
import { ControlMethods, useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
import { useEffect, useRef } from "react";
import { useHistory, useLocation } from "react-router-dom";
function syncRouteToPopout(
location: ReturnType<typeof useLocation>,
controls: ControlMethods
) {
const parsed = new URLSearchParams(location.search);
const value = parsed.get("modal");
if (value) controls.openPopout(value);
else controls.closePopout();
}
// TODO make closing a popout go backwords in history
// TODO fix first event breaking (clicking on page somehow resolves it)
export function useSyncPopouts(descriptor: string) {
const history = useHistory();
const videoInterface = useInterface(descriptor);
const controls = useControls(descriptor);
const intialized = useInitialized(descriptor);
const loc = useLocation();
const lastKnownValue = useRef<string | null>(null);
const controlsRef = useRef<typeof controls>(controls);
useEffect(() => {
controlsRef.current = controls;
}, [controls]);
// sync current popout to router
useEffect(() => {
const popoutId = videoInterface.popout;
if (lastKnownValue.current === popoutId) return;
lastKnownValue.current = popoutId;
// rest only triggers with changes
if (popoutId) {
const params = new URLSearchParams([["modal", popoutId]]).toString();
history.push({
search: params,
state: "popout",
});
} else {
history.push({
search: "",
state: "popout",
});
}
}, [videoInterface, history]);
// sync router to popout state (but only if its not done by block of code above)
useEffect(() => {
if (loc.state === "popout") return;
// sync popout state
syncRouteToPopout(loc, controlsRef.current);
}, [loc]);
// mount hook
const routerInitialized = useRef(false);
useEffect(() => {
if (routerInitialized.current) return;
if (!intialized) return;
syncRouteToPopout(loc, controlsRef.current);
routerInitialized.current = true;
}, [loc, intialized]);
}

View File

@ -10,18 +10,70 @@ import { IconPatch } from "@/components/buttons/IconPatch";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta"; import { useMeta } from "@/video/state/logic/meta";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useWatchedContext } from "@/state/watched";
import { ProgressRing } from "@/components/layout/ProgressRing";
function PopupSection(props: { function PopupSection(props: {
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
}) { }) {
return ( return (
<div className={["p-4", props.className || ""].join(" ")}> <div className={["p-5", props.className || ""].join(" ")}>
{props.children} {props.children}
</div> </div>
); );
} }
interface PopoutListEntryTypes {
active?: boolean;
children: React.ReactNode;
onClick?: () => void;
isOnDarkBackground?: boolean;
percentageCompleted?: number;
}
function PopoutListEntry(props: PopoutListEntryTypes) {
const bg = props.isOnDarkBackground ? "bg-ash-200" : "bg-ash-400";
const hover = props.isOnDarkBackground
? "hover:bg-ash-200"
: "hover:bg-ash-400";
return (
<div
className={[
"group -mx-2 flex items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
hover,
props.active
? `${bg} text-white outline-denim-700`
: "text-denim-700 hover:text-white",
].join(" ")}
onClick={props.onClick}
>
{props.active && (
<div className="absolute left-0 h-8 w-0.5 bg-bink-500" />
)}
<span className="truncate">{props.children}</span>
<div className="relative h-4 w-4">
<Icon
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
icon={Icons.CHEVRON_RIGHT}
/>
{props.percentageCompleted ? (
<ProgressRing
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
backingRingClassname="stroke-ash-500"
percentage={
props.percentageCompleted > 90 ? 100 : props.percentageCompleted
}
/>
) : (
""
)}
</div>
</div>
);
}
export function EpisodeSelectionPopout() { export function EpisodeSelectionPopout() {
const params = useParams<{ const params = useParams<{
media: string; media: string;
@ -90,80 +142,113 @@ export function EpisodeSelectionPopout() {
setCurrentVisibleSeason({ seasonId: id }); setCurrentVisibleSeason({ seasonId: id });
}; };
if (isPickingSeason) const { watched } = useWatchedContext();
return (
<> const titlePositionClass = useMemo(() => {
<PopupSection className="flex items-center space-x-3 border-b border-denim-500 font-bold text-white"> const offset = isPickingSeason ? "left-0" : "left-10";
Pick a season return [
</PopupSection> "absolute w-full transition-[left,opacity] duration-200",
<PopupSection className="overflow-y-auto"> offset,
<div className="space-y-1"> ].join(" ");
{currentSeasonInfo }, [isPickingSeason]);
? meta?.seasons?.map?.((season) => (
<div
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600"
key={season.id}
onClick={() => setSeason(season.id)}
>
{season.title}
</div>
))
: "No season"}
</div>
</PopupSection>
</>
);
return ( return (
<> <>
<PopupSection className="flex items-center space-x-3 border-b border-denim-500 font-bold text-white"> <PopupSection className="bg-ash-100 font-bold text-white">
<button <div className="relative flex items-center">
className="-m-1.5 rounded p-1.5 hover:bg-denim-600" <button
onClick={toggleIsPickingSeason} className={[
type="button" "-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200",
isPickingSeason ? "pointer-events-none opacity-0" : "opacity-1",
].join(" ")}
onClick={toggleIsPickingSeason}
type="button"
>
<Icon icon={Icons.CHEVRON_LEFT} />
</button>
<span
className={[
titlePositionClass,
!isPickingSeason ? "opacity-1" : "opacity-0",
].join(" ")}
>
{currentSeasonInfo?.title || ""}
</span>
<span
className={[
titlePositionClass,
isPickingSeason ? "opacity-1" : "opacity-0",
].join(" ")}
>
Seasons
</span>
</div>
</PopupSection>
<div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
<PopupSection
className={[
"absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
isPickingSeason
? "max-h-full border-t"
: "max-h-0 overflow-hidden py-0",
].join(" ")}
> >
<Icon icon={Icons.CHEVRON_LEFT} /> {currentSeasonInfo
</button> ? meta?.seasons?.map?.((season) => (
<span>{currentSeasonInfo?.title || ""}</span> <PopoutListEntry
</PopupSection> key={season.id}
<PopupSection className="h-full overflow-y-auto"> active={meta?.episode?.seasonId === season.id}
{loading ? ( onClick={() => setSeason(season.id)}
<div className="flex h-full w-full items-center justify-center"> isOnDarkBackground
<Loading /> >
</div> {season.title}
) : error ? ( </PopoutListEntry>
<div className="flex h-full w-full items-center justify-center"> ))
<div className="flex flex-col flex-wrap items-center text-slate-400"> : "No season"}
<IconPatch </PopupSection>
icon={Icons.EYE_SLASH} <PopupSection className="relative h-full overflow-y-auto">
className="text-xl text-bink-600" {loading ? (
/> <div className="flex h-full w-full items-center justify-center">
<p className="mt-6 w-full text-center"> <Loading />
Something went wrong loading the episodes for{" "}
{currentSeasonInfo?.title?.toLowerCase()}
</p>
</div> </div>
</div> ) : error ? (
) : ( <div className="flex h-full w-full items-center justify-center">
<div className="space-y-1"> <div className="flex flex-col flex-wrap items-center text-slate-400">
{currentSeasonEpisodes && currentSeasonInfo <IconPatch
? currentSeasonEpisodes.map((e) => ( icon={Icons.EYE_SLASH}
<div className="text-xl text-bink-600"
className={[ />
"text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600", <p className="mt-6 w-full text-center">
meta?.episode?.episodeId === e.id && Something went wrong loading the episodes for{" "}
"outline outline-2 outline-denim-700", {currentSeasonInfo?.title?.toLowerCase()}
].join(" ")} </p>
onClick={() => setCurrent(currentSeasonInfo.id, e.id)} </div>
key={e.id} </div>
> ) : (
{e.number}. {e.title} <div className="space-y-1">
</div> {currentSeasonEpisodes && currentSeasonInfo
)) ? currentSeasonEpisodes.map((e) => (
: "No episodes"} <PopoutListEntry
</div> key={e.id}
)} active={e.id === meta?.episode?.episodeId}
</PopupSection> onClick={() => setCurrent(currentSeasonInfo.id, e.id)}
percentageCompleted={
watched.items.find(
(item) =>
item.item?.series?.seasonId ===
currentSeasonInfo.id &&
item.item?.series?.episodeId === e.id
)?.percentage
}
>
E{e.number} - {e.title}
</PopoutListEntry>
))
: "No episodes"}
</div>
)}
</PopupSection>
</div>
</> </>
); );
} }

View File

@ -1,4 +1,5 @@
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { useSyncPopouts } from "@/video/components/hooks/useSyncPopouts";
import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout"; import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
@ -19,13 +20,12 @@ function ShowPopout(props: { popoutId: string | null }) {
return null; return null;
} }
// TODO use new design for popouts
// TODO improve anti offscreen math // TODO improve anti offscreen math
// TODO attach router history to popout state, so you can use back button to remove popout
export function PopoutProviderAction() { export function PopoutProviderAction() {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const videoInterface = useInterface(descriptor); const videoInterface = useInterface(descriptor);
const controls = useControls(descriptor); const controls = useControls(descriptor);
useSyncPopouts(descriptor);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
controls.closePopout(); controls.closePopout();
@ -40,12 +40,12 @@ export function PopoutProviderAction() {
30 30
)}px` )}px`
: "30px"; : "30px";
}, [videoInterface]); }, [videoInterface.popoutBounds]);
const distanceFromBottom = useMemo(() => { const distanceFromBottom = useMemo(() => {
return videoInterface.popoutBounds return videoInterface.popoutBounds
? `${videoInterface.popoutBounds.height + 30}px` ? `${videoInterface.popoutBounds.height + 30}px`
: "30px"; : "30px";
}, [videoInterface]); }, [videoInterface.popoutBounds]);
return ( return (
<Transition <Transition
@ -56,7 +56,7 @@ export function PopoutProviderAction() {
<div className="popout-wrapper pointer-events-auto absolute inset-0"> <div className="popout-wrapper pointer-events-auto absolute inset-0">
<div onClick={handleClick} className="absolute inset-0" /> <div onClick={handleClick} className="absolute inset-0" />
<div <div
className="grid-template-rows-[auto,minmax(0px,1fr)] absolute z-10 grid h-[500px] w-72 rounded-lg bg-denim-300" className="absolute z-10 grid h-[500px] w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200"
style={{ style={{
right: distanceFromRight, right: distanceFromRight,
bottom: distanceFromBottom, bottom: distanceFromBottom,

View File

@ -1,5 +1,6 @@
import { updateInterface } from "@/video/state/logic/interface"; import { updateInterface } from "@/video/state/logic/interface";
import { updateMeta } from "@/video/state/logic/meta"; import { updateMeta } from "@/video/state/logic/meta";
import { updateProgress } from "@/video/state/logic/progress";
import { VideoPlayerMeta } from "@/video/state/types"; import { VideoPlayerMeta } from "@/video/state/types";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { VideoPlayerStateController } from "../providers/providerTypes"; import { VideoPlayerStateController } from "../providers/providerTypes";
@ -11,6 +12,7 @@ export type ControlMethods = {
setFocused(focused: boolean): void; setFocused(focused: boolean): void;
setMeta(data?: VideoPlayerMeta): void; setMeta(data?: VideoPlayerMeta): void;
setCurrentEpisode(sId: string, eId: string): void; setCurrentEpisode(sId: string, eId: string): void;
setDraggingTime(num: number): void;
}; };
export function useControls( export function useControls(
@ -53,6 +55,13 @@ export function useControls(
state.interface.leftControlHovering = hovering; state.interface.leftControlHovering = hovering;
updateInterface(descriptor, state); updateInterface(descriptor, state);
}, },
setDraggingTime(num) {
state.progress.draggingTime = Math.max(
0,
Math.min(state.progress.duration, num)
);
updateProgress(descriptor, state);
},
openPopout(id: string) { openPopout(id: string) {
state.interface.popout = id; state.interface.popout = id;
updateInterface(descriptor, state); updateInterface(descriptor, state);

View File

@ -7,6 +7,7 @@ export type VideoProgressEvent = {
time: number; time: number;
duration: number; duration: number;
buffered: number; buffered: number;
draggingTime: number;
}; };
function getProgressFromState(state: VideoPlayerState): VideoProgressEvent { function getProgressFromState(state: VideoPlayerState): VideoProgressEvent {
@ -14,6 +15,7 @@ function getProgressFromState(state: VideoPlayerState): VideoProgressEvent {
time: state.progress.time, time: state.progress.time,
duration: state.progress.duration, duration: state.progress.duration,
buffered: state.progress.buffered, buffered: state.progress.buffered,
draggingTime: state.progress.draggingTime,
}; };
} }

View File

@ -98,6 +98,9 @@ export function createVideoStateProvider(
updateProgress(descriptor, state); updateProgress(descriptor, state);
}, },
setSeeking(active) { setSeeking(active) {
state.mediaPlaying.isSeeking = active;
updateInterface(descriptor, state);
// if it was playing when starting to seek, play again // if it was playing when starting to seek, play again
if (!active) { if (!active) {
if (!state.pausedWhenSeeking) this.play(); if (!state.pausedWhenSeeking) this.play();

View File

@ -42,6 +42,7 @@ export type VideoPlayerState = {
time: number; time: number;
duration: number; duration: number;
buffered: number; buffered: number;
draggingTime: number;
}; };
// meta data of video // meta data of video

View File

@ -1,4 +1,5 @@
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { ProgressRing } from "@/components/layout/ProgressRing";
import { ScrapeEventLog } from "@/hooks/useScrape"; import { ScrapeEventLog } from "@/hooks/useScrape";
interface MediaScrapeLogProps { interface MediaScrapeLogProps {
@ -18,27 +19,11 @@ function MediaScrapePill({ event }: MediaScrapePillProps) {
<div className="flex h-9 w-[220px] items-center rounded-full bg-slate-800 p-3 text-denim-700"> <div className="flex h-9 w-[220px] items-center rounded-full bg-slate-800 p-3 text-denim-700">
<div className="mr-2 flex w-[18px] items-center justify-center"> <div className="mr-2 flex w-[18px] items-center justify-center">
{!event.errored ? ( {!event.errored ? (
<svg className="h-[18px] w-[18px] -rotate-90" viewBox="0 0 100 100"> <ProgressRing
<circle className="h-[18px] w-[18px] text-bink-700"
className="fill-transparent stroke-denim-700 stroke-[15] opacity-25" percentage={event.percentage}
r="40" radius={40}
cx="50" />
cy="50"
/>
<circle
className="fill-transparent stroke-bink-700 stroke-[15] transition-[stroke-dashoffset] duration-150"
r="40"
cx="50"
cy="50"
style={{
strokeDasharray: `${2 * Math.PI * 40} ${2 * Math.PI * 40}`,
strokeDashoffset: `${
2 * Math.PI * 40 -
(event.percentage / 100) * (2 * Math.PI * 40)
}`,
}}
/>
</svg>
) : ( ) : (
<Icon icon={Icons.X} className="text-[0.85em] text-rose-400" /> <Icon icon={Icons.X} className="text-[0.85em] text-rose-400" />
)} )}

View File

@ -18,7 +18,13 @@ module.exports = {
"denim-400": "#2B263D", "denim-400": "#2B263D",
"denim-500": "#38334A", "denim-500": "#38334A",
"denim-600": "#504B64", "denim-600": "#504B64",
"denim-700": "#7A758F" "denim-700": "#7A758F",
"ash-600": "#817998",
"ash-500": "#9C93B5",
"ash-400": "#3D394D",
"ash-300": "#2C293A",
"ash-200": "#2B2836",
"ash-100": "#1E1C26"
}, },
/* fonts */ /* fonts */