diff --git a/src/app/watch/Channel.tsx b/src/app/watch/Channel.tsx
deleted file mode 100644
index 7b278fd..0000000
--- a/src/app/watch/Channel.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client";
-
-import NextLink from "next/link";
-
-import { Avatar } from "@nextui-org/avatar";
-import { Link } from "@nextui-org/link";
-
-import { Author } from "@/client/typings/author";
-import formatBigNumber from "@/utils/formatBigNumber";
-import { channelUrl } from "@/utils/urls";
-
-import { Component } from "@/typings/component";
-
-export const Channel: Component<{
- data: Author;
-}> = ({ data }) => {
- const url = data?.id ? channelUrl(data.id) : undefined;
-
- return (
-
-
+ {data.enabled && (
+ <>
+ {data.data.map((comment) => (
+
+ ))}
+ >
+ )}
+ {!data.enabled && (
+
+
+
Comments on this video are disabled
+
+ )}
+
+ >
+ )}
+ {!data && isLoading && (
+
- {description.map((item) => {
- switch (item.type) {
- case ItemType.Tokens:
- return {item.content};
-
- case ItemType.Link:
- return (
-
- {item.text ?? item.href}
-
- );
-
- case ItemType.Timestamp:
- return (
-
- {formatDuration(item.duration * 1000)}
-
- );
-
- case ItemType.Linebreak:
- return
;
- }
- })}
+
:
}
diff --git a/src/app/watch/HighlightRenderer.tsx b/src/app/watch/HighlightRenderer.tsx
new file mode 100644
index 0000000..c4abc94
--- /dev/null
+++ b/src/app/watch/HighlightRenderer.tsx
@@ -0,0 +1,38 @@
+import { FC, Fragment } from "react";
+
+import { Link } from "@nextui-org/link";
+
+import formatDuration from "@/utils/formatDuration";
+import { Item, ItemType } from "@/utils/highlight";
+
+export const HighlightRenderer: FC<{ highlighted: Item[] }> = ({
+ highlighted
+}) => {
+ return (
+ <>
+ {highlighted.map((item) => {
+ switch (item.type) {
+ case ItemType.Tokens:
+ return
{item.content};
+
+ case ItemType.Link:
+ return (
+
+ {item.text ?? item.href}
+
+ );
+
+ case ItemType.Timestamp:
+ return (
+
+ {formatDuration(item.duration * 1000)}
+
+ );
+
+ case ItemType.Linebreak:
+ return
;
+ }
+ })}
+ >
+ );
+};
diff --git a/src/app/watch/Watch.tsx b/src/app/watch/Watch.tsx
index e02df0a..dbef33d 100644
--- a/src/app/watch/Watch.tsx
+++ b/src/app/watch/Watch.tsx
@@ -10,10 +10,11 @@ import { useClient } from "@/hooks/useClient";
import formatBigNumber from "@/utils/formatBigNumber";
+import { Author } from "@/components/Author";
import { Container } from "@/components/Container";
import { LoadingPage } from "@/components/LoadingPage";
-import { Channel } from "./Channel";
+import { Comments } from "./Comments";
import { Description } from "./Description";
import { Player } from "./Player";
import { Related } from "./Related";
@@ -37,6 +38,19 @@ export const Watch: Component = () => {
enabled: !videoIdIsInvalid
});
+ const {
+ data: comments,
+ isLoading: isLoadingComments,
+ refetch: refetchComments,
+ error: commentsError
+ } = useQuery({
+ queryKey: ["comments", videoId],
+ queryFn: () => {
+ return client.getComments(videoId);
+ },
+ enabled: !videoIdIsInvalid
+ });
+
if (error) console.log(error);
return (
@@ -46,7 +60,7 @@ export const Watch: Component = () => {
-
+
{data.video.title}
@@ -57,7 +71,7 @@ export const Watch: Component = () => {
-
+
@@ -67,17 +81,28 @@ export const Watch: Component = () => {
{data.keywords.length !== 0 && (
-
-
Keywords:
- {data.keywords.map((keyword) => (
-
{keyword}
- ))}
+
+
Keywords:
+
+ {data.keywords.map((keyword) => (
+ {keyword}
+ ))}
+
)}
-
Comments
+
+
+
+
-
)}
diff --git a/src/client/adapters/index.ts b/src/client/adapters/index.ts
index 8ed8778..be9d91c 100644
--- a/src/client/adapters/index.ts
+++ b/src/client/adapters/index.ts
@@ -1,3 +1,4 @@
+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";
@@ -11,6 +12,8 @@ export interface ConnectedAdapter {
getSearch(query: string, options?: SearchOptions): Promise
;
getStream(videoId: string): Promise;
+
+ getComments(videoId: string, repliesToken?: string): Promise;
}
export default interface Adapter {
diff --git a/src/client/adapters/invidious/index.ts b/src/client/adapters/invidious/index.ts
index e6f6362..17d5f52 100644
--- a/src/client/adapters/invidious/index.ts
+++ b/src/client/adapters/invidious/index.ts
@@ -5,6 +5,7 @@ import ky from "ky";
import Adapter, { ApiType } from "@/client/adapters";
import Transformer from "./transformer";
+import Comments, { CommentsModel } from "./typings/comments";
import Search, { SearchModel } from "./typings/search";
import Suggestions, { SuggestionsModel } from "./typings/search/suggestions";
import Stream, { StreamModel } from "./typings/stream";
@@ -95,6 +96,30 @@ const getVideo = async (baseUrl: string, videoId: string): Promise => {
return data;
};
+const getComments = async (
+ baseUrl: string,
+ videoId: string,
+ continuation?: string
+): Promise => {
+ const url = new URL(apiPath("comments", videoId), baseUrl);
+
+ const searchParams = new URLSearchParams();
+
+ searchParams.append("source", "youtube");
+
+ if (continuation) searchParams.append("continuation", continuation);
+
+ const response = await ky.get(url, {
+ searchParams
+ });
+
+ const json = await response.json();
+
+ const data = CommentsModel.parse(json);
+
+ return data;
+};
+
const adapter: Adapter = {
apiType: ApiType.Invidious,
@@ -120,6 +145,12 @@ const adapter: Adapter = {
async getStream(videoId) {
return getVideo(url, videoId).then(Transformer.stream);
+ },
+
+ async getComments(videoId, repliesToken) {
+ return getComments(url, videoId, repliesToken).then(
+ Transformer.comments
+ );
}
};
}
diff --git a/src/client/adapters/invidious/transformer.ts b/src/client/adapters/invidious/transformer.ts
index a3c5380..c8d2365 100644
--- a/src/client/adapters/invidious/transformer.ts
+++ b/src/client/adapters/invidious/transformer.ts
@@ -1,3 +1,4 @@
+import { Comments } from "@/client/typings/comment";
import {
ChannelItem,
Item,
@@ -7,7 +8,9 @@ import {
import { Suggestions } from "@/client/typings/search/suggestions";
import { Stream } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
+import { parseSubscriberCount } from "@/utils/parseSubscriberCount";
+import InvidiousComments from "./typings/comments";
import InvidiousSearch from "./typings/search";
import InvidiousSuggestions from "./typings/search/suggestions";
import InvidiousStream, {
@@ -156,7 +159,8 @@ export default class Transformer {
author: {
id: stream.authorId,
name: stream.author,
- avatar: stream.authorThumbnails[0].url
+ avatar: stream.authorThumbnails[0].url,
+ subscribers: parseSubscriberCount(stream.subCountText)
},
description: stream.description,
duration: stream.lengthSeconds * 1000,
@@ -169,4 +173,28 @@ export default class Transformer {
}
};
}
+
+ public static comments(comments: InvidiousComments): Comments {
+ return {
+ enabled: true,
+ count: comments.commentCount,
+ data: comments.comments.map((comment) => ({
+ id: comment.commentId,
+ message: comment.content,
+ likes: comment.likeCount,
+ edited: comment.isEdited,
+ written: new Date(comment.published * 1000),
+ author: {
+ name: comment.author,
+ id: comment.authorId,
+ handle: comment.authorUrl,
+ avatar: comment.authorThumbnails[0].url
+ },
+ videoUploaderLiked: !!comment.creatorHeart,
+ videoUploaderReplied: false,
+ pinned: comment.isPinned,
+ repliesToken: comment.replies?.continuation
+ }))
+ };
+ }
}
diff --git a/src/client/adapters/invidious/typings/comments.ts b/src/client/adapters/invidious/typings/comments.ts
new file mode 100644
index 0000000..7068825
--- /dev/null
+++ b/src/client/adapters/invidious/typings/comments.ts
@@ -0,0 +1,44 @@
+import z from "zod";
+
+import { AuthorThumbnailModel } from "./thumbnail";
+
+export const CommentModel = z.object({
+ author: z.string(),
+ authorThumbnails: AuthorThumbnailModel.array(),
+ authorId: z.string(),
+ authorUrl: z.string(),
+
+ isEdited: z.boolean(),
+ isPinned: z.boolean(),
+
+ content: z.string(),
+ contentHtml: z.string(),
+ published: z.number(),
+ publishedText: z.string(),
+ likeCount: z.number(),
+ commentId: z.string(),
+ authorIsChannelOwner: z.boolean(),
+ creatorHeart: z
+ .object({
+ creatorThumbnail: z.string(),
+ creatorName: z.string()
+ })
+ .optional(),
+ replies: z
+ .object({
+ replyCount: z.number(),
+ continuation: z.string()
+ })
+ .optional()
+});
+
+export const CommentsModel = z.object({
+ commentCount: z.number().optional(),
+ videoId: z.string(),
+ comments: CommentModel.array(),
+ continuation: z.string().optional()
+});
+
+type Comments = z.infer;
+
+export default Comments;
diff --git a/src/client/adapters/piped/index.ts b/src/client/adapters/piped/index.ts
index e4088b7..b5d4ee7 100644
--- a/src/client/adapters/piped/index.ts
+++ b/src/client/adapters/piped/index.ts
@@ -7,6 +7,7 @@ import Adapter, { ApiType } from "@/client/adapters";
import { Suggestions } from "@/client/typings/search/suggestions";
import Transformer from "./transformer";
+import Comments, { CommentsModel } from "./typings/comments";
import Search, { SearchModel } from "./typings/search";
import Stream, { StreamModel } from "./typings/stream";
import Video, { VideoModel } from "./typings/video";
@@ -105,6 +106,28 @@ const getStream = async (
return data;
};
+const getComments = async (
+ apiBaseUrl: string,
+ videoId: string,
+ nextpage?: string
+): Promise => {
+ const searchParams = new URLSearchParams();
+
+ let url;
+ if (nextpage) {
+ url = new URL(path.join("nextpage", "comments", videoId), apiBaseUrl);
+ searchParams.append("nextpage", nextpage);
+ } else url = new URL(path.join("comments", videoId), apiBaseUrl);
+
+ const response = await ky.get(url);
+
+ const json = await response.json();
+
+ const data = CommentsModel.parse(json);
+
+ return data;
+};
+
const adapter: Adapter = {
apiType: ApiType.Piped,
@@ -146,6 +169,10 @@ const adapter: Adapter = {
async getStream(videoId) {
return getStream(url, videoId).then(Transformer.stream);
+ },
+
+ async getComments(videoId) {
+ return getComments(url, videoId).then(Transformer.comments);
}
};
}
diff --git a/src/client/adapters/piped/transformer.ts b/src/client/adapters/piped/transformer.ts
index 7ed7191..6b46596 100644
--- a/src/client/adapters/piped/transformer.ts
+++ b/src/client/adapters/piped/transformer.ts
@@ -1,3 +1,4 @@
+import { Comments } from "@/client/typings/comment";
import {
ChannelItem,
Item,
@@ -11,7 +12,9 @@ import {
parseChannelIdFromUrl,
parseVideoIdFromUrl
} from "@/utils/parseIdFromUrl";
+import { parseRelativeTime } from "@/utils/parseRelativeTime";
+import PipedComments from "./typings/comments";
import PipedItem from "./typings/item";
import PipedSearch from "./typings/search";
import PipedStream from "./typings/stream";
@@ -71,9 +74,7 @@ export default class Transformer {
if (videoId === null) throw new Error("Piped: Missing video id");
- const channelId = parseChannelIdFromUrl(data.uploaderUrl);
-
- if (channelId === null) throw new Error("Piped: Missing video channelId");
+ const channelId = parseChannelIdFromUrl(data.uploaderUrl) ?? undefined;
return {
duration: data.duration * 1000,
@@ -102,9 +103,7 @@ export default class Transformer {
}
public static stream(data: PipedStream): Stream {
- const channelId = parseChannelIdFromUrl(data.uploaderUrl);
-
- if (channelId === null) throw new Error("Piped: Missing channelId");
+ const channelId = parseChannelIdFromUrl(data.uploaderUrl) ?? undefined;
return {
category: data.category,
@@ -130,4 +129,32 @@ export default class Transformer {
}
};
}
+
+ public static comments(data: PipedComments): Comments {
+ return {
+ enabled: !data.disabled,
+ count: data.commentCount,
+ data: data.comments.map((comment) => ({
+ id: comment.commentId,
+ message: comment.commentText,
+ likes: comment.likeCount,
+ edited: false,
+
+ written: parseRelativeTime(comment.commentedTime).toJSDate(),
+
+ author: {
+ name: comment.author,
+ id: parseChannelIdFromUrl(comment.commentorUrl) ?? undefined,
+ avatar: comment.thumbnail,
+ verified: comment.verified
+ },
+
+ pinned: comment.pinned,
+ videoUploaderLiked: comment.hearted,
+ videoUploaderReplied: comment.creatorReplied,
+
+ repliesToken: comment.repliesPage ?? undefined
+ }))
+ };
+ }
}
diff --git a/src/client/adapters/piped/typings/comments.ts b/src/client/adapters/piped/typings/comments.ts
new file mode 100644
index 0000000..c53d3e8
--- /dev/null
+++ b/src/client/adapters/piped/typings/comments.ts
@@ -0,0 +1,45 @@
+import z from "zod";
+
+export const CommentModel = z.object({
+ author: z.string().describe("The name of the author of the comment"),
+ commentId: z.string().describe("The comment ID"),
+ commentText: z.string().describe("The text of the comment"),
+ commentedTime: z.string().describe("The time the comment was made"),
+ commentorUrl: z.string().describe("The URL of the channel of the comment"),
+ hearted: z.boolean().describe("Whether or not the comment has been hearted"),
+ likeCount: z.number().describe("The number of likes the comment has"),
+ pinned: z.boolean().describe("Whether or not the comment is pinned"),
+ thumbnail: z.string().url().describe("The thumbnail of the comment"),
+ verified: z
+ .boolean()
+ .describe("Whether or not the author of the comment is verified"),
+ replyCount: z
+ .number()
+ .transform((number) => (number < 0 ? 0 : number))
+ .optional()
+ .describe("The amount of replies this comment has"),
+ repliesPage: z
+ .string()
+ .nullable()
+ .describe("The token needed to fetch the replies"),
+ creatorReplied: z
+ .boolean()
+ .describe("Whether the creator has replied to the comment")
+});
+
+export const CommentsModel = z.object({
+ comments: CommentModel.array(), // A list of comments
+ commentCount: z
+ .number()
+ .transform((number) => (number < 0 ? 0 : number))
+ .optional(),
+ disabled: z.boolean(), // Whether or not the comments are disabled
+ nextpage: z
+ .string()
+ .nullable()
+ .describe("A JSON encoded page, which is used for the nextpage endpoint.")
+});
+
+type Comments = z.infer;
+
+export default Comments;
diff --git a/src/client/index.ts b/src/client/index.ts
index 040e8ca..aa6bcef 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -1,6 +1,7 @@
import Adapter, { ApiType, ConnectedAdapter } from "./adapters";
import InvidiousAdapter from "./adapters/invidious";
import PipedAdapter from "./adapters/piped";
+import { Comments } from "./typings/comment";
import { SearchResults } from "./typings/search";
import { SearchOptions } from "./typings/search/options";
import { Suggestions } from "./typings/search/suggestions";
@@ -81,4 +82,13 @@ export default class Client {
return await adapter.getStream(videoId);
}
+
+ public async getComments(
+ videoId: string,
+ repliesToken?: string
+ ): Promise {
+ const adapter = this.getBestAdapter();
+
+ return await adapter.getComments(videoId, repliesToken);
+ }
}
diff --git a/src/client/typings/author.ts b/src/client/typings/author.ts
index ac2b68e..b3054dc 100644
--- a/src/client/typings/author.ts
+++ b/src/client/typings/author.ts
@@ -1,6 +1,8 @@
export interface Author {
name: string;
id?: string;
+ handle?: string;
avatar?: string;
subscribers?: number;
+ verified?: boolean;
}
diff --git a/src/client/typings/comment.ts b/src/client/typings/comment.ts
new file mode 100644
index 0000000..60fa532
--- /dev/null
+++ b/src/client/typings/comment.ts
@@ -0,0 +1,24 @@
+import { Author } from "./author";
+
+export interface Comment {
+ id: string;
+ message: string;
+ likes: number;
+ edited: boolean;
+
+ written: Date;
+
+ author: Author;
+
+ pinned: boolean;
+ videoUploaderLiked: boolean;
+ videoUploaderReplied: boolean;
+
+ repliesToken?: string;
+}
+
+export interface Comments {
+ enabled: boolean;
+ count?: number;
+ data: Comment[];
+}
diff --git a/src/components/Author.tsx b/src/components/Author.tsx
index fdd5f8b..cb981af 100644
--- a/src/components/Author.tsx
+++ b/src/components/Author.tsx
@@ -19,12 +19,13 @@ export const Author: FC<{ data: AuthorProps }> = ({ data }) => {
)}
-
+
{data.name}
{data.subscribers && (
diff --git a/src/utils/formatUploadedTime.ts b/src/utils/formatUploadedTime.ts
index d22fa4f..c86e31f 100644
--- a/src/utils/formatUploadedTime.ts
+++ b/src/utils/formatUploadedTime.ts
@@ -1,7 +1,7 @@
import { DateTime } from "luxon";
const formatUploadedTime = (uploaded: Date): string => {
- return DateTime.fromJSDate(uploaded).toRelative() ?? "";
+ return DateTime.fromJSDate(uploaded).toRelative() ?? "Unknown time";
};
export default formatUploadedTime;
diff --git a/src/utils/highlight.ts b/src/utils/highlight.ts
index ad4597f..ce669ed 100644
--- a/src/utils/highlight.ts
+++ b/src/utils/highlight.ts
@@ -64,7 +64,7 @@ export enum ItemType {
Timestamp
}
-type Item = Timestamp | Link | Tokens | Linebreak;
+export type Item = Timestamp | Link | Tokens | Linebreak;
export interface ItemPattern {
regex: RegExp;
diff --git a/src/utils/parseRelativeTime.ts b/src/utils/parseRelativeTime.ts
new file mode 100644
index 0000000..fe229c5
--- /dev/null
+++ b/src/utils/parseRelativeTime.ts
@@ -0,0 +1,32 @@
+import { DateTime, Duration } from "luxon";
+
+export const parseRelativeTime = (text: string): DateTime => {
+ const parts = text.split(" ");
+
+ const value = parseInt(parts[0]);
+ const unit = parts[1];
+
+ let duration: Duration;
+
+ if (["second", "seconds"].includes(unit)) {
+ duration = Duration.fromObject({ seconds: value });
+ } else if (["minute", "minutes"].includes(unit)) {
+ duration = Duration.fromObject({ minutes: value });
+ } else if (["hour", "hours"].includes(unit)) {
+ duration = Duration.fromObject({ hours: value });
+ } else if (["day", "days"].includes(unit)) {
+ duration = Duration.fromObject({ days: value });
+ } else if (["week", "weeks"].includes(unit)) {
+ duration = Duration.fromObject({ weeks: value });
+ } else if (["month", "months"].includes(unit)) {
+ duration = Duration.fromObject({ months: value });
+ } else if (["year", "years"].includes(unit)) {
+ duration = Duration.fromObject({ years: value });
+ } else {
+ throw new Error(`Unknown time unit '${unit}'`);
+ }
+
+ const resultDate = DateTime.now().minus(duration);
+
+ return resultDate;
+};
diff --git a/src/utils/parseSubscriberCount.ts b/src/utils/parseSubscriberCount.ts
new file mode 100644
index 0000000..8b9803e
--- /dev/null
+++ b/src/utils/parseSubscriberCount.ts
@@ -0,0 +1,22 @@
+const subCountRegex = /([0-9]{1,3}(?:\.[0-9]{1,2})?)([KMB])?/g;
+
+const sizeMap: Record = {
+ K: 1.0e3,
+ M: 1.0e6,
+ B: 1.0e9
+};
+
+export const parseSubscriberCount = (subCount: string): number => {
+ const matchIterator = subCount.matchAll(subCountRegex);
+
+ const match = matchIterator.next().value;
+
+ if (match) {
+ const countFloat = parseFloat(match[1]);
+ const size = sizeMap[match[2].toString().toUpperCase()] ?? 1;
+
+ return countFloat * size;
+ }
+
+ return 5;
+};