mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-29 16:07:40 +01:00
episode select popout styling, popout router sync & dragging to update time action
This commit is contained in:
parent
3b4e9ce2ca
commit
2a3c93c24f
14 changed files with 318 additions and 100 deletions
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
39
src/components/layout/ProgressRing.tsx
Normal file
39
src/components/layout/ProgressRing.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
|
|
70
src/video/components/hooks/useSyncPopouts.ts
Normal file
70
src/video/components/hooks/useSyncPopouts.ts
Normal 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]);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -42,6 +42,7 @@ export type VideoPlayerState = {
|
|||
time: number;
|
||||
duration: number;
|
||||
buffered: number;
|
||||
draggingTime: number;
|
||||
};
|
||||
|
||||
// meta data of video
|
||||
|
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
@ -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 */
|
||||
|
|
Loading…
Reference in a new issue