bottom control layout + fullscreen + hovering

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-01 17:34:37 +02:00
parent a813efe5ba
commit 7e182a4b7a
18 changed files with 288 additions and 68 deletions

View File

@ -1,2 +1,3 @@
export * from "./atoms";
export * from "./base/Container";
export * from "./base/BottomControls";

View File

@ -0,0 +1,15 @@
import { Icons } from "@/components/Icon";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { usePlayerStore } from "@/stores/player/store";
export function Fullscreen() {
const { isFullscreen } = usePlayerStore((s) => s.interface);
const display = usePlayerStore((s) => s.display);
return (
<VideoPlayerButton
onClick={() => display?.toggleFullscreen()}
icon={isFullscreen ? Icons.COMPRESS : Icons.EXPAND}
/>
);
}

View File

@ -1,3 +1,5 @@
import { Icons } from "@/components/Icon";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { usePlayerStore } from "@/stores/player/store";
export function Pause() {
@ -10,8 +12,9 @@ export function Pause() {
};
return (
<button type="button" onClick={toggle}>
play/pause
</button>
<VideoPlayerButton
onClick={toggle}
icon={isPaused ? Icons.PLAY : Icons.PAUSE}
/>
);
}

View File

@ -1 +1,2 @@
export * from "./pause";
export * from "./Pause";
export * from "./Fullscreen";

View File

@ -0,0 +1,18 @@
import { Transition } from "@/components/Transition";
export function BottomControls(props: {
show: boolean;
children: React.ReactNode;
}) {
return (
<div className="w-full absolute bottom-0 flex flex-col pt-32 bg-gradient-to-t from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)]">
<Transition
animation="slide-up"
show={props.show}
className="pointer-events-auto px-4 pb-2 flex justify-end"
>
{props.children}
</Transition>
</div>
);
}

View File

@ -1,16 +1,90 @@
import { ReactNode } from "react";
import { ReactNode, RefObject, useEffect, useRef } from "react";
import { VideoContainer } from "@/components/player/internals/VideoContainer";
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
export interface PlayerProps {
children?: ReactNode;
onLoad?: () => void;
}
export function Container(props: PlayerProps) {
function useHovering(containerEl: RefObject<HTMLDivElement>) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateInterfaceHovering = usePlayerStore(
(s) => s.updateInterfaceHovering
);
const hovering = usePlayerStore((s) => s.interface.hovering);
useEffect(() => {
if (!containerEl.current) return;
const el = containerEl.current;
function pointerMove(e: PointerEvent) {
if (e.pointerType !== "mouse") return;
updateInterfaceHovering(PlayerHoverState.MOUSE_HOVER);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
timeoutRef.current = null;
}, 3000);
}
function pointerLeave(e: PointerEvent) {
if (e.pointerType !== "mouse") return;
updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}
function pointerUp(e: PointerEvent) {
if (e.pointerType === "mouse") return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (hovering !== PlayerHoverState.MOBILE_TAPPED)
updateInterfaceHovering(PlayerHoverState.MOBILE_TAPPED);
else updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
}
el.addEventListener("pointermove", pointerMove);
el.addEventListener("pointerleave", pointerLeave);
el.addEventListener("pointerup", pointerUp);
return () => {
el.removeEventListener("pointermove", pointerMove);
el.removeEventListener("pointerleave", pointerLeave);
el.removeEventListener("pointerup", pointerUp);
};
}, [containerEl, hovering, updateInterfaceHovering]);
}
function BaseContainer(props: { children?: ReactNode }) {
const containerEl = useRef<HTMLDivElement | null>(null);
const display = usePlayerStore((s) => s.display);
useHovering(containerEl);
// report container element to display interface
useEffect(() => {
if (display && containerEl.current) {
display.processContainerElement(containerEl.current);
}
}, [display, containerEl]);
return (
<div>
<VideoContainer />
<div className="relative overflow-hidden h-screen" ref={containerEl}>
{props.children}
</div>
);
}
export function Container(props: PlayerProps) {
const propRef = useRef(props.onLoad);
useEffect(() => {
propRef.current?.();
}, []);
return (
<BaseContainer>
<VideoContainer />
{props.children}
</BaseContainer>
);
}

View File

@ -1,14 +1,23 @@
import fscreen from "fscreen";
import {
DisplayInterface,
DisplayInterfaceEvents,
} from "@/components/player/display/displayInterface";
import { Source } from "@/components/player/hooks/usePlayer";
import {
canFullscreen,
canFullscreenAnyElement,
canWebkitFullscreen,
} from "@/utils/detectFeatures";
import { makeEmitter } from "@/utils/events";
export function makeVideoElementDisplayInterface(): DisplayInterface {
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
let source: Source | null = null;
let videoElement: HTMLVideoElement | null = null;
let containerElement: HTMLElement | null = null;
let isFullscreen = false;
function setSource() {
if (!videoElement || !source) return;
@ -17,13 +26,19 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
videoElement.addEventListener("pause", () => emit("pause", undefined));
}
function fullscreenChange() {
isFullscreen =
!!document.fullscreenElement || // other browsers
!!(document as any).webkitFullscreenElement; // safari
}
fscreen.addEventListener("fullscreenchange", fullscreenChange);
return {
on,
off,
// no need to destroy anything
destroy: () => {},
destroy: () => {
fscreen.removeEventListener("fullscreenchange", fullscreenChange);
},
load(newSource) {
source = newSource;
setSource();
@ -33,13 +48,36 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
videoElement = video;
setSource();
},
processContainerElement(container) {
containerElement = container;
},
pause() {
videoElement?.pause();
},
play() {
videoElement?.play();
},
toggleFullscreen() {
if (isFullscreen) {
isFullscreen = false;
emit("fullscreen", isFullscreen);
if (!fscreen.fullscreenElement) return;
fscreen.exitFullscreen();
return;
}
// enter fullscreen
isFullscreen = true;
emit("fullscreen", isFullscreen);
if (!canFullscreen() || fscreen.fullscreenElement) return;
if (canFullscreenAnyElement()) {
if (containerElement) fscreen.requestFullscreen(containerElement);
return;
}
if (canWebkitFullscreen()) {
if (videoElement) (videoElement as any).webkitEnterFullscreen();
}
},
};
}

View File

@ -4,6 +4,7 @@ import { Listener } from "@/utils/events";
export type DisplayInterfaceEvents = {
play: void;
pause: void;
fullscreen: boolean;
};
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
@ -11,5 +12,7 @@ export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
pause(): void;
load(source: Source): void;
processVideoElement(video: HTMLVideoElement): void;
processContainerElement(container: HTMLElement): void;
toggleFullscreen(): void;
destroy(): void;
}

View File

@ -18,5 +18,8 @@ export function usePlayer() {
display?.load(source);
setStatus(playerStatus.PLAYING);
},
setScrapeStatus() {
setStatus(playerStatus.SCRAPING);
},
};
}

View File

@ -0,0 +1,18 @@
import { Icon, Icons } from "@/components/Icon";
export function VideoPlayerButton(props: {
children?: React.ReactNode;
onClick: () => void;
icon?: Icons;
}) {
return (
<button
type="button"
onClick={props.onClick}
className="p-2 rounded-full hover:bg-video-buttonBackground hover:bg-opacity-75 transition-transform duration-100 active:scale-110 active:bg-opacity-100 active:text-white"
>
{props.icon && <Icon className="text-2xl" icon={props.icon} />}
{props.children}
</button>
);
}

View File

@ -34,7 +34,7 @@ function VideoElement() {
}
}, [display, videoEl]);
return <video autoPlay ref={videoEl} />;
return <video className="w-full h-screen" autoPlay ref={videoEl} />;
}
export function VideoContainer() {

View File

@ -1,24 +1,33 @@
import { MWStreamType } from "@/backend/helpers/streams";
import { Player } from "@/components/player";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
export function PlayerView() {
const { status, playMedia } = usePlayer();
const { status, playMedia, setScrapeStatus } = usePlayer();
const hovering = usePlayerStore((s) => s.interface.hovering);
function scrape() {
playMedia({
type: MWStreamType.MP4,
url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
// url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
url: "http://95.111.247.180/darude.mp4",
});
}
return (
<Player.Container>
<Player.Pause />
const showControlElements = hovering !== PlayerHoverState.NOT_HOVERING;
{status === playerStatus.IDLE ? (
<div>
return (
<Player.Container onLoad={setScrapeStatus}>
<Player.BottomControls show={showControlElements}>
<Player.Pause />
<Player.Fullscreen />
</Player.BottomControls>
{status === playerStatus.SCRAPING ? (
<div className="w-full h-screen">
<p>Its now scraping</p>
<button type="button" onClick={scrape}>
Finish scraping

View File

@ -0,0 +1,38 @@
import { DisplayInterface } from "@/components/player/display/displayInterface";
import { MakeSlice } from "@/stores/player/slices/types";
export interface DisplaySlice {
display: DisplayInterface | null;
setDisplay(display: DisplayInterface): void;
}
export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
display: null,
setDisplay(newDisplay: DisplayInterface) {
const display = get().display;
if (display) display.destroy();
// make display events update the state
newDisplay.on("pause", () =>
set((s) => {
s.mediaPlaying.isPaused = true;
s.mediaPlaying.isPlaying = false;
})
);
newDisplay.on("play", () =>
set((s) => {
s.mediaPlaying.isPaused = false;
s.mediaPlaying.isPlaying = true;
})
);
newDisplay.on("fullscreen", (isFullscreen) =>
set((s) => {
s.interface.isFullscreen = isFullscreen;
})
);
set((s) => {
s.display = newDisplay;
});
},
});

View File

@ -5,9 +5,16 @@ export enum VideoPlayerTimeFormat {
REMAINING = 1,
}
export enum PlayerHoverState {
NOT_HOVERING = "not_hovering",
MOUSE_HOVER = "mouse_hover",
MOBILE_TAPPED = "mobile_tapped",
}
export interface InterfaceSlice {
interface: {
isFullscreen: boolean;
hovering: PlayerHoverState;
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"
@ -15,14 +22,23 @@ export interface InterfaceSlice {
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
timeFormat: VideoPlayerTimeFormat; // Time format of the video player
};
updateInterfaceHovering(newState: PlayerHoverState): void;
}
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = () => ({
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set) => ({
interface: {
isFullscreen: false,
leftControlHovering: false,
hovering: PlayerHoverState.NOT_HOVERING,
volumeChangedWithKeybind: false,
volumeChangedWithKeybindDebounce: null,
timeFormat: VideoPlayerTimeFormat.REGULAR,
},
updateInterfaceHovering(newState: PlayerHoverState) {
set((s) => {
console.log("setting", newState);
s.interface.hovering = newState;
});
},
});

View File

@ -1,5 +1,4 @@
import { MWStreamType } from "@/backend/helpers/streams";
import { DisplayInterface } from "@/components/player/display/displayInterface";
import { MakeSlice } from "@/stores/player/slices/types";
import { ValuesOf } from "@/utils/typeguard";
@ -19,16 +18,13 @@ export interface SourceSliceSource {
export interface SourceSlice {
status: PlayerStatus;
source: SourceSliceSource | null;
display: DisplayInterface | null;
setStatus(status: PlayerStatus): void;
setSource(url: string, type: MWStreamType): void;
setDisplay(display: DisplayInterface): void;
}
export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
export const createSourceSlice: MakeSlice<SourceSlice> = (set) => ({
source: null,
status: playerStatus.IDLE,
display: null,
setStatus(status: PlayerStatus) {
set((s) => {
s.status = status;
@ -42,26 +38,4 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
};
});
},
setDisplay(newDisplay: DisplayInterface) {
const display = get().display;
if (display) display.destroy();
// make display events update the state
newDisplay.on("pause", () =>
set((s) => {
s.mediaPlaying.isPaused = true;
s.mediaPlaying.isPlaying = false;
})
);
newDisplay.on("play", () =>
set((s) => {
s.mediaPlaying.isPaused = false;
s.mediaPlaying.isPlaying = true;
})
);
set((s) => {
s.display = newDisplay;
});
},
});

View File

@ -1,5 +1,6 @@
import { StateCreator } from "zustand";
import { DisplaySlice } from "@/stores/player/slices/display";
import { InterfaceSlice } from "@/stores/player/slices/interface";
import { PlayingSlice } from "@/stores/player/slices/playing";
import { ProgressSlice } from "@/stores/player/slices/progress";
@ -8,7 +9,8 @@ import { SourceSlice } from "@/stores/player/slices/source";
export type AllSlices = InterfaceSlice &
PlayingSlice &
ProgressSlice &
SourceSlice;
SourceSlice &
DisplaySlice;
export type MakeSlice<Slice> = StateCreator<
AllSlices,
[["zustand/immer", never]],

View File

@ -1,6 +1,7 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createDisplaySlice } from "@/stores/player/slices/display";
import { createInterfaceSlice } from "@/stores/player/slices/interface";
import { createPlayingSlice } from "@/stores/player/slices/playing";
import { createProgressSlice } from "@/stores/player/slices/progress";
@ -13,5 +14,6 @@ export const usePlayerStore = create(
...createProgressSlice(...a),
...createPlayingSlice(...a),
...createSourceSlice(...a),
...createDisplaySlice(...a),
}))
);

View File

@ -26,23 +26,23 @@ module.exports = {
"ash-400": "#3D394D",
"ash-300": "#2C293A",
"ash-200": "#2B2836",
"ash-100": "#1E1C26",
"ash-100": "#1E1C26"
},
/* fonts */
fontFamily: {
"open-sans": "'Open Sans'",
"open-sans": "'Open Sans'"
},
/* animations */
keyframes: {
"loading-pin": {
"0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" },
"20%": { height: "1em", "background-color": "white" },
},
"20%": { height: "1em", "background-color": "white" }
}
},
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
},
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
}
},
plugins: [
require("tailwind-scrollbar"),
@ -52,25 +52,25 @@ module.exports = {
colors: {
// Branding
pill: {
background: "#1C1C36",
background: "#1C1C36"
},
// meta data for the theme itself
global: {
accentA: "#505DBD",
accentB: "#3440A1",
accentB: "#3440A1"
},
// light bar
lightBar: {
light: "#2A2A71",
light: "#2A2A71"
},
// only used for body colors/textures
background: {
main: "#0A0A10",
accentA: "#6E3B80",
accentB: "#1F1F50",
accentB: "#1F1F50"
},
// typography
@ -78,7 +78,7 @@ module.exports = {
emphasis: "#FFFFFF",
text: "#73739D",
dimmed: "#926CAD",
divider: "#262632",
divider: "#262632"
},
// search bar
@ -87,7 +87,7 @@ module.exports = {
focused: "#24243C",
placeholder: "#4A4A71",
icon: "#545476",
text: "#FFFFFF",
text: "#FFFFFF"
},
// media cards
@ -99,11 +99,16 @@ module.exports = {
barColor: "#4B4B63",
barFillColor: "#BA7FD6",
badge: "#151522",
badgeText: "#5F5F7A",
badgeText: "#5F5F7A"
},
},
},
},
}),
],
// video player
video: {
buttonBackground: "#444B5C"
}
}
}
}
})
]
};