scraping, topbar, fix timestuff, darkened overlay, fix click targets

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-02 21:04:40 +02:00
parent fa0ac293b4
commit 3b7df601af
19 changed files with 256 additions and 115 deletions

View File

@ -1,3 +1,7 @@
export * from "./atoms";
export * from "./base/Container";
export * from "./base/TopControls";
export * from "./base/BottomControls";
export * from "./base/BlackOverlay";
export * from "./base/BackLink";
export * from "./internals/BookmarkButton";

View File

@ -34,7 +34,7 @@ export function ProgressBar() {
return (
<div ref={ref}>
<div
className="group w-full h-8 flex items-center"
className="group w-full h-8 flex items-center cursor-pointer"
onMouseDown={dragMouseDown}
onTouchStart={dragMouseDown}
>

View File

@ -1,19 +1,29 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { VideoPlayerTimeFormat } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
import { formatSeconds } from "@/utils/formatSeconds";
function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60;
}
export function Time() {
const [timeMode, setTimeMode] = useState(true);
const timeFormat = usePlayerStore((s) => s.interface.timeFormat);
const setTimeFormat = usePlayerStore((s) => s.setTimeFormat);
const { duration, time, draggingTime } = usePlayerStore((s) => s.progress);
const { isSeeking } = usePlayerStore((s) => s.interface);
const { t } = useTranslation();
const hasHours = durationExceedsHour(duration);
function toggleMode() {
setTimeMode(!timeMode);
setTimeFormat(
timeFormat === VideoPlayerTimeFormat.REGULAR
? VideoPlayerTimeFormat.REMAINING
: VideoPlayerTimeFormat.REGULAR
);
}
const currentTime = Math.min(
@ -30,16 +40,23 @@ export function Time() {
},
});
const child = timeMode ? (
<>
{formatSeconds(currentTime)} <span>/ {formatSeconds(duration)}</span>
</>
) : (
<>
{t("videoPlayer.timeLeft", { timeLeft: formatSeconds(secondsRemaining) })}{" "}
{formattedTimeFinished}
</>
);
const child =
timeFormat === VideoPlayerTimeFormat.REGULAR ? (
<>
{formatSeconds(currentTime, hasHours)}{" "}
<span>/ {formatSeconds(duration, hasHours)}</span>
</>
) : (
<>
{t("videoPlayer.timeLeft", {
timeLeft: formatSeconds(
secondsRemaining,
durationExceedsHour(secondsRemaining)
),
})}{" "}
{formattedTimeFinished}
</>
);
return (
<VideoPlayerButton onClick={() => toggleMode()}>{child}</VideoPlayerButton>

View File

@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { useGoBack } from "@/hooks/useGoBack";
export function BackLink() {
const { t } = useTranslation();
const goBack = useGoBack();
return (
<div className="flex items-center">
<span
onClick={() => goBack()}
className="flex items-center cursor-pointer text-type-secondary hover:text-white transition-colors duration-200 font-medium"
>
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
<span>{t("videoPlayer.backToHomeShort")}</span>
</span>
<span className="text mx-3 text-type-secondary">/</span>
<span>Mr Jeebaloo&apos;s Big Ocean Adventure</span>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { Transition } from "@/components/Transition";
export function BlackOverlay(props: { show?: boolean }) {
return (
<Transition
animation="fade"
show={props.show}
className="absolute inset-0 w-full h-full bg-black bg-opacity-20 pointer-events-none"
/>
);
}

View File

@ -1,25 +1,19 @@
import { Transition } from "@/components/Transition";
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
export function BottomControls(props: {
show?: boolean;
children: React.ReactNode;
}) {
const { hovering } = usePlayerStore((s) => s.interface);
const visible =
(hovering !== PlayerHoverState.NOT_HOVERING || props.show) ?? false;
return (
<div className="w-full text-white">
<Transition
animation="fade"
show={visible}
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"
/>
<Transition
animation="slide-up"
show={visible}
show={props.show}
className="pointer-events-auto px-4 pb-3 absolute bottom-0 w-full"
>
{props.children}

View File

@ -1,5 +1,6 @@
import { ReactNode, RefObject, useEffect, useRef } from "react";
import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget";
import { VideoContainer } from "@/components/player/internals/VideoContainer";
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
@ -36,22 +37,12 @@ function useHovering(containerEl: RefObject<HTMLDivElement>) {
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]);
}
@ -69,7 +60,10 @@ function BaseContainer(props: { children?: ReactNode }) {
}, [display, containerEl]);
return (
<div className="relative overflow-hidden h-screen" ref={containerEl}>
<div
className="relative overflow-hidden h-screen select-none"
ref={containerEl}
>
{props.children}
</div>
);
@ -84,6 +78,7 @@ export function Container(props: PlayerProps) {
return (
<BaseContainer>
<VideoContainer />
<VideoClickTarget />
{props.children}
</BaseContainer>
);

View File

@ -0,0 +1,23 @@
import { Transition } from "@/components/Transition";
export function TopControls(props: {
show?: boolean;
children: React.ReactNode;
}) {
return (
<div className="w-full text-white">
<Transition
animation="fade"
show={props.show}
className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full"
/>
<Transition
animation="slide-down"
show={props.show}
className="pointer-events-auto px-4 pt-6 absolute top-0 w-full text-white"
>
{props.children}
</Transition>
</div>
);
}

View File

@ -21,6 +21,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
let containerElement: HTMLElement | null = null;
let isFullscreen = false;
let isPausedBeforeSeeking = false;
let isSeeking = false;
function setSource() {
if (!videoElement || !source) return;
@ -78,6 +79,9 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
videoElement?.play();
},
setSeeking(active) {
if (active === isSeeking) return;
isSeeking = active;
// if it was playing when starting to seek, play again
if (!active) {
if (!isPausedBeforeSeeking) this.play();

View File

@ -0,0 +1,9 @@
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
export function useShouldShowControls() {
const { hovering } = usePlayerStore((s) => s.interface);
const { isPaused } = usePlayerStore((s) => s.mediaPlaying);
return hovering !== PlayerHoverState.NOT_HOVERING || isPaused;
}

View File

@ -0,0 +1,14 @@
import { Icons } from "@/components/Icon";
import { VideoPlayerButton } from "./Button";
export function BookmarkButton() {
return (
<VideoPlayerButton
onClick={() => window.open("https://youtu.be/TENzstSjsus", "_blank")}
icon={Icons.BOOKMARK_OUTLINE}
iconSizeClass="text-base"
className="p-3"
/>
);
}

View File

@ -4,14 +4,21 @@ export function VideoPlayerButton(props: {
children?: React.ReactNode;
onClick: () => void;
icon?: Icons;
iconSizeClass?: string;
className?: string;
}) {
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"
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.className ?? "",
].join(" ")}
>
{props.icon && <Icon className="text-2xl" icon={props.icon} />}
{props.icon && (
<Icon className={props.iconSizeClass || "text-2xl"} icon={props.icon} />
)}
{props.children}
</button>
);

View File

@ -0,0 +1,47 @@
import { PointerEvent, useCallback } from "react";
import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer";
import { PlayerHoverState } from "@/stores/player/slices/interface";
import { usePlayerStore } from "@/stores/player/store";
export function VideoClickTarget() {
const show = useShouldShowVideoElement();
const display = usePlayerStore((s) => s.display);
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
const updateInterfaceHovering = usePlayerStore(
(s) => s.updateInterfaceHovering
);
const hovering = usePlayerStore((s) => s.interface.hovering);
const toggleFullscreen = useCallback(() => {
display?.toggleFullscreen();
}, [display]);
const togglePause = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
// pause on mouse click
if (e.pointerType === "mouse") {
if (e.button !== 0) return;
if (isPaused) display?.play();
else display?.pause();
return;
}
// toggle on other types of clicks
if (hovering !== PlayerHoverState.MOBILE_TAPPED)
updateInterfaceHovering(PlayerHoverState.MOBILE_TAPPED);
else updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
},
[display, isPaused, hovering, updateInterfaceHovering]
);
if (!show) return null;
return (
<div
className="absolute inset-0"
onDoubleClick={toggleFullscreen}
onPointerUp={togglePause}
/>
);
}

View File

@ -1,4 +1,4 @@
import { PointerEvent, useCallback, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { makeVideoElementDisplayInterface } from "@/components/player/display/base";
import { playerStatus } from "@/stores/player/slices/source";
@ -16,7 +16,7 @@ function useDisplayInterface() {
}, [display, setDisplay]);
}
function useShouldShowVideoElement() {
export function useShouldShowVideoElement() {
const status = usePlayerStore((s) => s.status);
if (status !== playerStatus.PLAYING) return false;
@ -26,20 +26,6 @@ function useShouldShowVideoElement() {
function VideoElement() {
const videoEl = useRef<HTMLVideoElement>(null);
const display = usePlayerStore((s) => s.display);
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
const toggleFullscreen = useCallback(() => {
display?.toggleFullscreen();
}, [display]);
const togglePause = useCallback(
(e: PointerEvent<HTMLVideoElement>) => {
if (e.pointerType !== "mouse") return;
if (isPaused) display?.play();
else display?.pause();
},
[display, isPaused]
);
// report video element to display interface
useEffect(() => {
@ -48,15 +34,7 @@ function VideoElement() {
}
}, [display, videoEl]);
return (
<video
className="w-full h-screen bg-black"
autoPlay
ref={videoEl}
onDoubleClick={toggleFullscreen}
onPointerUp={togglePause}
/>
);
return <video className="w-full h-screen bg-black" autoPlay ref={videoEl} />;
}
export function VideoContainer() {

View File

@ -1,26 +1,46 @@
import { useCallback } from "react";
import { MWStreamType } from "@/backend/helpers/streams";
import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
import { playerStatus } from "@/stores/player/slices/source";
export function PlayerView() {
const { status, playMedia, setScrapeStatus } = usePlayer();
const startStream = useCallback(() => {
playMedia({
type: MWStreamType.MP4,
// url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
// url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WhatCarCanYouGetForAGrand.mp4",
url: "http://95.111.247.180/frog.mp4",
});
}, [playMedia]);
const { status, setScrapeStatus } = usePlayer();
const desktopControlsVisible = useShouldShowControls();
return (
<Player.Container onLoad={setScrapeStatus}>
<Player.BottomControls>
{status === playerStatus.SCRAPING ? (
<ScrapingPart
media={{
type: "movie",
title: "Everything Everywhere All At Once",
tmdbId: "545611",
releaseYear: 2022,
}}
/>
) : null}
<Player.BlackOverlay show={desktopControlsVisible} />
<Player.TopControls show={desktopControlsVisible}>
<div className="grid grid-cols-[1fr,auto] xl:grid-cols-3 items-center">
<div className="flex space-x-3 items-center">
<Player.BackLink />
<Player.BookmarkButton />
</div>
<div className="text-center hidden xl:flex justify-center items-center">
<span className="text-white font-medium mr-3">S1 E5</span>
<span className="text-type-secondary font-medium">
Mr. Jeebaloo discovers Atlantis
</span>
</div>
<div className="flex items-center justify-end">
<BrandPill />
</div>
</div>
</Player.TopControls>
<Player.BottomControls show={desktopControlsVisible}>
<Player.ProgressBar />
<div className="flex justify-between">
<div className="flex space-x-3 items-center">
@ -34,18 +54,6 @@ export function PlayerView() {
</div>
</div>
</Player.BottomControls>
{status === playerStatus.SCRAPING ? (
<ScrapingPart
onGetStream={startStream}
media={{
type: "movie",
title: "Hamilton",
tmdbId: "556574",
releaseYear: 2020,
}}
/>
) : null}
</Player.Container>
);
}

View File

@ -1,11 +1,13 @@
import { ScrapeMedia } from "@movie-web/providers";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { MWStreamType } from "@/backend/helpers/streams";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { providers } from "@/utils/providers";
export interface ScrapingProps {
media: ScrapeMedia;
onGetStream?: () => void;
// onGetStream?: () => void;
}
export interface ScrapingSegment {
@ -32,7 +34,6 @@ function useScrape() {
media,
events: {
init(evt) {
console.log("init", evt);
setSources(
evt.sourceIds
.map((v) => {
@ -54,14 +55,12 @@ function useScrape() {
setSourceOrder(evt.sourceIds.map((v) => ({ id: v, children: [] })));
},
start(id) {
console.log("start", id);
setSources((s) => {
if (s[id]) s[id].status = "pending";
return { ...s };
});
},
update(evt) {
console.log("update", evt);
setSources((s) => {
if (s[evt.id]) {
s[evt.id].status = evt.status;
@ -72,7 +71,6 @@ function useScrape() {
});
},
discoverEmbeds(evt) {
console.log("discoverEmbeds", evt);
setSources((s) => {
evt.embeds.forEach((v) => {
const source = providers.getMetadata(v.embedScraperId);
@ -97,7 +95,6 @@ function useScrape() {
},
});
console.log(output);
return output;
},
[setSourceOrder, setSources]
@ -111,10 +108,26 @@ function useScrape() {
}
export function ScrapingPart(props: ScrapingProps) {
const { playMedia } = usePlayer();
const { startScraping, sourceOrder, sources } = useScrape();
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
(async () => {
const output = await startScraping(props.media);
if (output?.stream.type !== "file") return;
const firstFile = Object.values(output.stream.qualities)[0];
playMedia({
type: MWStreamType.MP4,
url: firstFile.url,
});
})();
}, [startScraping, props, playMedia]);
return (
<div>
<div className="h-full w-full flex items-center justify-center flex-col">
{sourceOrder.map((order) => {
const source = sources[order.id];
if (!source) return null;
@ -141,20 +154,6 @@ export function ScrapingPart(props: ScrapingProps) {
</div>
);
})}
<button
type="button"
onClick={() => startScraping(props.media)}
className="block"
>
Start scraping
</button>
<button
type="button"
onClick={() => props.onGetStream?.()}
className="block"
>
Finish scraping
</button>
</div>
);
}

View File

@ -25,6 +25,7 @@ export interface InterfaceSlice {
};
updateInterfaceHovering(newState: PlayerHoverState): void;
setSeeking(seeking: boolean): void;
setTimeFormat(format: VideoPlayerTimeFormat): void;
}
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
@ -38,6 +39,11 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
timeFormat: VideoPlayerTimeFormat.REGULAR,
},
setTimeFormat(format) {
set((s) => {
s.interface.timeFormat = format;
});
},
updateInterfaceHovering(newState: PlayerHoverState) {
set((s) => {
s.interface.hovering = newState;

View File

@ -78,7 +78,8 @@ module.exports = {
emphasis: "#FFFFFF",
text: "#73739D",
dimmed: "#926CAD",
divider: "#262632"
divider: "#262632",
secondary: "#64647B"
},
// search bar

View File

@ -1,7 +1,7 @@
player itself:
- [ ] BUG Pause should keep controls visible
- [ ] BUG Touch on bottoms shouldn't toggle UI
- [ ] BUG unpause when controls hover
- [x] BUG Pause should keep controls visible
- [x] BUG Touch on bottoms shouldn't toggle UI
- [x] BUG unpause when controls hover
- [ ] keyboard controls
- [ ] fullscreen
- [ ] barrel roll
@ -9,15 +9,16 @@ player itself:
- [ ] skip forward/backward
- [ ] pause
- [ ] volume ui
- [ ] header (back, title, logo)
- [x] header (back, title, logo)
- [ ] touch middle controls (forward, backward, pause)
- [ ] volume
- [ ] bookmark in header
- [ ] airplay
- [ ] responsiveness
- [ ] chromecast
- [ ] thumbnails
- [ ] hover darken overlay (10% black)
- [x] hover darken overlay (20% black)
- [ ] autoplay not working
- [ ] play button in middle if cant autoplay
player views:
- [ ] scraping view