buffer more visible, fix volume mute, rewrote entire overlay router system

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-11 22:09:28 +02:00
parent 4a2a8e89cc
commit 7b3452c535
19 changed files with 222 additions and 151 deletions

View File

@ -4,7 +4,7 @@ import {
} from "@headlessui/react";
import { Fragment, ReactNode } from "react";
type TransitionAnimations =
export type TransitionAnimations =
| "slide-down"
| "slide-full-left"
| "slide-full-right"

View File

@ -1,8 +1,4 @@
import { ReactNode, useEffect, useRef } from "react";
export function createOverlayAnchorEvent(id: string): string {
return `__overlay::anchor::${id}`;
}
import { ReactNode } from "react";
interface Props {
id: string;
@ -10,38 +6,5 @@ interface Props {
}
export function OverlayAnchor(props: Props) {
const ref = useRef<HTMLDivElement>(null);
const old = useRef<string | null>(null);
useEffect(() => {
if (!ref.current) return;
let cancelled = false;
function render() {
if (cancelled) return;
if (ref.current) {
const current = old.current;
const newer = ref.current.getBoundingClientRect();
const newerStr = JSON.stringify(newer);
if (current !== newerStr) {
old.current = newerStr;
const evtStr = createOverlayAnchorEvent(props.id);
(window as any)[evtStr] = newer;
const evObj = new CustomEvent(createOverlayAnchorEvent(props.id), {
detail: newer,
});
document.dispatchEvent(evObj);
}
}
window.requestAnimationFrame(render);
}
window.requestAnimationFrame(render);
return () => {
cancelled = true;
};
}, [props]);
return <div ref={ref}>{props.children}</div>;
return <div id={`__overlayRouter::${props.id}`}>{props.children}</div>;
}

View File

@ -1,29 +1,44 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { ReactNode, useEffect, useMemo } from "react";
import { Transition } from "@/components/Transition";
import { Transition, TransitionAnimations } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter";
import { useOverlayStore } from "@/stores/overlay/store";
interface Props {
id: string;
path: string;
children?: ReactNode;
className?: string;
height?: number;
width?: number;
height: number;
width: number;
}
export function OverlayPage(props: Props) {
const router = useInternalOverlayRouter(props.id);
const backwards = router.showBackwardsTransition(props.path);
const show = router.isCurrentPage(props.path);
const registerRoute = useOverlayStore((s) => s.registerRoute);
const path = useMemo(() => router.makePath(props.path), [props.path, router]);
const { isMobile } = useIsMobile();
useEffect(() => {
registerRoute({
id: path,
width: props.width,
height: props.height,
});
}, [props.height, props.width, path, registerRoute]);
const width = !isMobile ? `${props.width}px` : "100%";
let animation: TransitionAnimations = "none";
if (backwards === "yes" || backwards === "no")
animation = backwards === "yes" ? "slide-full-left" : "slide-full-right";
return (
<Transition
animation={backwards ? "slide-full-left" : "slide-full-right"}
animation={animation}
className="absolute inset-0"
durationClass="duration-[400ms]"
show={show}
@ -33,7 +48,6 @@ export function OverlayPage(props: Props) {
props.className,
"grid grid-rows-[auto,minmax(0,1fr)]",
])}
data-floating-page={show ? "true" : undefined}
style={{
height: props.height ? `${props.height}px` : undefined,
maxHeight: "70vh",

View File

@ -1,18 +1,90 @@
import { ReactNode } from "react";
import { a, easings, useSpring } from "@react-spring/web";
import { ReactNode, useEffect, useMemo, useRef } from "react";
import { OverlayAnchorPosition } from "@/components/overlays/positions/OverlayAnchorPosition";
import { OverlayMobilePosition } from "@/components/overlays/positions/OverlayMobilePosition";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter";
import { useOverlayStore } from "@/stores/overlay/store";
interface OverlayRouterProps {
children?: ReactNode;
id: string;
}
function RouterBase(props: { id: string; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const { isMobile } = useIsMobile();
const routes = useOverlayStore((s) => s.routes);
const router = useInternalOverlayRouter(props.id);
const routeMeta = useMemo(
() => routes[router.currentRoute ?? ""],
[routes, router]
);
const [dimensions, api] = useSpring(
() => ({
from: {
height: `${routeMeta?.height ?? 0}px`,
width: isMobile ? "100%" : `${routeMeta?.width ?? 0}px`,
},
config: {
easing: easings.linear,
},
}),
[]
);
const currentState = useRef<null | string>(null);
useEffect(() => {
const data = {
height: routeMeta?.height,
width: routeMeta?.width,
isMobile,
};
const dataStr = JSON.stringify(data);
if (dataStr !== currentState.current) {
const oldData = currentState.current
? JSON.parse(currentState.current)
: null;
currentState.current = dataStr;
if (data.isMobile) {
api.set({
width: "100%",
});
api({
height: `${routeMeta?.height ?? 0}px`,
});
} else if (oldData?.height === undefined && data.height !== undefined) {
api.set({
height: `${routeMeta?.height ?? 0}px`,
width: `${routeMeta?.width ?? 0}px`,
});
} else {
api({
height: `${routeMeta?.height ?? 0}px`,
width: `${routeMeta?.width ?? 0}px`,
});
}
}
}, [routeMeta?.height, routeMeta?.width, isMobile, api]);
return (
<a.div
ref={ref}
style={dimensions}
className="relative flex items-center justify-center overflow-hidden bg-red-500"
>
{props.children}
</a.div>
);
}
export function OverlayRouter(props: OverlayRouterProps) {
const { isMobile } = useIsMobile();
const content = props.children;
const content = <RouterBase id={props.id}>{props.children}</RouterBase>;
if (isMobile) return <OverlayMobilePosition>{content}</OverlayMobilePosition>;
return <OverlayAnchorPosition id={props.id}>{content}</OverlayAnchorPosition>;
return <OverlayAnchorPosition>{content}</OverlayAnchorPosition>;
}

View File

@ -1,78 +1,19 @@
import classNames from "classnames";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createOverlayAnchorEvent } from "@/components/overlays/OverlayAnchor";
import { ReactNode } from "react";
interface AnchorPositionProps {
children?: ReactNode;
id: string;
className?: string;
}
export function OverlayAnchorPosition(props: AnchorPositionProps) {
const ref = useRef<HTMLDivElement>(null);
const [left, setLeft] = useState<number>(0);
const [top, setTop] = useState<number>(0);
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const calculateAndSetCoords = useCallback(
(anchor: DOMRect, card: DOMRect) => {
const buttonCenter = anchor.left + anchor.width / 2;
const bottomReal = window.innerHeight - anchor.bottom;
setTop(
window.innerHeight - bottomReal - anchor.height - card.height - 30
);
setLeft(
Math.min(
buttonCenter - card.width / 2,
window.innerWidth - card.width - 30
)
);
},
[]
);
useEffect(() => {
if (!anchorRect || !cardRect) return;
calculateAndSetCoords(anchorRect, cardRect);
}, [anchorRect, calculateAndSetCoords, cardRect]);
useEffect(() => {
if (!ref.current) return;
function checkBox() {
const divRect = ref.current?.getBoundingClientRect();
setCardRect(divRect ?? null);
}
checkBox();
const observer = new ResizeObserver(checkBox);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
const evtStr = createOverlayAnchorEvent(props.id);
if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]);
function listen(ev: CustomEvent<DOMRect>) {
setAnchorRect(ev.detail);
}
document.addEventListener(evtStr, listen as any);
return () => {
document.removeEventListener(evtStr, listen as any);
};
}, [props.id]);
return (
<div
ref={ref}
style={{
transform: `translateX(${left}px) translateY(${top}px)`,
transform: `translateX(0px) translateY(0px)`,
}}
className={classNames([
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden",
"pointer-events-auto z-10 inline-block origin-top-left touch-none",
props.className,
])}
>

View File

@ -10,7 +10,7 @@ export function OverlayMobilePosition(props: MobilePositionProps) {
return (
<div
className={classNames([
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden",
"pointer-events-auto z-10 block origin-top-left touch-none overflow-hidden",
props.className,
])}
>

View File

@ -46,7 +46,7 @@ export function ProgressBar() {
>
{/* Pre-loaded content bar */}
<div
className="absolute top-0 left-0 h-full rounded-full bg-video-progress-preloaded bg-opacity-25 flex justify-end items-center"
className="absolute top-0 left-0 h-full rounded-full bg-video-progress-preloaded bg-opacity-50 flex justify-end items-center"
style={{
width: `${(buffered / duration) * 100}%`,
}}

View File

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useRef } from "react";
import { Icon, Icons } from "@/components/Icon";
import {
@ -24,6 +24,7 @@ export function Volume(props: Props) {
const commitVolume = useCallback(
(percentage) => {
console.log("setting", percentage);
setVolume(percentage);
},
[setVolume]

View File

@ -9,12 +9,12 @@ export function BottomControls(props: {
<Transition
animation="fade"
show={props.show}
className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] 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
animation="slide-up"
show={props.show}
className="pointer-events-auto px-4 pb-3 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>

View File

@ -79,11 +79,13 @@ export function Container(props: PlayerProps) {
}, []);
return (
<BaseContainer>
<div className="relative">
<VideoContainer />
<VideoClickTarget />
<HeadUpdater />
{props.children}
</BaseContainer>
<BaseContainer>
<VideoClickTarget />
<HeadUpdater />
{props.children}
</BaseContainer>
</div>
);
}

View File

@ -14,7 +14,7 @@ export function TopControls(props: {
<Transition
animation="slide-down"
show={props.show}
className="pointer-events-auto px-4 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 text-white"
>
{props.children}
</Transition>

View File

@ -26,6 +26,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
function setSource() {
if (!videoElement || !source) return;
videoElement.src = source.url;
videoElement.addEventListener("play", () => {
emit("play", undefined);
emit("loading", false);
@ -35,7 +36,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
videoElement.addEventListener("canplay", () => emit("loading", false));
videoElement.addEventListener("waiting", () => emit("loading", true));
videoElement.addEventListener("volumechange", () =>
emit("volumechange", videoElement?.volume ?? 0)
emit("volumechange", videoElement?.muted ? 0 : videoElement?.volume ?? 0)
);
videoElement.addEventListener("timeupdate", () =>
emit("time", videoElement?.currentTime ?? 0)
@ -118,9 +119,16 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
// clamp time between 0 and 1
let volume = Math.min(v, 1);
volume = Math.max(0, volume);
videoElement.muted = volume === 0; // Muted attribute is always supported
// update state
if (await canChangeVolume()) videoElement.volume = volume;
const isChangeable = await canChangeVolume();
if (isChangeable) {
videoElement.volume = volume;
} else {
// For browsers where it can't be changed
emit("volumechange", volume === 0 ? 0 : 1);
}
},
toggleFullscreen() {
if (isFullscreen) {

View File

@ -37,7 +37,14 @@ function VideoElement() {
}
}, [display, videoEl]);
return <video className="w-full h-screen bg-black" autoPlay ref={videoEl} />;
return (
<video
className="absolute inset-0 w-full h-screen bg-black"
autoPlay
playsInline
ref={videoEl}
/>
);
}
export function VideoContainer() {

View File

@ -16,8 +16,13 @@ export function useInternalOverlayRouter(id: string) {
const [route, setRoute] = useQueryParam("r");
const transition = useOverlayStore((s) => s.transition);
const setTransition = useOverlayStore((s) => s.setTransition);
const setAnchorPoint = useOverlayStore((s) => s.setAnchorPoint);
const routerActive = !!route && route.startsWith(`/${id}`);
function makePath(path: string) {
return joinPath(splitPath(path, id));
}
function navigate(path: string) {
const oldRoute = route;
const newRoute = joinPath(splitPath(path, id));
@ -29,17 +34,17 @@ export function useInternalOverlayRouter(id: string) {
}
function showBackwardsTransition(path: string) {
if (!transition) return false;
if (!transition) return "none";
const current = joinPath(splitPath(path, id));
if (current === transition.to && transition.from.startsWith(transition.to))
return true;
return "yes";
if (
current === transition.from &&
transition.to.startsWith(transition.from)
)
return true;
return false;
return "yes";
return "no";
}
function isCurrentPage(path: string) {
@ -56,9 +61,22 @@ export function useInternalOverlayRouter(id: string) {
}, [setRoute, setTransition]);
const open = useCallback(() => {
const anchor = document.getElementById(`__overlayRouter::${id}`);
if (anchor) {
const rect = anchor.getBoundingClientRect();
setAnchorPoint({
h: rect.height,
w: rect.width,
x: rect.x,
y: rect.y,
});
} else {
setAnchorPoint(null);
}
setTransition(null);
setRoute(`/${id}`);
}, [id, setRoute, setTransition]);
}, [id, setRoute, setTransition, setAnchorPoint]);
return {
showBackwardsTransition,
@ -67,6 +85,8 @@ export function useInternalOverlayRouter(id: string) {
navigate,
close,
open,
makePath,
currentRoute: route,
};
}

View File

@ -19,19 +19,11 @@ export function PlayerView() {
const meta = useMemo<PlayerMeta>(
() => ({
type: "show",
title: "House",
tmdbId: "1408",
releaseYear: 2004,
episode: {
number: 1,
title: "Pilot",
tmdbId: "63738",
},
season: {
number: 1,
tmdbId: "3674",
title: "Season 1",
},
title: "Normal People",
releaseYear: 2020,
tmdbId: "89905",
episode: { number: 12, tmdbId: "2207576", title: "Episode 12" },
season: { number: 1, tmdbId: "125160", title: "Season 1" },
}),
[]
);
@ -48,10 +40,20 @@ export function PlayerView() {
media={scrapeMedia}
onGetStream={(out) => {
if (out?.stream.type !== "file") return;
const qualities = Object.keys(
out.stream.qualities
console.log(out.stream.qualities);
const qualities = Object.keys(out.stream.qualities).sort(
(a, b) => Number(b) - Number(a)
) as (keyof typeof out.stream.qualities)[];
const file = out.stream.qualities[qualities[0]];
let file;
for (const quality of qualities) {
if (out.stream.qualities[quality]?.url) {
console.log(quality);
file = out.stream.qualities[quality];
break;
}
}
if (!file) return;
playMedia({

View File

@ -1,3 +1,5 @@
import { useEffect, useRef } from "react";
import { OverlayAnchor } from "@/components/overlays/OverlayAnchor";
import { Overlay, OverlayDisplay } from "@/components/overlays/OverlayDisplay";
import { OverlayPage } from "@/components/overlays/OverlayPage";
@ -20,7 +22,7 @@ export default function TestView() {
Open
</button>
<OverlayAnchor id={router.id}>
<div className="h-20 w-20 mt-64 bg-white" />
<div className="h-20 w-20 hover:w-24 mt-[50rem] bg-white" />
</OverlayAnchor>
<Overlay id={router.id}>
<OverlayRouter id={router.id}>
@ -45,7 +47,7 @@ export default function TestView() {
</button>
</div>
</OverlayPage>
<OverlayPage id={router.id} path="/one">
<OverlayPage id={router.id} path="/one" width={300} height={300}>
<div className="bg-blue-900 p-4">
<p>ONE</p>
<button
@ -58,7 +60,7 @@ export default function TestView() {
</button>
</div>
</OverlayPage>
<OverlayPage id={router.id} path="/two">
<OverlayPage id={router.id} path="/two" width={200} height={200}>
<div className="bg-blue-900 p-4">
<p>TWO</p>
<button

View File

@ -65,6 +65,16 @@ body[data-no-select] {
height: 60vh;
}
.h-screen {
height: 100vh;
height: 100dvh;
}
.min-h-screen {
min-height: 100vh;
min-height: 100dvh;
}
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
:root {

View File

@ -6,18 +6,47 @@ export interface OverlayTransition {
to: string;
}
export interface OverlayRoute {
id: string;
height: number;
width: number;
}
export interface ActiveAnchorPoint {
x: number;
y: number;
w: number;
h: number;
}
interface OverlayStore {
transition: null | OverlayTransition;
routes: Record<string, OverlayRoute>;
anchorPoint: ActiveAnchorPoint | null;
setTransition(newTrans: OverlayTransition | null): void;
registerRoute(route: OverlayRoute): void;
setAnchorPoint(point: ActiveAnchorPoint | null): void;
}
export const useOverlayStore = create(
immer<OverlayStore>((set) => ({
transition: null,
routes: {},
anchorPoint: null,
setTransition(newTrans) {
set((s) => {
s.transition = newTrans;
});
},
registerRoute(route) {
set((s) => {
s.routes[route.id] = route;
});
},
setAnchorPoint(point) {
set((s) => {
s.anchorPoint = point;
});
},
}))
);

View File

@ -24,7 +24,7 @@ export const createPlayingSlice: MakeSlice<PlayingSlice> = (set) => ({
isDragSeeking: false,
isFirstLoading: true,
hasPlayedOnce: false,
volume: 0,
volume: 1,
playbackSpeed: 1,
},
play() {