mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-25 08:41:48 +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 { 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>
|
||||||
|
@ -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)
|
||||||
|
@ -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 />
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(() => {
|
||||||
|
Loading…
Reference in New Issue
Block a user