Started work on #9, many small changes

main
Guus van Meerveld 2 years ago
parent 0973689fb7
commit 99d1e1dc90
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

@ -9,6 +9,7 @@
"^next.*", "^next.*",
"^react$", "^react$",
"^react.*", "^react.*",
"^axios.*",
"^@emotion/.*", "^@emotion/.*",
"^@mui/material/.*", "^@mui/material/.*",
"^@mui/.*", "^@mui/.*",

@ -12,7 +12,8 @@ module.exports = {
env: { env: {
NEXT_PUBLIC_GITHUB_URL: packageInfo.repository.url, NEXT_PUBLIC_GITHUB_URL: packageInfo.repository.url,
NEXT_PUBLIC_APP_NAME: process.env.APP_NAME ?? packageInfo.displayName, NEXT_PUBLIC_APP_NAME: process.env.APP_NAME ?? packageInfo.displayName,
NEXT_PUBLIC_DEFAULT_SERVER: process.env.DEFAULT_SERVER ?? "vid.puffyan.us" NEXT_PUBLIC_DEFAULT_SERVER:
process.env.DEFAULT_SERVER ?? "invidious.privacy.gd"
}, },
basePath: process.env.BASE_PATH ?? "", basePath: process.env.BASE_PATH ?? "",
trailingSlash: !(process.env.CI == "true") trailingSlash: !(process.env.CI == "true")

@ -0,0 +1,71 @@
import Link from "next/link";
import { FC } from "react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { abbreviateNumber, formatNumber } from "@src/utils/";
import { Channel as ChannelModel } from "@interfaces/api/search";
const Channel: FC<{ channel: ChannelModel }> = ({ channel }) => {
const theme = useTheme();
const thumbnail = channel.authorThumbnails.find(
(thumbnail) => thumbnail.height == 512
)?.url as string;
return (
<Link
passHref
href={{ pathname: "/channel", query: { c: channel.authorId } }}
>
<a>
<Paper sx={{ my: 2 }}>
<Box
sx={{
p: 3,
display: { md: "flex", xs: "block" },
alignItems: "center"
}}
>
<Avatar
sx={{
width: 96,
height: 96,
mx: { md: 3, xs: "auto" },
mb: { md: 0, xs: 2 }
}}
src={thumbnail}
alt={channel.author}
/>
<Box sx={{ textAlign: { md: "left", xs: "center" } }}>
<Typography variant="h5">{channel.author}</Typography>
<Typography
variant="subtitle1"
color={theme.palette.text.secondary}
>
{abbreviateNumber(channel.subCount)} subscribers {" "}
{formatNumber(channel.videoCount)} videos
</Typography>
<Typography
variant="subtitle1"
color={theme.palette.text.secondary}
>
{channel.description}
</Typography>
</Box>
</Box>
</Paper>
</a>
</Link>
);
};
export default Channel;

@ -34,6 +34,8 @@ const Navbar: FC = () => {
const [drawerIsOpen, setDrawerState] = useState(false); const [drawerIsOpen, setDrawerState] = useState(false);
const router = useRouter(); const router = useRouter();
const query = router.query["search_query"];
const toggleDrawer = const toggleDrawer =
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if ( if (
@ -141,6 +143,7 @@ const Navbar: FC = () => {
</SearchIconWrapper> </SearchIconWrapper>
<form action={`${router.basePath}/results`} method="get"> <form action={`${router.basePath}/results`} method="get">
<StyledInputBase <StyledInputBase
defaultValue={query ?? ""}
name="search_query" name="search_query"
placeholder="Search…" placeholder="Search…"
inputProps={{ "aria-label": "search" }} inputProps={{ "aria-label": "search" }}
@ -150,12 +153,22 @@ const Navbar: FC = () => {
</Box> </Box>
<Link href="/settings" passHref> <Link href="/settings" passHref>
<IconButton <a
sx={{ display: { md: "flex", xs: "none" } }} onClick={(e) => {
size="large" if (router.pathname == "/settings") {
e.preventDefault();
router.back();
}
}}
> >
<Settings /> <IconButton
</IconButton> sx={{ display: { md: "flex", xs: "none" } }}
size="large"
>
<Settings />
</IconButton>
</a>
</Link> </Link>
</Toolbar> </Toolbar>
</AppBar> </AppBar>

@ -0,0 +1,94 @@
import Link from "next/link";
import { FC } from "react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Grid from "@mui/material/Grid";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { abbreviateNumber } from "@src/utils/";
import VideoModel from "@interfaces/video";
import { useAuthorThumbnail } from "@utils/requests";
const Video: FC<{ video: VideoModel }> = ({ video }) => {
const theme = useTheme();
const {
ref,
isLoading,
thumbnail: authorThumbnail
} = useAuthorThumbnail(video.author.id, 176);
return (
<Paper sx={{ my: 2 }}>
<Grid container spacing={0}>
<Grid item md={4}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
style={{
width: "100%",
height: "100%",
borderRadius: "4px"
}}
src={video.thumbnail}
alt="thumbnail"
loading="lazy"
/>
</Grid>
<Grid item md={8} sx={{ padding: 3, width: "100%" }}>
<Link href={{ pathname: "/watch", query: { v: video.id } }}>
<a>
<Typography gutterBottom noWrap variant="h5">
{video.title}
</Typography>
</a>
</Link>
<Typography
gutterBottom
variant="subtitle1"
color={theme.palette.text.secondary}
>
{abbreviateNumber(video.views)} Views Published{" "}
{video.published.text}
</Typography>
<Typography
gutterBottom
variant="subtitle1"
color={theme.palette.text.secondary}
>
{video.description.text}
</Typography>
<Link
passHref
href={{ pathname: "/channel", query: { c: video.author.id } }}
>
<a>
<Box ref={ref} sx={{ display: "flex", alignItems: "center" }}>
{isLoading && <CircularProgress />}
{!isLoading && (
<Avatar src={authorThumbnail} alt={video.author.name} />
)}
<Typography
sx={{ ml: 2 }}
variant="subtitle1"
color={theme.palette.text.secondary}
>
{video.author.name}
</Typography>
</Box>
</a>
</Link>
</Grid>
</Grid>
</Paper>
);
};
export default Video;

@ -1,12 +1,7 @@
import axios, { AxiosError } from "axios";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FC, useEffect } from "react"; import { FC } from "react";
import { useInView } from "react-intersection-observer/";
import { useQuery } from "react-query";
import Avatar from "@mui/material/Avatar"; import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
@ -17,94 +12,71 @@ import CardMedia from "@mui/material/CardMedia";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { abbreviateNumber } from "@src/utils"; import { abbreviateNumber } from "@src/utils";
import { Video as VideoModel } from "@interfaces/video"; import VideoModel from "@interfaces/video";
import { useSettings } from "@utils/hooks";
interface Channel { import { useAuthorThumbnail } from "@utils/requests";
authorThumbnails: { url: string; width: number; height: number }[];
}
const Video: FC<VideoModel> = ({ const Video: FC<VideoModel> = (video) => {
thumbnail, const theme = useTheme();
title,
id,
author,
views,
published
}) => {
const [settings] = useSettings();
const { isLoading, error, data, refetch, isFetched } = useQuery< const {
Channel, ref,
AxiosError<Error> isLoading,
>( thumbnail: authorThumbnail
["channelData", author.id], } = useAuthorThumbnail(video.author.id, 100);
() =>
axios
.get(
`https://${settings.invidiousServer}/api/v1/channels/${author.id}`,
{
params: {
fields: ["authorThumbnails"].join(",")
}
}
)
.then((res) => res.data),
{ enabled: false }
);
const { ref, inView } = useInView({
threshold: 0
});
const router = useRouter(); const router = useRouter();
useEffect(() => {
if (inView && !isFetched) refetch();
}, [inView, isFetched, refetch]);
return ( return (
<Card sx={{ width: "100%" }}> <Card sx={{ width: "100%" }}>
<CardActionArea <CardActionArea
onClick={() => router.push({ pathname: "/watch", query: { v: id } })} onClick={() =>
router.push({ pathname: "/watch", query: { v: video.id } })
}
> >
<CardMedia <CardMedia
height="270" height="270"
component="img" component="img"
image={thumbnail} image={video.thumbnail}
alt="video thumbnail" alt="video thumbnail"
/> />
<CardContent> <CardContent>
<Tooltip title={title}> <Tooltip title={video.title}>
<Typography noWrap gutterBottom variant="h6" component="div"> <Typography noWrap gutterBottom variant="h6" component="div">
{title} {video.title}
</Typography> </Typography>
</Tooltip> </Tooltip>
<Link passHref href={`/channel/${author.id}`}> <Link passHref href={`/channel/${video.author.id}`}>
<Box ref={ref} sx={{ display: "flex", alignItems: "center" }}> <a>
{isLoading && <CircularProgress sx={{ mr: 2 }} />} <Box ref={ref} sx={{ display: "flex", alignItems: "center" }}>
{data && ( {isLoading && <CircularProgress sx={{ mr: 2 }} />}
<Avatar {!isLoading && (
sx={{ mr: 2 }} <Avatar
alt={author.name} sx={{ mr: 2 }}
src={ alt={video.author.name}
data.authorThumbnails.find( src={authorThumbnail}
(thumbnail) => thumbnail.width == 100 />
)?.url as string )}
} <Typography
/> color={theme.palette.text.secondary}
)} variant="subtitle1"
<Typography color="text.secondary" variant="subtitle1"> >
{author.name} {video.author.name}
</Typography> </Typography>
</Box> </Box>
</a>
</Link> </Link>
<Typography sx={{ mt: 2 }} color="text.secondary" variant="body2"> <Typography
{abbreviateNumber(views)} Views Published {published.text} sx={{ mt: 2 }}
color={theme.palette.text.secondary}
variant="body2"
>
{abbreviateNumber(video.views)} Views Published{" "}
{video.published.text}
</Typography> </Typography>
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>

@ -0,0 +1,4 @@
a {
text-decoration: none;
color: unset;
}

@ -1,3 +1,22 @@
export interface Error { export interface Error {
error: string; error: string;
} }
export interface Thumbnail {
url: string;
width: number;
height: number;
quality?: Quality;
}
export enum Quality {
Default = "default",
End = "end",
High = "high",
Maxres = "maxres",
Maxresdefault = "maxresdefault",
Medium = "medium",
Middle = "middle",
Sddefault = "sddefault",
Start = "start"
}

@ -0,0 +1,83 @@
import { Thumbnail } from "@interfaces/api";
import VideoTrending from "@interfaces/api/trending";
interface Results {
type: Type;
}
export interface ChannelResult extends Results {
type: "channel";
author: string;
authorId: string;
authorUrl: string;
authorThumbnails: Thumbnail[];
subCount: number;
videoCount: number;
description: string;
descriptionHtml: string;
}
export interface VideoResult extends Results {
type: "video";
title: string;
author: string;
authorId: string;
authorUrl: string;
description: string;
descriptionHtml: string;
videoId: string;
videoThumbnails: Thumbnail[];
viewCount: number;
published: number;
publishedText: string;
lengthSeconds: number;
liveNow: boolean;
premium: boolean;
}
export interface PlaylistResult extends Results {
type: "playlist";
title: string;
playlistId: string;
author: string;
authorId: string;
authorUrl: string;
videoCount: number;
videos: Video[];
}
export interface CategoryResult extends Results {
type: "category";
title: string;
contents: VideoTrending[];
}
export interface Content {
type: Type;
title: string;
videoId: string;
author: string;
authorId: string;
authorUrl: string;
videoThumbnails: Thumbnail[];
description: string;
descriptionHtml: string;
viewCount: number;
published: number;
publishedText: string;
lengthSeconds: number;
liveNow: boolean;
premium: boolean;
isUpcoming: boolean;
}
export type Type = "category" | "channel" | "playlist" | "video";
export interface Video {
title: string;
videoId: string;
lengthSeconds: number;
videoThumbnails: Thumbnail[];
}
export default Results;

@ -1,3 +1,5 @@
import { Thumbnail } from "@interfaces/api";
interface Trending { interface Trending {
type: string; type: string;
title: string; title: string;
@ -5,7 +7,7 @@ interface Trending {
author: string; author: string;
authorId: string; authorId: string;
authorUrl: string; authorUrl: string;
videoThumbnails: VideoThumbnail[]; videoThumbnails: Thumbnail[];
description: string; description: string;
descriptionHtml: string; descriptionHtml: string;
viewCount: number; viewCount: number;
@ -17,11 +19,4 @@ interface Trending {
isUpcoming: boolean; isUpcoming: boolean;
} }
interface VideoThumbnail {
quality: string;
url: string;
width: number;
height: number;
}
export default Trending; export default Trending;

@ -1,3 +1,11 @@
import { Thumbnail } from "@interfaces/api";
import {
AdaptiveFormat,
Caption,
FormatStream,
RecommendedVideo
} from "@interfaces/video";
export interface Video { export interface Video {
type: string; type: string;
title: string; title: string;
@ -37,83 +45,6 @@ export interface Video {
recommendedVideos: RecommendedVideo[]; recommendedVideos: RecommendedVideo[];
} }
export interface AdaptiveFormat {
index: string;
bitrate: string;
init: string;
url: string;
itag: string;
type: string;
clen: string;
lmt: string;
projectionType: ProjectionType;
fps?: number;
container?: Container;
encoding?: string;
resolution?: string;
qualityLabel?: string;
}
export interface FormatStream {
url: string;
itag: string;
type: string;
quality: string;
fps: number;
container: string;
encoding: string;
resolution: string;
qualityLabel: string;
size: string;
}
enum Container {
M4A = "m4a",
Mp4 = "mp4",
Webm = "webm"
}
enum ProjectionType {
Rectangular = "RECTANGULAR"
}
interface Thumbnail {
url: string;
width: number;
height: number;
quality?: Quality;
}
enum Quality {
Default = "default",
End = "end",
High = "high",
Maxres = "maxres",
Maxresdefault = "maxresdefault",
Medium = "medium",
Middle = "middle",
Sddefault = "sddefault",
Start = "start"
}
export interface Caption {
label: string;
language_code: string;
url: string;
}
export interface RecommendedVideo {
videoId: string;
title: string;
videoThumbnails: Thumbnail[];
author: string;
authorUrl: string;
authorId: string;
lengthSeconds: number;
viewCountText: string;
viewCount: number;
}
interface Storyboard { interface Storyboard {
url: string; url: string;
templateUrl: string; templateUrl: string;

@ -1,11 +1,6 @@
import { import { Thumbnail } from "@interfaces/api";
AdaptiveFormat,
Caption, interface Video {
FormatStream,
RecommendedVideo
} from "@interfaces/api/video";
export interface Video {
thumbnail: string; thumbnail: string;
title: string; title: string;
description: { description: {
@ -17,6 +12,7 @@ export interface Video {
name: string; name: string;
id: string; id: string;
url: string; url: string;
thumbnail?: string;
}; };
views: number; views: number;
published: { published: {
@ -26,30 +22,79 @@ export interface Video {
length: number; length: number;
live: boolean; live: boolean;
premium: boolean; premium: boolean;
} keywords?: string[];
likes?: number;
export interface FullVideo extends Video { dislikes?: number;
keywords: string[]; familyFriendly?: boolean;
likes: number; genre?: {
dislikes: number;
familyFriendly: boolean;
genre: {
type: string; type: string;
url: string; url: string;
}; };
author: { subscriptions?: string;
thumbnail: string; rating?: number;
name: string; premiered?: Date;
id: string; recommendedVideos?: RecommendedVideo[];
url: string; adaptiveFormats?: AdaptiveFormat[];
}; formatStreams?: FormatStream[];
subscriptions: string; captions?: Caption[];
rating: number; }
premiered: Date | undefined;
recommendedVideos: RecommendedVideo[]; export interface RecommendedVideo {
adaptiveFormats: AdaptiveFormat[]; videoId: string;
formatStreams: FormatStream[]; title: string;
captions: Caption[]; videoThumbnails: Thumbnail[];
author: string;
authorUrl: string;
authorId: string;
lengthSeconds: number;
viewCountText: string;
viewCount: number;
}
export interface Caption {
label: string;
language_code: string;
url: string;
}
enum ProjectionType {
Rectangular = "RECTANGULAR"
}
export interface AdaptiveFormat {
index: string;
bitrate: string;
init: string;
url: string;
itag: string;
type: string;
clen: string;
lmt: string;
projectionType: ProjectionType;
fps?: number;
container?: Container;
encoding?: string;
resolution?: string;
qualityLabel?: string;
}
export interface FormatStream {
url: string;
itag: string;
type: string;
quality: string;
fps: number;
container: string;
encoding: string;
resolution: string;
qualityLabel: string;
size: string;
}
enum Container {
M4A = "m4a",
Mp4 = "mp4",
Webm = "webm"
} }
export default Video; export default Video;

@ -5,7 +5,7 @@ import type { DefaultSeoProps } from "next-seo";
const name = process.env.NEXT_PUBLIC_APP_NAME; const name = process.env.NEXT_PUBLIC_APP_NAME;
const SEO: DefaultSeoProps = { const SEO: DefaultSeoProps = {
titleTemplate: `%s | ${name}`, titleTemplate: `%s - ${name}`,
defaultTitle: name, defaultTitle: name,
description: packageInfo.description, description: packageInfo.description,
openGraph: { openGraph: {

@ -10,6 +10,7 @@ import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import "@src/globals.css";
import SEO from "@src/next-seo.config"; import SEO from "@src/next-seo.config";
import createTheme from "@src/theme"; import createTheme from "@src/theme";

@ -0,0 +1,103 @@
import NotFound from "./404";
import { NextPage } from "next";
import { NextSeo } from "next-seo";
import { useRouter } from "next/router";
import { useQuery } from "react-query";
import axios from "axios";
import Container from "@mui/material/Container";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import Result, {
CategoryResult,
ChannelResult,
PlaylistResult,
VideoResult
} from "@interfaces/api/search";
import { apiToVideo } from "@utils/conversions";
import { useSettings } from "@utils/hooks";
import Channel from "@components/Channel/Inline";
import Layout from "@components/Layout";
import Loading from "@components/Loading";
import Video from "@components/Video/Inline";
const Results: NextPage = () => {
const router = useRouter();
const [settings] = useSettings();
const query = router.query["search_query"];
const { data, isLoading } = useQuery<Result[] | undefined>(
["searchResultsFor", query],
() =>
query
? axios
.get(`https://${settings.invidiousServer}/api/v1/search`, {
params: {
q: query,
type: "all"
}
})
.then((res) => res.data)
: undefined
);
if (!router.isReady || isLoading)
return (
<>
<NextSeo title="Searching..." />
<Layout>
<Loading />
</Layout>
</>
);
if (!query) return <NotFound />;
const channels = data?.filter((result) => result.type == "channel") as
| ChannelResult[]
| undefined;
const videos = data?.filter((result) => result.type == "video") as
| VideoResult[]
| undefined;
// const categories = data?.filter()
return (
<>
<NextSeo title={query as string} />
<Layout>
<Container sx={{ py: 2 }}>
{channels && channels.length != 0 && (
<>
<Typography variant="h5">Channels</Typography>
{channels.map((channel, i) => (
<Channel key={i} channel={channel} />
))}
<Divider sx={{ my: 4 }} />
</>
)}
{videos && videos.length != 0 && (
<>
<Typography variant="h5">Videos</Typography>
{videos.map((video, i) => (
<Video key={i} video={apiToVideo(video)} />
))}
<Divider sx={{ my: 4 }} />
</>
)}
</Container>
</Layout>
</>
);
};
export default Results;

@ -1,5 +1,3 @@
import axios, { AxiosError } from "axios";
import { NextPage } from "next"; import { NextPage } from "next";
import { NextSeo } from "next-seo"; import { NextSeo } from "next-seo";
@ -7,14 +5,16 @@ import { useState } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import axios, { AxiosError } from "axios";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Chip from "@mui/material/Chip"; import Chip from "@mui/material/Chip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { Error } from "@interfaces/api"; import { Error } from "@interfaces/api";
import TrendingModel from "@interfaces/api/trending"; import VideoTrending from "@interfaces/api/trending";
import { trendingToVideo } from "@utils/conversions"; import { apiToVideo } from "@utils/conversions";
import { useSettings } from "@utils/hooks"; import { useSettings } from "@utils/hooks";
import Layout from "@components/Layout"; import Layout from "@components/Layout";
@ -26,10 +26,10 @@ const Trending: NextPage = () => {
const [settings] = useSettings(); const [settings] = useSettings();
const { isLoading, error, data, refetch } = useQuery< const { isLoading, error, data } = useQuery<
TrendingModel[], VideoTrending[],
AxiosError<Error> AxiosError<Error>
>("trendingData", (context) => >("trendingData", () =>
axios axios
.get(`https://${settings.invidiousServer}/api/v1/trending`, { .get(`https://${settings.invidiousServer}/api/v1/trending`, {
params: { params: {
@ -84,7 +84,7 @@ const Trending: NextPage = () => {
); );
})} })}
</Box> </Box>
<Grid videos={data.map(trendingToVideo)} /> <Grid videos={data.map(apiToVideo)} />
</> </>
)} )}
</Box> </Box>

@ -1,5 +1,4 @@
import NotFound from "./404"; import NotFound from "./404";
import axios, { AxiosError } from "axios";
import { NextPage } from "next"; import { NextPage } from "next";
import { NextSeo } from "next-seo"; import { NextSeo } from "next-seo";
@ -9,8 +8,10 @@ import { useEffect } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import axios, { AxiosError } from "axios";
import { Error } from "@interfaces/api"; import { Error } from "@interfaces/api";
import Video from "@interfaces/api/video"; import VideoAPI from "@interfaces/api/video";
import { videoToVideo } from "@utils/conversions"; import { videoToVideo } from "@utils/conversions";
import { useSettings } from "@utils/hooks"; import { useSettings } from "@utils/hooks";
@ -19,29 +20,35 @@ import Layout from "@components/Layout";
import Loading from "@components/Loading"; import Loading from "@components/Loading";
const Watch: NextPage = () => { const Watch: NextPage = () => {
const router = useRouter(); const { query, isReady } = useRouter();
const videoId = router.query["v"]; const videoId = query["v"];
const [settings] = useSettings(); const [settings] = useSettings();
const { isLoading, error, data, refetch } = useQuery< const { isLoading, error, data } = useQuery<
Video, VideoAPI | null,
AxiosError<Error> AxiosError<Error>
>( >(["videoData", videoId], () =>
["videoData", videoId], videoId
() => ? axios
axios .get(`https://${settings.invidiousServer}/api/v1/videos/${videoId}`, {
.get(`https://${settings.invidiousServer}/api/v1/videos/${videoId}`, { params: {}
params: {} })
}) .then((res) => res.data)
.then((res) => res.data), : null
{ enabled: false }
); );
useEffect(() => { if (!isReady || isLoading) {
if (videoId) refetch(); return (
}, [videoId, refetch]); <>
<NextSeo title="Loading video..." />
<Layout>
<Loading />
</Layout>
</>
);
}
if (!videoId) { if (!videoId) {
return <NotFound />; return <NotFound />;
@ -49,14 +56,9 @@ const Watch: NextPage = () => {
return ( return (
<> <>
<NextSeo <NextSeo title={data ? data.title : "Not Found"} />
title={data ? data.title : isLoading ? "Loading video..." : "Not Found"}
/> <Layout>{}</Layout>
<Layout>
{isLoading && <Loading />}
{}
</Layout>
</> </>
); );
}; };

@ -1,8 +1,10 @@
import TrendingAPI from "@interfaces/api/trending"; import { Quality } from "@interfaces/api";
import { VideoResult } from "@interfaces/api/search";
import VideoTrending from "@interfaces/api/trending";
import VideoAPI from "@interfaces/api/video"; import VideoAPI from "@interfaces/api/video";
import Video, { FullVideo } from "@interfaces/video"; import Video from "@interfaces/video";
export const trendingToVideo = (item: TrendingAPI): Video => { export const apiToVideo = (item: VideoTrending | VideoResult): Video => {
return { return {
title: item.title, title: item.title,
description: { description: {
@ -24,12 +26,12 @@ export const trendingToVideo = (item: TrendingAPI): Video => {
live: item.liveNow, live: item.liveNow,
premium: item.premium, premium: item.premium,
thumbnail: item.videoThumbnails.find( thumbnail: item.videoThumbnails.find(
(thumbnail) => thumbnail.quality == "maxresdefault" (thumbnail) => thumbnail.quality == Quality.Maxresdefault
)?.url as string )?.url as string
}; };
}; };
export const videoToVideo = (item: VideoAPI): FullVideo => { export const videoToVideo = (item: VideoAPI): Video => {
return { return {
title: item.title, title: item.title,
views: item.viewCount, views: item.viewCount,

@ -8,11 +8,12 @@ export const abbreviateNumber = (value: number): string => {
suffixNum++; suffixNum++;
} }
value = parseInt(value.toPrecision(3)); return `${value.toPrecision(4)}${suffixes[suffixNum]}`;
return `${value}${suffixes[suffixNum]}`;
}; };
export const formatNumber = (number: number) =>
number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,");
export const toCamelCase = (string: string): string => export const toCamelCase = (string: string): string =>
string string
.replace(/(?:^\w|[A-Z]|\b\w)/g, (leftTrim: string, index: number) => .replace(/(?:^\w|[A-Z]|\b\w)/g, (leftTrim: string, index: number) =>

@ -0,0 +1,57 @@
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { useQuery } from "react-query";
import axios, { AxiosError } from "axios";
import { useSettings } from "@utils/hooks";
interface Channel {
authorThumbnails: { url: string; width: number; height: number }[];
}
export const useAuthorThumbnail = (
authorId: string,
quality: 32 | 48 | 76 | 100 | 176 | 512
): {
isLoading: boolean;
error: AxiosError<Error> | null;
ref: (node?: Element) => void;
thumbnail?: string;
} => {
const [settings] = useSettings();
const { ref, inView } = useInView({
threshold: 0
});
const { isLoading, error, data, isFetched, refetch } = useQuery<
Channel,
AxiosError<Error>
>(
["channelData", authorId],
() =>
axios
.get(
`https://${settings.invidiousServer}/api/v1/channels/${authorId}`,
{
params: {
fields: ["authorThumbnails"].join(",")
}
}
)
.then((res) => res.data),
{ enabled: false }
);
useEffect(() => {
if (!isFetched && inView) refetch();
}, [refetch, isFetched, inView]);
const thumbnail = data?.authorThumbnails.find(
(thumbnail) => thumbnail.width == quality
)?.url;
return { isLoading, error, ref, thumbnail };
};
Loading…
Cancel
Save