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.gstatic.com" crossorigin />
<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"
/>

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);
}
}
.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
);
// TODO make dragging update timer
useEffect(() => {
if (dragRef.current === dragging) return;
dragRef.current = dragging;
controls.setSeeking(dragging);
}, [dragRef, dragging, controls]);
useEffect(() => {
if (dragging) {
controls.setDraggingTime(videoTime.duration * (dragPercentage / 100));
}
}, [videoTime, dragging, dragPercentage, controls]);
let watchProgress = makePercentageString(
makePercentage((videoTime.time / videoTime.duration) * 100)
);

View File

@ -1,4 +1,5 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useProgress } from "@/video/state/logic/progress";
function durationExceedsHour(secs: number): boolean {
@ -35,9 +36,13 @@ interface Props {
export function TimeAction(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const videoTime = useProgress(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
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);
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 { useMeta } from "@/video/state/logic/meta";
import { useControls } from "@/video/state/logic/controls";
import { useWatchedContext } from "@/state/watched";
import { ProgressRing } from "@/components/layout/ProgressRing";
function PopupSection(props: {
children?: React.ReactNode;
className?: string;
}) {
return (
<div className={["p-4", props.className || ""].join(" ")}>
<div className={["p-5", props.className || ""].join(" ")}>
{props.children}
</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() {
const params = useParams<{
media: string;
@ -90,43 +142,71 @@ export function EpisodeSelectionPopout() {
setCurrentVisibleSeason({ seasonId: id });
};
if (isPickingSeason)
return (
<>
<PopupSection className="flex items-center space-x-3 border-b border-denim-500 font-bold text-white">
Pick a season
</PopupSection>
<PopupSection className="overflow-y-auto">
<div className="space-y-1">
{currentSeasonInfo
? 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>
</>
);
const { watched } = useWatchedContext();
const titlePositionClass = useMemo(() => {
const offset = isPickingSeason ? "left-0" : "left-10";
return [
"absolute w-full transition-[left,opacity] duration-200",
offset,
].join(" ");
}, [isPickingSeason]);
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">
<div className="relative flex items-center">
<button
className="-m-1.5 rounded p-1.5 hover:bg-denim-600"
className={[
"-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>{currentSeasonInfo?.title || ""}</span>
<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>
<PopupSection className="h-full overflow-y-auto">
<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(" ")}
>
{currentSeasonInfo
? meta?.seasons?.map?.((season) => (
<PopoutListEntry
key={season.id}
active={meta?.episode?.seasonId === season.id}
onClick={() => setSeason(season.id)}
isOnDarkBackground
>
{season.title}
</PopoutListEntry>
))
: "No season"}
</PopupSection>
<PopupSection className="relative h-full overflow-y-auto">
{loading ? (
<div className="flex h-full w-full items-center justify-center">
<Loading />
@ -148,22 +228,27 @@ export function EpisodeSelectionPopout() {
<div className="space-y-1">
{currentSeasonEpisodes && currentSeasonInfo
? currentSeasonEpisodes.map((e) => (
<div
className={[
"text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600",
meta?.episode?.episodeId === e.id &&
"outline outline-2 outline-denim-700",
].join(" ")}
onClick={() => setCurrent(currentSeasonInfo.id, e.id)}
<PopoutListEntry
key={e.id}
active={e.id === meta?.episode?.episodeId}
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.number}. {e.title}
</div>
E{e.number} - {e.title}
</PopoutListEntry>
))
: "No episodes"}
</div>
)}
</PopupSection>
</div>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { Icon, Icons } from "@/components/Icon";
import { ProgressRing } from "@/components/layout/ProgressRing";
import { ScrapeEventLog } from "@/hooks/useScrape";
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="mr-2 flex w-[18px] items-center justify-center">
{!event.errored ? (
<svg className="h-[18px] w-[18px] -rotate-90" viewBox="0 0 100 100">
<circle
className="fill-transparent stroke-denim-700 stroke-[15] opacity-25"
r="40"
cx="50"
cy="50"
<ProgressRing
className="h-[18px] w-[18px] text-bink-700"
percentage={event.percentage}
radius={40}
/>
<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" />
)}

View File

@ -18,7 +18,13 @@ module.exports = {
"denim-400": "#2B263D",
"denim-500": "#38334A",
"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 */