implement video player on mediapage

This commit is contained in:
Jelle van Snik 2023-01-10 21:18:10 +01:00
parent 35c7ac4b8d
commit d28e6e6735
6 changed files with 62 additions and 142 deletions

View File

@ -1,109 +0,0 @@
import { ReactElement, useEffect, useRef, useState } from "react";
import Hls from "hls.js";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
import { MWMediaCaption, MWMediaStream } from "@/providers";
export interface VideoPlayerProps {
source: MWMediaStream;
captions: MWMediaCaption[];
startAt?: number;
onProgress?: (event: ProgressEvent) => void;
}
export function SkeletonVideoPlayer(props: { error?: boolean }) {
return (
<div className="flex aspect-video w-full items-center justify-center bg-denim-200 lg:rounded-xl">
{props.error ? (
<div className="flex flex-col items-center">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p className="mt-5 text-white">Couldn&apos;t get your stream</p>
</div>
) : (
<div className="flex flex-col items-center">
<Loading />
<p className="mt-3 text-white">Getting your stream...</p>
</div>
)}
</div>
);
}
export function VideoPlayer(props: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [hasErrored, setErrored] = useState(false);
const [isLoading, setLoading] = useState(true);
const showVideo = !isLoading && !hasErrored;
const mustUseHls = props.source.type === "m3u8";
// reset if stream url changes
useEffect(() => {
setLoading(true);
setErrored(false);
// hls support
if (mustUseHls) {
if (!videoRef.current) return;
if (!Hls.isSupported()) {
setLoading(false);
setErrored(true);
return;
}
const hls = new Hls();
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
videoRef.current.src = props.source.url;
return;
}
hls.attachMedia(videoRef.current);
hls.loadSource(props.source.url);
hls.on(Hls.Events.ERROR, (event, data) => {
setErrored(true);
console.error(data);
});
}
}, [props.source.url, videoRef, mustUseHls]);
let skeletonUi: null | ReactElement = null;
if (hasErrored) {
skeletonUi = <SkeletonVideoPlayer error />;
} else if (isLoading) {
skeletonUi = <SkeletonVideoPlayer />;
}
return (
<>
{skeletonUi}
<video
className={`w-full rounded-xl bg-black ${!showVideo ? "hidden" : ""}`}
ref={videoRef}
onProgress={(e) =>
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
}
onLoadedData={(e) => {
setLoading(false);
if (props.startAt)
(e.target as HTMLVideoElement).currentTime = props.startAt;
}}
onError={(e) => {
console.error("failed to playback stream", e);
setErrored(true);
}}
controls
autoPlay
>
{!mustUseHls ? (
<source src={props.source.url} type="video/mp4" />
) : null}
{props.captions.map((v) => (
<track key={v.id} kind="captions" label={v.label} src={v.url} />
))}
</video>
</>
);
}

View File

@ -12,6 +12,11 @@ import { VideoPlayerHeader } from "./parts/VideoPlayerHeader";
import { useVideoPlayerState } from "./VideoContext"; import { useVideoPlayerState } from "./VideoContext";
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer"; import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
interface DecoratedVideoPlayerProps {
title?: string;
onGoBack?: () => void;
}
function LeftSideControls() { function LeftSideControls() {
const { videoState } = useVideoPlayerState(); const { videoState } = useVideoPlayerState();
@ -35,7 +40,9 @@ function LeftSideControls() {
); );
} }
export function DecoratedVideoPlayer(props: VideoPlayerProps) { export function DecoratedVideoPlayer(
props: VideoPlayerProps & DecoratedVideoPlayerProps
) {
const top = useRef<HTMLDivElement>(null); const top = useRef<HTMLDivElement>(null);
const bottom = useRef<HTMLDivElement>(null); const bottom = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
@ -98,7 +105,7 @@ export function DecoratedVideoPlayer(props: VideoPlayerProps) {
ref={top} ref={top}
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2" className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
> >
<VideoPlayerHeader title="Spiderman: Coming House" /> <VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
</div> </div>
</CSSTransition> </CSSTransition>
</BackdropControl> </BackdropControl>

View File

@ -17,7 +17,7 @@ function formatSeconds(secs: number, showHours = false): string {
const minutes = time % 60; const minutes = time % 60;
time /= 60; time /= 60;
const hours = minutes % 60; const hours = time % 60;
if (!showHours) if (!showHours)
return `${Math.round(minutes).toString()}:${Math.round(seconds) return `${Math.round(minutes).toString()}:${Math.round(seconds)

View File

@ -2,15 +2,17 @@ import { Icon, Icons } from "@/components/Icon";
import { BrandPill } from "@/components/layout/BrandPill"; import { BrandPill } from "@/components/layout/BrandPill";
interface VideoPlayerHeaderProps { interface VideoPlayerHeaderProps {
title: string; title?: string;
onClick?: () => void; onClick?: () => void;
} }
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
const showDivider = props.title || props.onClick;
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center">
<p className="flex items-center"> <p className="flex items-center">
{props.onClick ? (
<span <span
onClick={props.onClick} onClick={props.onClick}
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100" className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
@ -18,8 +20,13 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
<Icon className="mr-2" icon={Icons.ARROW_LEFT} /> <Icon className="mr-2" icon={Icons.ARROW_LEFT} />
<span>Back to home</span> <span>Back to home</span>
</span> </span>
) : null}
{showDivider ? (
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" /> <span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
) : null}
{props.title ? (
<span className="text-white">{props.title}</span> <span className="text-white">{props.title}</span>
) : null}
</p> </p>
</div> </div>
<BrandPill /> <BrandPill />

View File

@ -1,4 +1,4 @@
import { ReactElement, useEffect, useState } from "react"; import { ReactElement, useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconPatch } from "@/components/buttons/IconPatch"; import { IconPatch } from "@/components/buttons/IconPatch";
@ -6,10 +6,8 @@ import { Icons } from "@/components/Icon";
import { Navigation } from "@/components/layout/Navigation"; import { Navigation } from "@/components/layout/Navigation";
import { Paper } from "@/components/layout/Paper"; import { Paper } from "@/components/layout/Paper";
import { LoadingSeasons, Seasons } from "@/components/layout/Seasons"; import { LoadingSeasons, Seasons } from "@/components/layout/Seasons";
import { import { SkeletonVideoPlayer } from "@/components/media/VideoPlayer";
SkeletonVideoPlayer, import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
VideoPlayer,
} from "@/components/media/VideoPlayer";
import { ArrowLink } from "@/components/text/ArrowLink"; import { ArrowLink } from "@/components/text/ArrowLink";
import { DotList } from "@/components/text/DotList"; import { DotList } from "@/components/text/DotList";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
@ -30,6 +28,8 @@ import {
useBookmarkContext, useBookmarkContext,
} from "@/state/bookmark"; } from "@/state/bookmark";
import { getWatchedFromPortable, useWatchedContext } from "@/state/watched"; import { getWatchedFromPortable, useWatchedContext } from "@/state/watched";
import { SourceControl } from "@/components/video/controls/SourceControl";
import { ProgressListenerControl } from "@/components/video/controls/ProgressListenerControl";
import { NotFoundChecks } from "./notfound/NotFoundChecks"; import { NotFoundChecks } from "./notfound/NotFoundChecks";
interface StyledMediaViewProps { interface StyledMediaViewProps {
@ -38,28 +38,37 @@ interface StyledMediaViewProps {
} }
function StyledMediaView(props: StyledMediaViewProps) { function StyledMediaView(props: StyledMediaViewProps) {
const reactHistory = useHistory();
const watchedStore = useWatchedContext(); const watchedStore = useWatchedContext();
const startAtTime: number | undefined = getWatchedFromPortable( const startAtTime: number | undefined = getWatchedFromPortable(
watchedStore.watched.items, watchedStore.watched.items,
props.media props.media
)?.progress; )?.progress;
function updateProgress(e: Event) { const updateProgress = useCallback(
if (!props.media) return; (time: number, duration: number) => {
const el: HTMLVideoElement = e.currentTarget as HTMLVideoElement; // Don't update stored progress if less than 30s into the video
if (el.currentTime <= 30) { if (time <= 30) return;
return; // Don't update stored progress if less than 30s into the video watchedStore.updateProgress(props.media, time, duration);
} },
watchedStore.updateProgress(props.media, el.currentTime, el.duration); [props, watchedStore]
} );
const goBack = useCallback(() => {
if (reactHistory.action !== "POP") reactHistory.goBack();
else reactHistory.push("/");
}, [reactHistory]);
return ( return (
<VideoPlayer <div className="overflow-hidden lg:rounded-xl">
source={props.stream} <DecoratedVideoPlayer title={props.media.title} onGoBack={goBack}>
captions={props.stream.captions} <SourceControl source={props.stream.url} type={props.stream.type} />
onProgress={(e) => updateProgress(e)} <ProgressListenerControl
startAt={startAtTime} startAt={startAtTime}
onProgress={updateProgress}
/> />
</DecoratedVideoPlayer>
</div>
); );
} }

View File

@ -10,12 +10,18 @@ import { useCallback, useState } from "react";
// - captions // - captions
// - mobile UI // - mobile UI
// - safari fullscreen will make video overlap player controls // - safari fullscreen will make video overlap player controls
// - safari progress bar is fucked // - safari progress bar is fucked (video doesnt change time but video.currentTime does change)
// TODO optional todos: // TODO optional todos:
// - shortcuts when player is active // - shortcuts when player is active
// - improve seekables (if possible) // - improve seekables (if possible)
// TODO stuff to test:
// - browser: firefox, chrome, edge, safari desktop
// - phones: android firefox, android chrome, iphone safari
// - devices: ipadOS
// - features: HLS, error handling
export function TestView() { export function TestView() {
const [show, setShow] = useState(true); const [show, setShow] = useState(true);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {