watch: very basic video player
continuous-integration/drone/push Build is failing Details

nextui
Guus van Meerveld 1 month ago
parent 64c8c6bb74
commit c29052ca61

@ -22,6 +22,7 @@
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.0.1",
"react-player": "^2.15.1",
"reactjs-visibility": "^0.1.4",
"sanitize-html": "^2.13.0",
"use-debounce": "^10.0.0",

@ -30,13 +30,16 @@ export const Description: Component<{ data: string }> = ({ data }) => {
[data]
);
const descriptionCut = useMemo(
() =>
expandedDescription
? sanitizedDescription
: sanitizedDescription.substring(0, shortenedDescriptionLength) + "...",
[sanitizedDescription, expandedDescription]
);
const descriptionAlreadyShort = sanitizedDescription.length <= 200;
const descriptionCut = useMemo(() => {
if (descriptionAlreadyShort) return sanitizedDescription;
else if (expandedDescription) return sanitizedDescription;
else
return (
sanitizedDescription.substring(0, shortenedDescriptionLength) + "..."
);
}, [sanitizedDescription, descriptionAlreadyShort, expandedDescription]);
const description = useMemo(
() => highlight(descriptionCut),
@ -48,13 +51,15 @@ export const Description: Component<{ data: string }> = ({ data }) => {
<h2 className="text-ellipsis overflow-y-hidden">
<HighlightRenderer highlighted={description} />
</h2>
<Button
startContent={expandedDescription ? <CollapseIcon /> : <ExpandIcon />}
variant="light"
onClick={() => setExpandedDescription((state) => !state)}
>
{expandedDescription ? "Show less" : "Show more"}
</Button>
{!descriptionAlreadyShort && (
<Button
startContent={expandedDescription ? <CollapseIcon /> : <ExpandIcon />}
variant="light"
onClick={() => setExpandedDescription((state) => !state)}
>
{expandedDescription ? "Show less" : "Show more"}
</Button>
)}
</div>
);
};

@ -1,11 +0,0 @@
"use client";
import { Component } from "@/typings/component";
export const Player: Component = () => {
return (
<>
<video src="" className="w-full" />
</>
);
};

@ -0,0 +1,209 @@
"use client";
import { useDebounce } from "use-debounce";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FiMaximize as MaximizeIcon,
FiMinimize as MinimizeIcon,
FiVolumeX as MutedIcon,
FiPause as PauseIcon,
FiFastForward as PlaybackRateIcon,
FiPlay as PlayIcon,
FiVolume as VolumeIcon,
FiVolume1 as VolumeIcon1,
FiVolume2 as VolumeIcon2
} from "react-icons/fi";
import ReactPlayer from "react-player";
import { Button } from "@nextui-org/button";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger
} from "@nextui-org/dropdown";
import { Slider } from "@nextui-org/slider";
import { HlsStream, Stream, StreamType } from "@/client/typings/stream";
import formatDuration from "@/utils/formatDuration";
import { Component } from "@/typings/component";
export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
const stream = streams.find((stream) => stream.type === StreamType.Hls);
const player = useRef<ReactPlayer>(null);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [maximized, setMaximized] = useState(false);
const [volume, setVolume] = useState(40);
const [playbackRate, setPlaybackRate] = useState(1.0);
const [playing, setPlaying] = useState(false);
const [userSetProgress, setUserSetProgress] = useState(0);
const [seek] = useDebounce(userSetProgress, 100);
useEffect(() => {
player.current?.seekTo(seek);
}, [seek]);
const volumeIcons = useMemo(
() => [
<VolumeIcon key="vol" />,
<VolumeIcon1 key="vol1" />,
<VolumeIcon2 key="vol2" />
],
[]
);
const playbackRateCategories = useMemo(
() => [
{ key: 0.25, label: "0.25" },
{ key: 0.5, label: "0.5" },
{ key: 1, label: "1.0" },
{ key: 1.25, label: "1.25" },
{ key: 1.5, label: "1.5" },
{ key: 2, label: "2.0" }
],
[]
);
const handleBuffering = useCallback(() => {}, []);
return (
<>
{stream && (
<div className="relative" style={{ paddingTop: `${100 / (16 / 9)}%` }}>
<div
className="w-full absolute bottom-0 z-10 pb-2 px-4"
style={{
background:
"linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%)"
}}
>
<div className="flex flex-col gap-1">
<Slider
aria-label="Video progress bar"
step={0.1}
onChange={(value) => {
if (typeof value === "number") {
setUserSetProgress(value / 100);
setProgress(value / 100);
}
}}
className="cursor-pointer"
value={progress * 100}
/>
<div className="flex flex-row items-center gap-4">
<div className="flex flex-1 flex-row gap-2 items-center">
<Button
variant="light"
isIconOnly
className="text-xl"
onClick={() => setPlaying((state) => !state)}
>
{playing ? <PauseIcon /> : <PlayIcon />}
</Button>
<Dropdown>
<DropdownTrigger>
<Button
variant="light"
isIconOnly
className="text-xl"
onClick={() => setMaximized((state) => !state)}
>
{
volumeIcons[
Math.floor((volume / 100) * volumeIcons.length)
]
}
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Volume menu">
<DropdownItem>
<Slider
className="h-48"
value={volume}
onChange={(value) => {
if (typeof value === "number") setVolume(value);
}}
orientation="vertical"
/>
</DropdownItem>
</DropdownMenu>
</Dropdown>
<p>
{formatDuration(progress * duration * 1000)} /{" "}
{formatDuration(duration * 1000)}
</p>
</div>
<div className="flex flex-row gap-2 items-center">
<Dropdown>
<DropdownTrigger>
<Button
className="text-xl"
// startContent={<PlaybackRateIcon />}
variant="light"
>
{playbackRate}x
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Playback rate menu"
onAction={(key) => {
setPlaybackRate(parseFloat(key as string));
}}
items={playbackRateCategories}
>
{(item) => (
<DropdownItem key={item.key}>
{item.label}x
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
<Button
variant="light"
isIconOnly
className="text-xl"
onClick={() => setMaximized((state) => !state)}
>
{maximized ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
</div>
</div>
</div>
<ReactPlayer
playing={playing}
volume={volume / 100}
playbackRate={playbackRate}
ref={player}
className="absolute top-0 left-0"
width="100%"
height="100%"
config={{ file: { forceHLS: true } }}
onPause={() => setPlaying(false)}
onPlay={() => setPlaying(true)}
onDuration={(duration) => setDuration(duration)}
onBuffer={handleBuffering}
onProgress={({ played }) => {
setProgress(played);
}}
// onPlaybackQualityChange={(e: unknown) =>
// console.log("onPlaybackQualityChange", e)
// }
url={(stream as HlsStream).url}
/>
</div>
)}
</>
);
};

@ -26,6 +26,9 @@ import { Related } from "./Related";
import { Component } from "@/typings/component";
// TODO: Make all keywords visible in some way
const maxKeyWords = 3;
export const Watch: Component = () => {
const client = useClient();
@ -38,7 +41,7 @@ export const Watch: Component = () => {
const { data, isLoading, error } = useQuery({
queryKey: ["watch", videoId],
queryFn: () => {
return client.getStream(videoId);
return client.getWatchable(videoId);
},
enabled: !videoIdIsInvalid
});
@ -62,10 +65,10 @@ export const Watch: Component = () => {
<Container>
{isLoading && <LoadingPage />}
{data && !isLoading && (
<div className="flex flex-col">
<Player />
<div className="flex flex-col gap-4">
<Player streams={data.streams} />
<div className="flex flex-col xl:flex-row gap-4">
<div className="xl:w-2/3 flex flex-col gap-4">
<div className=" flex flex-col gap-4">
<div className="flex flex-col">
<h1 className="text-2xl">{data.video.title}</h1>
<div className="flex flex-row gap-4 text-lg tracking-tight text-default-500">
@ -98,7 +101,7 @@ export const Watch: Component = () => {
<div className="flex flex-row gap-2">
<p>Keywords:</p>
<div className="flex flex-row gap-2 whitespace-nowrap overflow-x-scroll">
{data.keywords.map((keyword) => (
{data.keywords.slice(0, maxKeyWords).map((keyword) => (
<Chip key={keyword}>{keyword}</Chip>
))}
</div>
@ -114,7 +117,7 @@ export const Watch: Component = () => {
videoUploader={data.video.author}
/>
</div>
<div className="xl:w-1/3 flex justify-center">
<div className="flex justify-center">
<Related data={data.related} />
</div>
</div>

@ -2,8 +2,8 @@ import { Comments } from "@/client/typings/comment";
import { SearchResults } from "@/client/typings/search";
import { SearchOptions } from "@/client/typings/search/options";
import { Suggestions } from "@/client/typings/search/suggestions";
import { Stream } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
import { Watchable } from "@/client/typings/watchable";
export interface ConnectedAdapter {
getTrending(region: string): Promise<Video[]>;
@ -11,7 +11,7 @@ export interface ConnectedAdapter {
getSearchSuggestions(query: string): Promise<Suggestions>;
getSearch(query: string, options?: SearchOptions): Promise<SearchResults>;
getStream(videoId: string): Promise<Stream>;
getWatchable(videoId: string): Promise<Watchable>;
getComments(videoId: string, repliesToken?: string): Promise<Comments>;
}

@ -143,7 +143,7 @@ const adapter: Adapter = {
return { items: items, nextCursor: (page + 1).toString() };
},
async getStream(videoId) {
async getWatchable(videoId) {
return getVideo(url, videoId).then(Transformer.stream);
},

@ -6,8 +6,9 @@ import {
VideoItem
} from "@/client/typings/item";
import { Suggestions } from "@/client/typings/search/suggestions";
import { Stream } from "@/client/typings/stream";
import { Stream, StreamType } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
import { Watchable } from "@/client/typings/watchable";
import { parseSubscriberCount } from "@/utils/parseSubscriberCount";
import InvidiousComments from "./typings/comments";
@ -141,35 +142,40 @@ export default class Transformer {
});
}
public static stream(stream: InvidiousStream): Stream {
const thumbnail = Transformer.findBestThumbnail(stream.videoThumbnails);
public static stream(data: InvidiousStream): Watchable {
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
if (thumbnail === null)
throw new Error(
`Invidious: Missing thumbnail for video with id ${stream.videoId}`
`Invidious: Missing thumbnail for video with id ${data.videoId}`
);
const streams: Stream[] = [];
streams.push({ type: StreamType.Dash, url: data.dashUrl });
return {
category: stream.genre,
dislikes: stream.dislikeCount,
likes: stream.likeCount,
keywords: stream.keywords,
related: stream.recommendedVideos.map(Transformer.recommendedVideo),
category: data.genre,
dislikes: data.dislikeCount,
likes: data.likeCount,
keywords: data.keywords,
related: data.recommendedVideos.map(Transformer.recommendedVideo),
streams,
video: {
author: {
id: stream.authorId,
name: stream.author,
avatar: stream.authorThumbnails[0].url,
subscribers: parseSubscriberCount(stream.subCountText)
id: data.authorId,
name: data.author,
avatar: data.authorThumbnails[0].url,
subscribers: parseSubscriberCount(data.subCountText)
},
description: stream.description,
duration: stream.lengthSeconds * 1000,
id: stream.videoId,
live: stream.liveNow,
description: data.description,
duration: data.lengthSeconds * 1000,
id: data.videoId,
live: data.liveNow,
thumbnail: thumbnail,
title: stream.title,
uploaded: new Date(stream.published * 1000),
views: stream.viewCount
title: data.title,
uploaded: new Date(data.published * 1000),
views: data.viewCount
}
};
}

@ -167,7 +167,7 @@ const adapter: Adapter = {
}).then(Transformer.search);
},
async getStream(videoId) {
async getWatchable(videoId) {
return getStream(url, videoId).then((data) =>
Transformer.stream(data, videoId)
);

@ -6,8 +6,9 @@ import {
VideoItem
} from "@/client/typings/item";
import { SearchResults } from "@/client/typings/search";
import { Stream } from "@/client/typings/stream";
import { Stream, StreamType } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
import { Watchable } from "@/client/typings/watchable";
import {
parseChannelIdFromUrl,
parseVideoIdFromUrl
@ -102,8 +103,11 @@ export default class Transformer {
return { items, nextCursor: data.nextpage };
}
public static stream(data: PipedStream, videoId: string): Stream {
const channelId = parseChannelIdFromUrl(data.uploaderUrl) ?? undefined;
public static stream(data: PipedStream, videoId: string): Watchable {
const streams: Stream[] = [];
if (data.dash) streams.push({ type: StreamType.Dash, url: data.dash });
if (data.hls) streams.push({ type: StreamType.Hls, url: data.hls });
return {
category: data.category,
@ -111,9 +115,10 @@ export default class Transformer {
dislikes: data.dislikes,
likes: data.likes,
related: data.relatedStreams.map(Transformer.item),
streams,
video: {
author: {
id: channelId,
id: parseChannelIdFromUrl(data.uploaderUrl) ?? undefined,
name: data.uploader,
avatar: data.uploaderAvatar,
subscribers: data.uploaderSubscriberCount

@ -5,8 +5,8 @@ import { Comments } from "./typings/comment";
import { SearchResults } from "./typings/search";
import { SearchOptions } from "./typings/search/options";
import { Suggestions } from "./typings/search/suggestions";
import { Stream } from "./typings/stream";
import { Video } from "./typings/video";
import { Watchable } from "./typings/watchable";
export interface RemoteApi {
type: ApiType;
@ -77,10 +77,10 @@ export default class Client {
});
}
public async getStream(videoId: string): Promise<Stream> {
public async getWatchable(videoId: string): Promise<Watchable> {
const adapter = this.getBestAdapter();
return await adapter.getStream(videoId);
return await adapter.getWatchable(videoId);
}
public async getComments(

@ -1,11 +1,31 @@
import { Item } from "./item";
import { Video } from "./video";
export enum StreamType {
Dash,
Hls,
Standard
}
export interface BaseStream {
type: StreamType;
}
export interface DashStream extends BaseStream {
type: StreamType.Dash;
url: string;
}
export interface Stream {
video: Video;
keywords: string[];
likes: number;
dislikes: number;
category: string;
related: Item[];
export interface HlsStream extends BaseStream {
type: StreamType.Hls;
url: string;
}
export interface StandardStream extends BaseStream {
type: StreamType.Standard;
video: VideoStream[];
audio: AudioStream[];
}
export interface VideoStream {}
export interface AudioStream {}
export type Stream = DashStream | HlsStream | StandardStream;

@ -0,0 +1,13 @@
import { Item } from "./item";
import { Stream } from "./stream";
import { Video } from "./video";
export interface Watchable {
video: Video;
streams: Stream[];
keywords: string[];
likes: number;
dislikes: number;
category: string;
related: Item[];
}

@ -1,5 +1,6 @@
import NextLink from "next/link";
import { FC } from "react";
import { FiCheckCircle as VerifiedIcon } from "react-icons/fi";
import { Avatar } from "@nextui-org/avatar";
import { Link } from "@nextui-org/link";
@ -26,7 +27,10 @@ export const Author: FC<{ data: AuthorProps }> = ({ data }) => {
/>
)}
<div className="flex flex-col">
<p className="text-lg text-default-600">{data.name}</p>
<div className="flex flex-row gap-1 items-center">
<p className="text-lg text-default-600">{data.name}</p>
<VerifiedIcon className="text-success" />
</div>
{data.subscribers && (
<p className="text-default-400 tracking-tight">
{formatBigNumber(data.subscribers)} subscribers

@ -7,8 +7,8 @@ export const useClient = (): Client => {
const [client] = useState(
() =>
new Client([
{ baseUrl: "https://invidious.fdn.fr/", type: ApiType.Invidious }
// { baseUrl: "https://pipedapi.kavin.rocks", type: ApiType.Piped }
// { baseUrl: "https://invidious.fdn.fr/", type: ApiType.Invidious }
{ baseUrl: "https://pipedapi.kavin.rocks", type: ApiType.Piped }
])
);

@ -3898,7 +3898,7 @@ deep-is@^0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@4.3.1, deepmerge@^4.2.2:
deepmerge@4.3.1, deepmerge@^4.0.0, deepmerge@^4.2.2:
version "4.3.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
@ -5276,6 +5276,11 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
load-script@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/load-script/-/load-script-1.0.0.tgz#0491939e0bee5643ee494a7e3da3d2bac70c6ca4"
integrity sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==
loader-utils@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
@ -5389,6 +5394,11 @@ make-dir@^3.0.2, make-dir@^3.1.0:
dependencies:
semver "^6.0.0"
memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -5865,7 +5875,7 @@ pretty-bytes@^5.3.0, pretty-bytes@^5.4.1:
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
prop-types@^15.8.1:
prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -5899,6 +5909,11 @@ react-dom@^18:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-fast-compare@^3.0.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
react-icons@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.0.1.tgz#1694e11bfa2a2888cab47dcc30154ce90485feee"
@ -5909,6 +5924,17 @@ react-is@^16.13.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-player@^2.15.1:
version "2.15.1"
resolved "https://registry.yarnpkg.com/react-player/-/react-player-2.15.1.tgz#a5905126a6c5ba2667391a0d72da9f3a1ab57d54"
integrity sha512-ni1XFuYZuhIKKdeFII+KRLmIPcvCYlyXvtSMhNOgssdfnSovmakBtBTW2bxowPvmpKy5BTR4jC4CF79ucgHT+g==
dependencies:
deepmerge "^4.0.0"
load-script "^1.0.0"
memoize-one "^5.1.1"
prop-types "^15.7.2"
react-fast-compare "^3.0.1"
react-remove-scroll-bar@^2.3.4:
version "2.3.5"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz#cd2543b3ed7716c7c5b446342d21b0e0b303f47c"

Loading…
Cancel
Save