mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-11 01:55:05 +01:00
implement video player on mediapage
This commit is contained in:
parent
35c7ac4b8d
commit
d28e6e6735
@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -12,6 +12,11 @@ import { VideoPlayerHeader } from "./parts/VideoPlayerHeader";
|
||||
import { useVideoPlayerState } from "./VideoContext";
|
||||
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
|
||||
|
||||
interface DecoratedVideoPlayerProps {
|
||||
title?: string;
|
||||
onGoBack?: () => void;
|
||||
}
|
||||
|
||||
function LeftSideControls() {
|
||||
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 bottom = useRef<HTMLDivElement>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
@ -98,7 +105,7 @@ export function DecoratedVideoPlayer(props: VideoPlayerProps) {
|
||||
ref={top}
|
||||
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>
|
||||
</CSSTransition>
|
||||
</BackdropControl>
|
||||
|
@ -17,7 +17,7 @@ function formatSeconds(secs: number, showHours = false): string {
|
||||
const minutes = time % 60;
|
||||
|
||||
time /= 60;
|
||||
const hours = minutes % 60;
|
||||
const hours = time % 60;
|
||||
|
||||
if (!showHours)
|
||||
return `${Math.round(minutes).toString()}:${Math.round(seconds)
|
||||
|
@ -2,24 +2,31 @@ import { Icon, Icons } from "@/components/Icon";
|
||||
import { BrandPill } from "@/components/layout/BrandPill";
|
||||
|
||||
interface VideoPlayerHeaderProps {
|
||||
title: string;
|
||||
title?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
const showDivider = props.title || props.onClick;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-1 items-center">
|
||||
<p className="flex items-center">
|
||||
<span
|
||||
onClick={props.onClick}
|
||||
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
|
||||
<span>Back to home</span>
|
||||
</span>
|
||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
||||
<span className="text-white">{props.title}</span>
|
||||
{props.onClick ? (
|
||||
<span
|
||||
onClick={props.onClick}
|
||||
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<Icon className="mr-2" icon={Icons.ARROW_LEFT} />
|
||||
<span>Back to home</span>
|
||||
</span>
|
||||
) : null}
|
||||
{showDivider ? (
|
||||
<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>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<BrandPill />
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
@ -6,10 +6,8 @@ import { Icons } from "@/components/Icon";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { Paper } from "@/components/layout/Paper";
|
||||
import { LoadingSeasons, Seasons } from "@/components/layout/Seasons";
|
||||
import {
|
||||
SkeletonVideoPlayer,
|
||||
VideoPlayer,
|
||||
} from "@/components/media/VideoPlayer";
|
||||
import { SkeletonVideoPlayer } from "@/components/media/VideoPlayer";
|
||||
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
import { DotList } from "@/components/text/DotList";
|
||||
import { Title } from "@/components/text/Title";
|
||||
@ -30,6 +28,8 @@ import {
|
||||
useBookmarkContext,
|
||||
} from "@/state/bookmark";
|
||||
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";
|
||||
|
||||
interface StyledMediaViewProps {
|
||||
@ -38,28 +38,37 @@ interface StyledMediaViewProps {
|
||||
}
|
||||
|
||||
function StyledMediaView(props: StyledMediaViewProps) {
|
||||
const reactHistory = useHistory();
|
||||
const watchedStore = useWatchedContext();
|
||||
const startAtTime: number | undefined = getWatchedFromPortable(
|
||||
watchedStore.watched.items,
|
||||
props.media
|
||||
)?.progress;
|
||||
|
||||
function updateProgress(e: Event) {
|
||||
if (!props.media) return;
|
||||
const el: HTMLVideoElement = e.currentTarget as HTMLVideoElement;
|
||||
if (el.currentTime <= 30) {
|
||||
return; // Don't update stored progress if less than 30s into the video
|
||||
}
|
||||
watchedStore.updateProgress(props.media, el.currentTime, el.duration);
|
||||
}
|
||||
const updateProgress = useCallback(
|
||||
(time: number, duration: number) => {
|
||||
// Don't update stored progress if less than 30s into the video
|
||||
if (time <= 30) return;
|
||||
watchedStore.updateProgress(props.media, time, duration);
|
||||
},
|
||||
[props, watchedStore]
|
||||
);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (reactHistory.action !== "POP") reactHistory.goBack();
|
||||
else reactHistory.push("/");
|
||||
}, [reactHistory]);
|
||||
|
||||
return (
|
||||
<VideoPlayer
|
||||
source={props.stream}
|
||||
captions={props.stream.captions}
|
||||
onProgress={(e) => updateProgress(e)}
|
||||
startAt={startAtTime}
|
||||
/>
|
||||
<div className="overflow-hidden lg:rounded-xl">
|
||||
<DecoratedVideoPlayer title={props.media.title} onGoBack={goBack}>
|
||||
<SourceControl source={props.stream.url} type={props.stream.type} />
|
||||
<ProgressListenerControl
|
||||
startAt={startAtTime}
|
||||
onProgress={updateProgress}
|
||||
/>
|
||||
</DecoratedVideoPlayer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -10,12 +10,18 @@ import { useCallback, useState } from "react";
|
||||
// - captions
|
||||
// - mobile UI
|
||||
// - 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:
|
||||
// - shortcuts when player is active
|
||||
// - 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() {
|
||||
const [show, setShow] = useState(true);
|
||||
const handleClick = useCallback(() => {
|
||||
|
Loading…
Reference in New Issue
Block a user