39 changed files with 1224 additions and 430 deletions
@ -0,0 +1,47 @@ |
|||||||
|
import { ReactNode, useEffect, useRef } from "react"; |
||||||
|
|
||||||
|
export function createFloatingAnchorEvent(id: string): string { |
||||||
|
return `__floating::anchor::${id}`; |
||||||
|
} |
||||||
|
|
||||||
|
interface Props { |
||||||
|
id: string; |
||||||
|
children?: ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
export function FloatingAnchor(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 = createFloatingAnchorEvent(props.id); |
||||||
|
(window as any)[evtStr] = newer; |
||||||
|
const evObj = new CustomEvent(createFloatingAnchorEvent(props.id), { |
||||||
|
detail: newer, |
||||||
|
}); |
||||||
|
document.dispatchEvent(evObj); |
||||||
|
} |
||||||
|
} |
||||||
|
window.requestAnimationFrame(render); |
||||||
|
} |
||||||
|
|
||||||
|
window.requestAnimationFrame(render); |
||||||
|
return () => { |
||||||
|
cancelled = true; |
||||||
|
}; |
||||||
|
}, [props]); |
||||||
|
|
||||||
|
return <div ref={ref}>{props.children}</div>; |
||||||
|
} |
@ -0,0 +1,189 @@ |
|||||||
|
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition"; |
||||||
|
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition"; |
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||||
|
import { PopoutSection } from "@/video/components/popouts/PopoutUtils"; |
||||||
|
import { useSpringValue, animated, easings } from "@react-spring/web"; |
||||||
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; |
||||||
|
import { Icon, Icons } from "../Icon"; |
||||||
|
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle"; |
||||||
|
|
||||||
|
interface FloatingCardProps { |
||||||
|
children?: ReactNode; |
||||||
|
onClose?: () => void; |
||||||
|
for: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface RootFloatingCardProps extends FloatingCardProps { |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
function CardBase(props: { children: ReactNode }) { |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
const { isMobile } = useIsMobile(); |
||||||
|
const height = useSpringValue(0, { |
||||||
|
config: { easing: easings.easeInOutSine, duration: 300 }, |
||||||
|
}); |
||||||
|
const width = useSpringValue(0, { |
||||||
|
config: { easing: easings.easeInOutSine, duration: 300 }, |
||||||
|
}); |
||||||
|
const [pages, setPages] = useState<NodeListOf<Element> | null>(null); |
||||||
|
|
||||||
|
const getNewHeight = useCallback( |
||||||
|
(updateList = true) => { |
||||||
|
if (!ref.current) return; |
||||||
|
const children = ref.current.querySelectorAll( |
||||||
|
":scope *[data-floating-page='true']" |
||||||
|
); |
||||||
|
if (updateList) setPages(children); |
||||||
|
if (children.length === 0) { |
||||||
|
height.start(0); |
||||||
|
width.start(0); |
||||||
|
return; |
||||||
|
} |
||||||
|
const lastChild = children[children.length - 1]; |
||||||
|
const rect = lastChild.getBoundingClientRect(); |
||||||
|
const rectHeight = lastChild.scrollHeight; |
||||||
|
if (height.get() === 0) { |
||||||
|
height.set(rectHeight); |
||||||
|
width.set(rect.width); |
||||||
|
} else { |
||||||
|
height.start(rectHeight); |
||||||
|
width.start(rect.width); |
||||||
|
} |
||||||
|
}, |
||||||
|
[height, width] |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!ref.current) return; |
||||||
|
getNewHeight(); |
||||||
|
const observer = new MutationObserver(() => { |
||||||
|
getNewHeight(); |
||||||
|
}); |
||||||
|
observer.observe(ref.current, { |
||||||
|
attributes: false, |
||||||
|
childList: true, |
||||||
|
subtree: false, |
||||||
|
}); |
||||||
|
return () => { |
||||||
|
observer.disconnect(); |
||||||
|
}; |
||||||
|
}, [getNewHeight]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const observer = new ResizeObserver(() => { |
||||||
|
getNewHeight(false); |
||||||
|
}); |
||||||
|
pages?.forEach((el) => observer.observe(el)); |
||||||
|
return () => { |
||||||
|
observer.disconnect(); |
||||||
|
}; |
||||||
|
}, [pages, getNewHeight]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<animated.div |
||||||
|
ref={ref} |
||||||
|
style={{ |
||||||
|
height, |
||||||
|
width: isMobile ? "100%" : width, |
||||||
|
}} |
||||||
|
className="relative flex items-center justify-center overflow-hidden" |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</animated.div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function FloatingCard(props: RootFloatingCardProps) { |
||||||
|
const { isMobile } = useIsMobile(); |
||||||
|
const content = <CardBase>{props.children}</CardBase>; |
||||||
|
|
||||||
|
if (isMobile) |
||||||
|
return ( |
||||||
|
<FloatingCardMobilePosition |
||||||
|
className={props.className} |
||||||
|
onClose={props.onClose} |
||||||
|
> |
||||||
|
{content} |
||||||
|
</FloatingCardMobilePosition> |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<FloatingCardAnchorPosition id={props.for} className={props.className}> |
||||||
|
{content} |
||||||
|
</FloatingCardAnchorPosition> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function PopoutFloatingCard(props: FloatingCardProps) { |
||||||
|
return ( |
||||||
|
<FloatingCard |
||||||
|
className="overflow-hidden rounded-md bg-ash-300" |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export const FloatingCardView = { |
||||||
|
Header(props: { |
||||||
|
title: string; |
||||||
|
description: string; |
||||||
|
close?: boolean; |
||||||
|
goBack: () => any; |
||||||
|
action?: React.ReactNode; |
||||||
|
backText?: string; |
||||||
|
}) { |
||||||
|
let left = ( |
||||||
|
<div |
||||||
|
onClick={props.goBack} |
||||||
|
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white" |
||||||
|
> |
||||||
|
<Icon icon={Icons.ARROW_LEFT} /> |
||||||
|
<span>{props.backText || "Go back"}</span> |
||||||
|
</div> |
||||||
|
); |
||||||
|
if (props.close) |
||||||
|
left = ( |
||||||
|
<div |
||||||
|
onClick={props.goBack} |
||||||
|
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white" |
||||||
|
> |
||||||
|
<Icon icon={Icons.X} /> |
||||||
|
<span>Close</span> |
||||||
|
</div> |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex flex-col bg-[#1C161B]"> |
||||||
|
<FloatingDragHandle /> |
||||||
|
<PopoutSection> |
||||||
|
<div className="flex justify-between"> |
||||||
|
<div>{left}</div> |
||||||
|
<div>{props.action ?? null}</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 className="mt-8 mb-2 text-3xl font-bold text-white"> |
||||||
|
{props.title} |
||||||
|
</h2> |
||||||
|
<p>{props.description}</p> |
||||||
|
</PopoutSection> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}, |
||||||
|
Content(props: { children: React.ReactNode; noSection?: boolean }) { |
||||||
|
return ( |
||||||
|
<div className="grid h-full grid-rows-[1fr]"> |
||||||
|
{props.noSection ? ( |
||||||
|
<div className="relative h-full overflow-y-auto bg-ash-300"> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<PopoutSection className="relative h-full overflow-y-auto bg-ash-300"> |
||||||
|
{props.children} |
||||||
|
</PopoutSection> |
||||||
|
)} |
||||||
|
<MobilePopoutSpacer /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}, |
||||||
|
}; |
@ -0,0 +1,56 @@ |
|||||||
|
import { Transition } from "@/components/Transition"; |
||||||
|
import React, { ReactNode, useCallback, useEffect, useRef } from "react"; |
||||||
|
import { createPortal } from "react-dom"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
children?: ReactNode; |
||||||
|
onClose?: () => void; |
||||||
|
show?: boolean; |
||||||
|
darken?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export function FloatingContainer(props: Props) { |
||||||
|
const target = useRef<Element | null>(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
function listen(e: MouseEvent) { |
||||||
|
target.current = e.target as Element; |
||||||
|
} |
||||||
|
document.addEventListener("mousedown", listen); |
||||||
|
return () => { |
||||||
|
document.removeEventListener("mousedown", listen); |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
const click = useCallback( |
||||||
|
(e: React.MouseEvent) => { |
||||||
|
const startedTarget = target.current; |
||||||
|
target.current = null; |
||||||
|
if (e.currentTarget !== e.target) return; |
||||||
|
if (!startedTarget) return; |
||||||
|
if (!startedTarget.isEqualNode(e.currentTarget as Element)) return; |
||||||
|
if (props.onClose) props.onClose(); |
||||||
|
}, |
||||||
|
[props] |
||||||
|
); |
||||||
|
|
||||||
|
return createPortal( |
||||||
|
<Transition show={props.show} animation="none"> |
||||||
|
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none"> |
||||||
|
<Transition animation="fade" isChild> |
||||||
|
<div |
||||||
|
onClick={click} |
||||||
|
className={[ |
||||||
|
"absolute inset-0", |
||||||
|
props.darken ? "bg-black opacity-90" : "", |
||||||
|
].join(" ")} |
||||||
|
/> |
||||||
|
</Transition> |
||||||
|
<Transition animation="slide-up" className="h-0" isChild> |
||||||
|
{props.children} |
||||||
|
</Transition> |
||||||
|
</div> |
||||||
|
</Transition>, |
||||||
|
document.body |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||||
|
|
||||||
|
export function FloatingDragHandle() { |
||||||
|
const { isMobile } = useIsMobile(); |
||||||
|
|
||||||
|
if (!isMobile) return null; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="relative z-50 mx-auto my-3 -mb-3 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" /> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function MobilePopoutSpacer() { |
||||||
|
const { isMobile } = useIsMobile(); |
||||||
|
|
||||||
|
if (!isMobile) return null; |
||||||
|
|
||||||
|
return <div className="h-[200px]" />; |
||||||
|
} |
@ -0,0 +1,39 @@ |
|||||||
|
import { Transition } from "@/components/Transition"; |
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||||
|
import { ReactNode } from "react"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
children?: ReactNode; |
||||||
|
show?: boolean; |
||||||
|
className?: string; |
||||||
|
height?: number; |
||||||
|
width?: number; |
||||||
|
active?: boolean; // true if a child view is loaded
|
||||||
|
} |
||||||
|
|
||||||
|
export function FloatingView(props: Props) { |
||||||
|
const { isMobile } = useIsMobile(); |
||||||
|
const width = !isMobile ? `${props.width}px` : "100%"; |
||||||
|
return ( |
||||||
|
<Transition |
||||||
|
animation={props.active ? "slide-full-left" : "slide-full-right"} |
||||||
|
className="absolute inset-0" |
||||||
|
durationClass="duration-[400ms]" |
||||||
|
show={props.show} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={[ |
||||||
|
props.className ?? "", |
||||||
|
"grid grid-rows-[auto,minmax(0,1fr)]", |
||||||
|
].join(" ")} |
||||||
|
data-floating-page={props.show ? "true" : undefined} |
||||||
|
style={{ |
||||||
|
height: props.height ? `${props.height}px` : undefined, |
||||||
|
width: props.width ? width : undefined, |
||||||
|
}} |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
</Transition> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,80 @@ |
|||||||
|
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor"; |
||||||
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; |
||||||
|
|
||||||
|
interface AnchorPositionProps { |
||||||
|
children?: ReactNode; |
||||||
|
id: string; |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function FloatingCardAnchorPosition(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 = createFloatingAnchorEvent(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)`, |
||||||
|
}} |
||||||
|
className={[ |
||||||
|
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden", |
||||||
|
props.className ?? "", |
||||||
|
].join(" ")} |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,91 @@ |
|||||||
|
import { useSpring, animated, config } from "@react-spring/web"; |
||||||
|
import { useDrag } from "@use-gesture/react"; |
||||||
|
import { ReactNode, useEffect, useRef, useState } from "react"; |
||||||
|
|
||||||
|
interface MobilePositionProps { |
||||||
|
children?: ReactNode; |
||||||
|
className?: string; |
||||||
|
onClose?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
export function FloatingCardMobilePosition(props: MobilePositionProps) { |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
const closing = useRef<boolean>(false); |
||||||
|
const [cardRect, setCardRect] = useState<DOMRect | null>(null); |
||||||
|
const [{ y }, api] = useSpring(() => ({ |
||||||
|
y: 0, |
||||||
|
onRest() { |
||||||
|
if (!closing.current) return; |
||||||
|
if (props.onClose) props.onClose(); |
||||||
|
}, |
||||||
|
})); |
||||||
|
|
||||||
|
const bind = useDrag( |
||||||
|
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => { |
||||||
|
if (closing.current) return; |
||||||
|
const height = cardRect?.height ?? 0; |
||||||
|
if (last) { |
||||||
|
// if past half height downwards
|
||||||
|
// OR Y velocity is past 0.5 AND going down AND 20 pixels below start position
|
||||||
|
if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) { |
||||||
|
api.start({ |
||||||
|
y: height * 1.2, |
||||||
|
immediate: false, |
||||||
|
config: { ...config.wobbly, velocity: vy, clamp: true }, |
||||||
|
}); |
||||||
|
closing.current = true; |
||||||
|
} else { |
||||||
|
api.start({ |
||||||
|
y: 0, |
||||||
|
immediate: false, |
||||||
|
config: config.wobbly, |
||||||
|
}); |
||||||
|
} |
||||||
|
} else { |
||||||
|
api.start({ y: my, immediate: true }); |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
from: () => [0, y.get()], |
||||||
|
filterTaps: true, |
||||||
|
bounds: { top: 0 }, |
||||||
|
rubberband: true, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
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(); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none" |
||||||
|
style={{ |
||||||
|
transform: `translateY(${ |
||||||
|
window.innerHeight - (cardRect?.height ?? 0) + 200 |
||||||
|
}px)`,
|
||||||
|
}} |
||||||
|
> |
||||||
|
<animated.div |
||||||
|
ref={ref} |
||||||
|
className={[props.className ?? "", "touch-none"].join(" ")} |
||||||
|
style={{ |
||||||
|
y, |
||||||
|
}} |
||||||
|
{...bind()} |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</animated.div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
import { useLayoutEffect, useState } from "react"; |
||||||
|
|
||||||
|
export function useFloatingRouter(initial = "/") { |
||||||
|
const [route, setRoute] = useState<string[]>( |
||||||
|
initial.split("/").filter((v) => v.length > 0) |
||||||
|
); |
||||||
|
const [previousRoute, setPreviousRoute] = useState(route); |
||||||
|
const currentPage = route[route.length - 1] ?? "/"; |
||||||
|
|
||||||
|
useLayoutEffect(() => { |
||||||
|
if (previousRoute.length === route.length) return; |
||||||
|
// when navigating backwards, we delay the updating by a bit so transitions can be applied correctly
|
||||||
|
setTimeout(() => { |
||||||
|
setPreviousRoute(route); |
||||||
|
}, 20); |
||||||
|
}, [route, previousRoute]); |
||||||
|
|
||||||
|
function navigate(path: string) { |
||||||
|
const newRoute = path.split("/").filter((v) => v.length > 0); |
||||||
|
if (newRoute.length > previousRoute.length) setPreviousRoute(newRoute); |
||||||
|
setRoute(newRoute); |
||||||
|
} |
||||||
|
|
||||||
|
function isActive(page: string) { |
||||||
|
if (page === "/") return true; |
||||||
|
const index = previousRoute.indexOf(page); |
||||||
|
if (index === -1) return false; // not active
|
||||||
|
if (index === previousRoute.length - 1) return false; // active but latest route so shouldnt be counted as active
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
function isCurrentPage(page: string) { |
||||||
|
return page === currentPage; |
||||||
|
} |
||||||
|
|
||||||
|
function isLoaded(page: string) { |
||||||
|
if (page === "/") return true; |
||||||
|
return route.includes(page); |
||||||
|
} |
||||||
|
|
||||||
|
function pageProps(page: string) { |
||||||
|
return { |
||||||
|
show: isCurrentPage(page), |
||||||
|
active: isActive(page), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function reset() { |
||||||
|
navigate("/"); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
navigate, |
||||||
|
reset, |
||||||
|
isLoaded, |
||||||
|
isCurrentPage, |
||||||
|
pageProps, |
||||||
|
isActive, |
||||||
|
}; |
||||||
|
} |
@ -1,34 +0,0 @@ |
|||||||
import { Icons } from "@/components/Icon"; |
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
|
||||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; |
|
||||||
import { useControls } from "@/video/state/logic/controls"; |
|
||||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; |
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile"; |
|
||||||
import { useTranslation } from "react-i18next"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function CaptionsSelectionAction(props: Props) { |
|
||||||
const { t } = useTranslation(); |
|
||||||
const descriptor = useVideoPlayerDescriptor(); |
|
||||||
const controls = useControls(descriptor); |
|
||||||
const { isMobile } = useIsMobile(); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={props.className}> |
|
||||||
<div className="relative"> |
|
||||||
<PopoutAnchor for="captions"> |
|
||||||
<VideoPlayerIconButton |
|
||||||
className={props.className} |
|
||||||
text={isMobile ? (t("videoPlayer.buttons.captions") as string) : ""} |
|
||||||
wide={isMobile} |
|
||||||
onClick={() => controls.openPopout("captions")} |
|
||||||
icon={Icons.CAPTIONS} |
|
||||||
/> |
|
||||||
</PopoutAnchor> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -0,0 +1,12 @@ |
|||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useMeta } from "@/video/state/logic/meta"; |
||||||
|
import { MWMediaType } from "@/backend/metadata/types"; |
||||||
|
|
||||||
|
export function DividerAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const meta = useMeta(descriptor); |
||||||
|
|
||||||
|
if (meta?.meta.meta.type !== MWMediaType.SERIES) return null; |
||||||
|
|
||||||
|
return <div className="mx-2 h-6 w-px bg-white opacity-50" />; |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { Icons } from "@/components/Icon"; |
||||||
|
import { useTranslation } from "react-i18next"; |
||||||
|
import { PopoutListAction } from "../../popouts/PopoutUtils"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onClick: () => any; |
||||||
|
} |
||||||
|
|
||||||
|
export function CaptionsSelectionAction(props: Props) { |
||||||
|
const { t } = useTranslation(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<PopoutListAction icon={Icons.CAPTIONS} onClick={props.onClick}> |
||||||
|
{t("videoPlayer.buttons.captions")} |
||||||
|
</PopoutListAction> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
import { useTranslation } from "react-i18next"; |
||||||
|
import { PopoutListAction } from "../../popouts/PopoutUtils"; |
||||||
|
import { QualityDisplayAction } from "./QualityDisplayAction"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onClick?: () => any; |
||||||
|
} |
||||||
|
|
||||||
|
export function SourceSelectionAction(props: Props) { |
||||||
|
const { t } = useTranslation(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<PopoutListAction |
||||||
|
icon={Icons.CLAPPER_BOARD} |
||||||
|
onClick={props.onClick} |
||||||
|
right={<QualityDisplayAction />} |
||||||
|
noChevron |
||||||
|
> |
||||||
|
{t("videoPlayer.buttons.source")} |
||||||
|
</PopoutListAction> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,29 @@ |
|||||||
|
import { FloatingCardView } from "@/components/popout/FloatingCard"; |
||||||
|
import { FloatingDragHandle } from "@/components/popout/FloatingDragHandle"; |
||||||
|
import { FloatingView } from "@/components/popout/FloatingView"; |
||||||
|
import { useFloatingRouter } from "@/hooks/useFloatingRouter"; |
||||||
|
import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction"; |
||||||
|
import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction"; |
||||||
|
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction"; |
||||||
|
import { CaptionSelectionPopout } from "./CaptionSelectionPopout"; |
||||||
|
import { SourceSelectionPopout } from "./SourceSelectionPopout"; |
||||||
|
|
||||||
|
export function SettingsPopout() { |
||||||
|
const floatingRouter = useFloatingRouter(); |
||||||
|
const { pageProps, navigate } = floatingRouter; |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<FloatingView {...pageProps("/")} width={320}> |
||||||
|
<FloatingDragHandle /> |
||||||
|
<FloatingCardView.Content> |
||||||
|
<DownloadAction /> |
||||||
|
<SourceSelectionAction onClick={() => navigate("/source")} /> |
||||||
|
<CaptionsSelectionAction onClick={() => navigate("/captions")} /> |
||||||
|
</FloatingCardView.Content> |
||||||
|
</FloatingView> |
||||||
|
<SourceSelectionPopout router={floatingRouter} prefix="source" /> |
||||||
|
<CaptionSelectionPopout router={floatingRouter} prefix="captions" /> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
// simple empty view, perfect for putting in tests
|
||||||
|
export function TestView() { |
||||||
|
return <div />; |
||||||
|
} |
Loading…
Reference in new issue