Spaces:
Running
Running
Commit
•
8f2b05f
1
Parent(s):
e3d26ad
add like button and report button
Browse files- public/report.jpg +0 -0
- src/app/channel/page.tsx +4 -2
- src/app/interface/action-button/index.tsx +8 -2
- src/app/interface/left-menu/index.tsx +2 -2
- src/app/interface/like-button/generic.tsx +81 -0
- src/app/interface/like-button/index.tsx +79 -0
- src/app/interface/media-list/index.tsx +65 -0
- src/app/interface/recommended-videos/index.tsx +2 -2
- src/app/interface/top-header/index.tsx +6 -4
- src/app/interface/track-card/index.tsx +230 -0
- src/app/interface/track-list/index.tsx +13 -0
- src/app/interface/video-card/index.tsx +29 -30
- src/app/interface/video-list/index.tsx +7 -45
- src/app/main.tsx +70 -29
- src/app/music/index.tsx +0 -9
- src/app/music/page.tsx +88 -0
- src/app/page.tsx +7 -4
- src/app/playlist/page.tsx +88 -0
- src/app/server/actions/ai-tube-hf/getChannelVideos.ts +61 -48
- src/app/server/actions/ai-tube-hf/getVideos.ts +69 -57
- src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts +1 -0
- src/app/server/actions/stats.ts +87 -1
- src/app/server/actions/utils/parseDatasetPrompt.ts +1 -1
- src/app/state/useStore.ts +31 -1
- src/app/views/home-view/index.tsx +1 -1
- src/app/views/public-channel-view/index.tsx +14 -8
- src/app/views/public-music-videos-view/index.tsx +14 -10
- src/app/views/public-video-view/index.tsx +11 -2
- src/app/views/report-modal/index.tsx +121 -0
- src/app/watch/page.tsx +11 -9
- src/components/ui/dialog.tsx +1 -1
- src/lib/getCollectionKey.ts +5 -0
- src/types.ts +21 -0
- tailwind.config.js +4 -0
public/report.jpg
ADDED
src/app/channel/page.tsx
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
import { AppQueryProps } from "@/types"
|
|
|
2 |
import { Main } from "../main"
|
3 |
import { getChannel } from "../server/actions/ai-tube-hf/getChannel"
|
4 |
import { getChannelVideos } from "../server/actions/ai-tube-hf/getChannelVideos"
|
@@ -6,10 +7,11 @@ import { getChannelVideos } from "../server/actions/ai-tube-hf/getChannelVideos"
|
|
6 |
export default async function ChannelPage({ searchParams: { c: channelId } }: AppQueryProps) {
|
7 |
const channel = await getChannel({ channelId, neverThrow: true })
|
8 |
|
9 |
-
const
|
10 |
channel: channel,
|
11 |
status: "published",
|
|
|
12 |
})
|
13 |
|
14 |
-
return (<Main channel={channel}
|
15 |
}
|
|
|
1 |
import { AppQueryProps } from "@/types"
|
2 |
+
|
3 |
import { Main } from "../main"
|
4 |
import { getChannel } from "../server/actions/ai-tube-hf/getChannel"
|
5 |
import { getChannelVideos } from "../server/actions/ai-tube-hf/getChannelVideos"
|
|
|
7 |
export default async function ChannelPage({ searchParams: { c: channelId } }: AppQueryProps) {
|
8 |
const channel = await getChannel({ channelId, neverThrow: true })
|
9 |
|
10 |
+
const publicChannelVideos = await getChannelVideos({
|
11 |
channel: channel,
|
12 |
status: "published",
|
13 |
+
neverThrow: true,
|
14 |
})
|
15 |
|
16 |
+
return (<Main channel={channel} publicChannelVideos={publicChannelVideos} />)
|
17 |
}
|
src/app/interface/action-button/index.tsx
CHANGED
@@ -14,11 +14,13 @@ export const actionButtonClassName = cn(
|
|
14 |
export function ActionButton({
|
15 |
className,
|
16 |
children,
|
17 |
-
href
|
|
|
18 |
}: {
|
19 |
className?: string
|
20 |
children?: ReactNode
|
21 |
href?: string
|
|
|
22 |
}) {
|
23 |
|
24 |
const classNames = cn(
|
@@ -34,7 +36,11 @@ export function ActionButton({
|
|
34 |
)
|
35 |
}
|
36 |
return (
|
37 |
-
<div className={classNames}
|
|
|
|
|
|
|
|
|
38 |
{children}
|
39 |
</div>
|
40 |
)
|
|
|
14 |
export function ActionButton({
|
15 |
className,
|
16 |
children,
|
17 |
+
href,
|
18 |
+
onClick,
|
19 |
}: {
|
20 |
className?: string
|
21 |
children?: ReactNode
|
22 |
href?: string
|
23 |
+
onClick?: () => void
|
24 |
}) {
|
25 |
|
26 |
const classNames = cn(
|
|
|
36 |
)
|
37 |
}
|
38 |
return (
|
39 |
+
<div className={classNames} onClick={() => {
|
40 |
+
try {
|
41 |
+
onClick?.()
|
42 |
+
} catch (err) {}
|
43 |
+
}}>
|
44 |
{children}
|
45 |
</div>
|
46 |
)
|
src/app/interface/left-menu/index.tsx
CHANGED
@@ -5,7 +5,7 @@ import { MdVideoLibrary } from "react-icons/md"
|
|
5 |
import { RiHome8Line } from "react-icons/ri"
|
6 |
import { PiRobot } from "react-icons/pi"
|
7 |
import { CgProfile } from "react-icons/cg"
|
8 |
-
import {
|
9 |
|
10 |
import { useStore } from "@/app/state/useStore"
|
11 |
import { cn } from "@/lib/utils"
|
@@ -49,7 +49,7 @@ export function LeftMenu() {
|
|
49 |
{/*
|
50 |
<Link href="/music">
|
51 |
<MenuItem
|
52 |
-
icon={<
|
53 |
selected={view === "public_music_videos"}
|
54 |
>
|
55 |
Music
|
|
|
5 |
import { RiHome8Line } from "react-icons/ri"
|
6 |
import { PiRobot } from "react-icons/pi"
|
7 |
import { CgProfile } from "react-icons/cg"
|
8 |
+
import { MdOutlinePlayCircleOutline } from "react-icons/md";
|
9 |
|
10 |
import { useStore } from "@/app/state/useStore"
|
11 |
import { cn } from "@/lib/utils"
|
|
|
49 |
{/*
|
50 |
<Link href="/music">
|
51 |
<MenuItem
|
52 |
+
icon={<MdOutlinePlayCircleOutline className="h-6.5 w-6.5" />}
|
53 |
selected={view === "public_music_videos"}
|
54 |
>
|
55 |
Music
|
src/app/interface/like-button/generic.tsx
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { RiThumbUpLine } from "react-icons/ri"
|
2 |
+
import { RiThumbUpFill } from "react-icons/ri"
|
3 |
+
import { RiThumbDownLine } from "react-icons/ri"
|
4 |
+
import { RiThumbDownFill } from "react-icons/ri"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
export const likeButtonClassName = cn(
|
9 |
+
`flex flex-row`,
|
10 |
+
`items-center justify-center text-center`,
|
11 |
+
`h-8 lg:h-9`,
|
12 |
+
`rounded-2xl`,
|
13 |
+
`text-xs lg:text-sm font-medium`,
|
14 |
+
`bg-neutral-700/50 text-zinc-100`,
|
15 |
+
)
|
16 |
+
|
17 |
+
export function GenericLikeButton({
|
18 |
+
className,
|
19 |
+
onLike,
|
20 |
+
onDislike,
|
21 |
+
isLikedByUser = false,
|
22 |
+
isDislikedByUser = false,
|
23 |
+
numberOfLikes = 0,
|
24 |
+
numberOfDislikes = 0,
|
25 |
+
}: {
|
26 |
+
className?: string
|
27 |
+
onLike?: () => void
|
28 |
+
onDislike?: () => void
|
29 |
+
isLikedByUser?: boolean
|
30 |
+
isDislikedByUser?: boolean
|
31 |
+
numberOfLikes?: number
|
32 |
+
numberOfDislikes?: number
|
33 |
+
}) {
|
34 |
+
|
35 |
+
const classNames = cn(
|
36 |
+
likeButtonClassName,
|
37 |
+
className,
|
38 |
+
)
|
39 |
+
|
40 |
+
|
41 |
+
return (
|
42 |
+
<div className={classNames}>
|
43 |
+
<div className={cn(
|
44 |
+
`flex flex-row items-center justify-center`,
|
45 |
+
`cursor-pointer rounded-l-full overflow-hidden`,
|
46 |
+
`hover:bg-neutral-700/90`,
|
47 |
+
`space-x-1.5 lg:space-x-2 pl-2 lg:pl-3 pr-3 lg:pr-4 h-8 lg:h-9`
|
48 |
+
)}
|
49 |
+
onClick={() => {
|
50 |
+
try {
|
51 |
+
onLike?.()
|
52 |
+
} catch (err) {
|
53 |
+
|
54 |
+
}}}
|
55 |
+
>
|
56 |
+
<div>{
|
57 |
+
isLikedByUser ? <RiThumbUpFill /> : <RiThumbUpLine />
|
58 |
+
}</div>
|
59 |
+
<div>{numberOfLikes}</div>
|
60 |
+
</div>
|
61 |
+
<div className={cn(
|
62 |
+
`flex flex-row items-center justify-center`,
|
63 |
+
`cursor-pointer rounded-r-full overflow-hidden`,
|
64 |
+
`hover:bg-neutral-700/90`,
|
65 |
+
`space-x-1.5 lg:space-x-2 pl-2 lg:pl-3 pr-3 lg:pr-4 h-8 lg:h-9`
|
66 |
+
)}
|
67 |
+
onClick={() => {
|
68 |
+
try {
|
69 |
+
onDislike?.()
|
70 |
+
} catch (err) {
|
71 |
+
|
72 |
+
}}}
|
73 |
+
>
|
74 |
+
<div>{
|
75 |
+
isDislikedByUser ? <RiThumbDownFill /> : <RiThumbDownLine />
|
76 |
+
}</div>
|
77 |
+
<div>{numberOfDislikes}</div>
|
78 |
+
</div>
|
79 |
+
</div>
|
80 |
+
)
|
81 |
+
}
|
src/app/interface/like-button/index.tsx
ADDED
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect, useState, useTransition } from "react"
|
2 |
+
import { VideoInfo, VideoRating } from "@/types"
|
3 |
+
|
4 |
+
import { GenericLikeButton } from "./generic"
|
5 |
+
import { getVideoRating, rateVideo } from "@/app/server/actions/stats"
|
6 |
+
import { useLocalStorage } from "usehooks-ts"
|
7 |
+
import { localStorageKeys } from "@/app/state/localStorageKeys"
|
8 |
+
import { defaultSettings } from "@/app/state/defaultSettings"
|
9 |
+
|
10 |
+
export function LikeButton({
|
11 |
+
video
|
12 |
+
}: {
|
13 |
+
video?: VideoInfo
|
14 |
+
}) {
|
15 |
+
const [_pending, startTransition] = useTransition()
|
16 |
+
|
17 |
+
const [rating, setRating] = useState<VideoRating>({
|
18 |
+
isLikedByUser: false,
|
19 |
+
isDislikedByUser: false,
|
20 |
+
numberOfLikes: 0,
|
21 |
+
numberOfDislikes: 0,
|
22 |
+
})
|
23 |
+
|
24 |
+
const [huggingfaceApiKey] = useLocalStorage<string>(
|
25 |
+
localStorageKeys.huggingfaceApiKey,
|
26 |
+
defaultSettings.huggingfaceApiKey
|
27 |
+
)
|
28 |
+
|
29 |
+
useEffect(() => {
|
30 |
+
startTransition(async () => {
|
31 |
+
if (!video || !video?.id) { return }
|
32 |
+
|
33 |
+
const freshRating = await getVideoRating(video.id, huggingfaceApiKey)
|
34 |
+
setRating(freshRating)
|
35 |
+
|
36 |
+
})
|
37 |
+
}, [video?.id, huggingfaceApiKey])
|
38 |
+
|
39 |
+
if (!video) { return null }
|
40 |
+
|
41 |
+
if (!huggingfaceApiKey) { return null }
|
42 |
+
|
43 |
+
const handleLike = huggingfaceApiKey ? () => {
|
44 |
+
// we use premeptive update
|
45 |
+
setRating({
|
46 |
+
...rating,
|
47 |
+
isLikedByUser: true,
|
48 |
+
isDislikedByUser: false,
|
49 |
+
numberOfLikes: rating.numberOfLikes + 1,
|
50 |
+
numberOfDislikes: rating.numberOfDislikes - 1,
|
51 |
+
})
|
52 |
+
startTransition(async () => {
|
53 |
+
const freshRating = await rateVideo(video.id, true, huggingfaceApiKey)
|
54 |
+
setRating(freshRating)
|
55 |
+
})
|
56 |
+
} : undefined
|
57 |
+
|
58 |
+
const handleDislike = huggingfaceApiKey ? () => {
|
59 |
+
setRating({
|
60 |
+
...rating,
|
61 |
+
isLikedByUser: false,
|
62 |
+
isDislikedByUser: true,
|
63 |
+
numberOfLikes: rating.numberOfLikes - 1,
|
64 |
+
numberOfDislikes: rating.numberOfDislikes + 1,
|
65 |
+
})
|
66 |
+
startTransition(async () => {
|
67 |
+
const freshRating = await rateVideo(video.id, false, huggingfaceApiKey)
|
68 |
+
setRating(freshRating)
|
69 |
+
})
|
70 |
+
} : undefined
|
71 |
+
|
72 |
+
return (
|
73 |
+
<GenericLikeButton
|
74 |
+
{...rating}
|
75 |
+
onLike={handleLike}
|
76 |
+
onDislike={handleDislike}
|
77 |
+
/>
|
78 |
+
)
|
79 |
+
}
|
src/app/interface/media-list/index.tsx
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { cn } from "@/lib/utils"
|
2 |
+
import { MediaDisplayLayout, VideoInfo } from "@/types"
|
3 |
+
import { TrackCard } from "../track-card"
|
4 |
+
import { VideoCard } from "../video-card"
|
5 |
+
|
6 |
+
export function MediaList({
|
7 |
+
items,
|
8 |
+
type = "video",
|
9 |
+
layout = "grid",
|
10 |
+
className = "",
|
11 |
+
onSelect,
|
12 |
+
}: {
|
13 |
+
items: VideoInfo[]
|
14 |
+
|
15 |
+
/**
|
16 |
+
* Layout mode
|
17 |
+
*
|
18 |
+
* This isn't necessarily based on screen size, it can also be:
|
19 |
+
* - based on the device type (eg. a smart TV)
|
20 |
+
* - a design choice for a particular page
|
21 |
+
*/
|
22 |
+
layout?: MediaDisplayLayout
|
23 |
+
|
24 |
+
/**
|
25 |
+
* Content type
|
26 |
+
*
|
27 |
+
* Used to change the way we display elements
|
28 |
+
*/
|
29 |
+
type?: "video" | "track"
|
30 |
+
|
31 |
+
className?: string
|
32 |
+
|
33 |
+
onSelect?: (media: VideoInfo) => void
|
34 |
+
}) {
|
35 |
+
|
36 |
+
return (
|
37 |
+
<div
|
38 |
+
className={cn(
|
39 |
+
layout === "table"
|
40 |
+
? `flex flex-col` :
|
41 |
+
layout === "grid"
|
42 |
+
? `grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4` :
|
43 |
+
layout === "vertical"
|
44 |
+
? `grid grid-cols-1 gap-2`
|
45 |
+
: `flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4`,
|
46 |
+
className,
|
47 |
+
)}
|
48 |
+
>
|
49 |
+
{items.map((media, i) => {
|
50 |
+
const Component = type === "track" ? TrackCard : VideoCard
|
51 |
+
|
52 |
+
return (
|
53 |
+
<Component
|
54 |
+
key={media.id}
|
55 |
+
media={media}
|
56 |
+
className="w-full"
|
57 |
+
layout={layout}
|
58 |
+
onSelect={onSelect}
|
59 |
+
index={i}
|
60 |
+
/>
|
61 |
+
)
|
62 |
+
})}
|
63 |
+
</div>
|
64 |
+
)
|
65 |
+
}
|
src/app/interface/recommended-videos/index.tsx
CHANGED
@@ -24,14 +24,14 @@ export function RecommendedVideos({
|
|
24 |
sortBy: "random",
|
25 |
niceToHaveTags: video.tags,
|
26 |
ignoreVideoIds: [video.id],
|
27 |
-
maxVideos: 16
|
28 |
}))
|
29 |
})
|
30 |
}, video.tags)
|
31 |
|
32 |
return (
|
33 |
<VideoList
|
34 |
-
|
35 |
layout="vertical"
|
36 |
/>
|
37 |
)
|
|
|
24 |
sortBy: "random",
|
25 |
niceToHaveTags: video.tags,
|
26 |
ignoreVideoIds: [video.id],
|
27 |
+
maxVideos: 16,
|
28 |
}))
|
29 |
})
|
30 |
}, video.tags)
|
31 |
|
32 |
return (
|
33 |
<VideoList
|
34 |
+
items={recommendedVideos}
|
35 |
layout="vertical"
|
36 |
/>
|
37 |
)
|
src/app/interface/top-header/index.tsx
CHANGED
@@ -71,16 +71,18 @@ export function TopHeader() {
|
|
71 |
<div className={cn(
|
72 |
`flex flex-row items-center justify-start`,
|
73 |
`transition-all duration-200 ease-in-out`,
|
74 |
-
`cursor-pointer`,
|
75 |
"pt-2 text-3xl space-x-1",
|
76 |
-
"
|
77 |
pathway.className,
|
78 |
isNormalSize
|
79 |
? "sm:scale-125 sm:ml-4 sm:mb-4" : "sm:scale-100 sm:mb-2"
|
80 |
)}
|
|
|
81 |
onClick={() => {
|
82 |
-
setView("home")
|
83 |
}}
|
|
|
84 |
>
|
85 |
<div className="mr-1">
|
86 |
<div className={cn(
|
@@ -94,7 +96,7 @@ export function TopHeader() {
|
|
94 |
</div>
|
95 |
</div>
|
96 |
<div className="font-semibold">
|
97 |
-
AiTube
|
98 |
</div>
|
99 |
</div>
|
100 |
</div>
|
|
|
71 |
<div className={cn(
|
72 |
`flex flex-row items-center justify-start`,
|
73 |
`transition-all duration-200 ease-in-out`,
|
74 |
+
// `cursor-pointer`,
|
75 |
"pt-2 text-3xl space-x-1",
|
76 |
+
view === "public_music_videos" ? "pl-1" : "",
|
77 |
pathway.className,
|
78 |
isNormalSize
|
79 |
? "sm:scale-125 sm:ml-4 sm:mb-4" : "sm:scale-100 sm:mb-2"
|
80 |
)}
|
81 |
+
/*
|
82 |
onClick={() => {
|
83 |
+
setView(view === "public_music_videos" ? "public_music_videos" : "home")
|
84 |
}}
|
85 |
+
*/
|
86 |
>
|
87 |
<div className="mr-1">
|
88 |
<div className={cn(
|
|
|
96 |
</div>
|
97 |
</div>
|
98 |
<div className="font-semibold">
|
99 |
+
{view === "public_music_videos" ? "AiTube Music" : "AiTube"}
|
100 |
</div>
|
101 |
</div>
|
102 |
</div>
|
src/app/interface/track-card/index.tsx
ADDED
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useEffect, useRef, useState } from "react"
|
4 |
+
import Link from "next/link"
|
5 |
+
import { RiCheckboxCircleFill } from "react-icons/ri"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
import { MediaDisplayLayout, VideoInfo } from "@/types"
|
9 |
+
import { formatDuration } from "@/lib/formatDuration"
|
10 |
+
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
11 |
+
import { isCertifiedUser } from "@/app/certification"
|
12 |
+
import { transparentImage } from "@/lib/transparentImage"
|
13 |
+
import { DefaultAvatar } from "../default-avatar"
|
14 |
+
|
15 |
+
export function TrackCard({
|
16 |
+
media,
|
17 |
+
className = "",
|
18 |
+
layout = "grid",
|
19 |
+
onSelect,
|
20 |
+
index
|
21 |
+
}: {
|
22 |
+
media: VideoInfo
|
23 |
+
className?: string
|
24 |
+
layout?: MediaDisplayLayout
|
25 |
+
onSelect?: (media: VideoInfo) => void
|
26 |
+
index: number
|
27 |
+
}) {
|
28 |
+
const ref = useRef<HTMLVideoElement>(null)
|
29 |
+
const [duration, setDuration] = useState(0)
|
30 |
+
|
31 |
+
const [channelThumbnail, setChannelThumbnail] = useState(media.channel.thumbnail)
|
32 |
+
const [mediaThumbnail, setMediaThumbnail] = useState(
|
33 |
+
`https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${media.id}.webp`
|
34 |
+
)
|
35 |
+
const [mediaThumbnailReady, setMediaThumbnailReady] = useState(false)
|
36 |
+
const [shouldLoadMedia, setShouldLoadMedia] = useState(false)
|
37 |
+
|
38 |
+
const isTable = layout === "table"
|
39 |
+
const isCompact = layout === "vertical"
|
40 |
+
|
41 |
+
const handlePointerEnter = () => {
|
42 |
+
// ref.current?.load()
|
43 |
+
ref.current?.play()
|
44 |
+
}
|
45 |
+
const handlePointerLeave = () => {
|
46 |
+
ref.current?.pause()
|
47 |
+
// ref.current?.load()
|
48 |
+
}
|
49 |
+
const handleLoad = () => {
|
50 |
+
if (ref.current?.readyState) {
|
51 |
+
setDuration(ref.current.duration)
|
52 |
+
}
|
53 |
+
}
|
54 |
+
|
55 |
+
const handleClick = () => {
|
56 |
+
onSelect?.(media)
|
57 |
+
}
|
58 |
+
|
59 |
+
const handleBadChannelThumbnail = () => {
|
60 |
+
try {
|
61 |
+
if (channelThumbnail) {
|
62 |
+
setChannelThumbnail("")
|
63 |
+
}
|
64 |
+
} catch (err) {
|
65 |
+
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
useEffect(() => {
|
70 |
+
setTimeout(() => {
|
71 |
+
setShouldLoadMedia(true)
|
72 |
+
}, index * 1500)
|
73 |
+
}, [index])
|
74 |
+
|
75 |
+
return (
|
76 |
+
<Link href={`/music?m=${media.id}`}>
|
77 |
+
<div
|
78 |
+
className={cn(
|
79 |
+
`w-full flex`,
|
80 |
+
isTable ? `flex-row h-14 space-x-2 px-2 py-2 rounded-lg` :
|
81 |
+
isCompact ? `flex-row h-24 py-1 space-x-2` :
|
82 |
+
`flex-col space-y-3`,
|
83 |
+
`bg-line-900`,
|
84 |
+
`cursor-pointer`,
|
85 |
+
index % 2 ? "bg-neutral-800/40" : "",
|
86 |
+
className,
|
87 |
+
)}
|
88 |
+
onPointerEnter={handlePointerEnter}
|
89 |
+
onPointerLeave={handlePointerLeave}
|
90 |
+
// onClick={handleClick}
|
91 |
+
>
|
92 |
+
{/* THUMBNAIL BLOCK */}
|
93 |
+
<div
|
94 |
+
className={cn(
|
95 |
+
`flex items-center justify-center`,
|
96 |
+
`rounded overflow-hidden`,
|
97 |
+
isTable ? ` flex-col` :
|
98 |
+
isCompact ? ` flex-col w-42 h-42` :
|
99 |
+
` flex-col aspect-square`
|
100 |
+
)}
|
101 |
+
>
|
102 |
+
<div className={cn(
|
103 |
+
`relative`,
|
104 |
+
`aspect-square`,
|
105 |
+
isTable ? "w-full h-full" : isCompact ? `w-42 h-42` : ``
|
106 |
+
)}>
|
107 |
+
{!isTable && mediaThumbnailReady && shouldLoadMedia
|
108 |
+
? <video
|
109 |
+
// mute the video
|
110 |
+
muted
|
111 |
+
|
112 |
+
// prevent iOS from attempting to open the video in full screen, which is annoying
|
113 |
+
playsInline
|
114 |
+
|
115 |
+
ref={ref}
|
116 |
+
src={media.assetUrl}
|
117 |
+
className={cn(
|
118 |
+
`w-full h-full`,
|
119 |
+
`aspect-square`,
|
120 |
+
duration > 0 ? `opacity-100`: 'opacity-0',
|
121 |
+
`transition-all duration-500`,
|
122 |
+
)}
|
123 |
+
onLoadedMetadata={handleLoad}
|
124 |
+
|
125 |
+
/> : null}
|
126 |
+
<img
|
127 |
+
src={mediaThumbnail}
|
128 |
+
className={cn(
|
129 |
+
`absolute`,
|
130 |
+
`aspect-square object-cover`,
|
131 |
+
`rounded-lg overflow-hidden`,
|
132 |
+
mediaThumbnailReady ? `opacity-100`: 'opacity-0',
|
133 |
+
`hover:opacity-0 w-full h-full top-0 z-30`,
|
134 |
+
//`pointer-events-none`,
|
135 |
+
`transition-all duration-500 hover:delay-300 ease-in-out`,
|
136 |
+
)}
|
137 |
+
onMouseEnter={() => {
|
138 |
+
setShouldLoadMedia(true)
|
139 |
+
}}
|
140 |
+
onLoad={() => {
|
141 |
+
setMediaThumbnailReady(true)
|
142 |
+
}}
|
143 |
+
onError={() => {
|
144 |
+
setMediaThumbnail(transparentImage)
|
145 |
+
setMediaThumbnailReady(false)
|
146 |
+
}}
|
147 |
+
/>
|
148 |
+
</div>
|
149 |
+
|
150 |
+
{isTable ? null : <div className={cn(
|
151 |
+
// `aspect-video`,
|
152 |
+
`z-40`,
|
153 |
+
`w-full flex flex-row items-end justify-end`
|
154 |
+
)}>
|
155 |
+
<div className={cn(
|
156 |
+
`-mt-8`,
|
157 |
+
`mr-0`,
|
158 |
+
)}
|
159 |
+
>
|
160 |
+
<div className={cn(
|
161 |
+
`mb-[5px]`,
|
162 |
+
`mr-[5px]`,
|
163 |
+
`flex flex-col items-center justify-center text-center`,
|
164 |
+
`bg-neutral-900 rounded`,
|
165 |
+
`text-2xs font-semibold px-[3px] py-[1px]`,
|
166 |
+
)}
|
167 |
+
>{formatDuration(duration)}</div>
|
168 |
+
</div>
|
169 |
+
</div>}
|
170 |
+
</div>
|
171 |
+
|
172 |
+
{/* TEXT BLOCK */}
|
173 |
+
<div className={cn(
|
174 |
+
`flex flex-row`,
|
175 |
+
isCompact ? `w-40 lg:w-44 xl:w-51` : `space-x-4`,
|
176 |
+
)}>
|
177 |
+
{
|
178 |
+
isTable || isCompact ? null
|
179 |
+
: channelThumbnail ? <div className="flex flex-col">
|
180 |
+
<div className="flex w-9 rounded-full overflow-hidden">
|
181 |
+
<img
|
182 |
+
src={channelThumbnail}
|
183 |
+
onError={handleBadChannelThumbnail}
|
184 |
+
/>
|
185 |
+
</div>
|
186 |
+
</div>
|
187 |
+
: <DefaultAvatar
|
188 |
+
username={media.channel.datasetUser}
|
189 |
+
bgColor="#fde047"
|
190 |
+
textColor="#1c1917"
|
191 |
+
width={36}
|
192 |
+
roundShape
|
193 |
+
/>}
|
194 |
+
<div className={cn(
|
195 |
+
`flex flex-col`,
|
196 |
+
isTable ? `justify-center` :
|
197 |
+
isCompact ? `` : `flex-grow`
|
198 |
+
)}>
|
199 |
+
<h3 className={cn(
|
200 |
+
`text-zinc-100 mb-0 line-clamp-2`,
|
201 |
+
isTable ? `font-normal text-2xs md:text-xs lg:text-sm mb-0.5` :
|
202 |
+
isCompact ? `font-medium text-2xs md:text-xs lg:text-sm mb-1.5` :
|
203 |
+
`font-medium text-base`
|
204 |
+
)}>{media.label}</h3>
|
205 |
+
<div className={cn(
|
206 |
+
`flex flex-row items-center`,
|
207 |
+
`text-neutral-400 font-normal space-x-1`,
|
208 |
+
isTable ? `text-2xs md:text-xs lg:text-sm` :
|
209 |
+
isCompact ? `text-3xs md:text-2xs lg:text-xs` : `text-sm`
|
210 |
+
)}>
|
211 |
+
<div>{media.channel.label}</div>
|
212 |
+
{isCertifiedUser(media.channel.datasetUser) ? <div><RiCheckboxCircleFill className="opacity-40" /></div> : null}
|
213 |
+
</div>
|
214 |
+
|
215 |
+
{isTable ? null : <div className={cn(
|
216 |
+
`flex flex-row`,
|
217 |
+
`text-neutral-400 font-normal`,
|
218 |
+
isCompact ? `text-2xs lg:text-xs` : `text-sm`,
|
219 |
+
`space-x-1`
|
220 |
+
)}>
|
221 |
+
<div>{media.numberOfViews} views</div>
|
222 |
+
<div className="font-semibold scale-125">·</div>
|
223 |
+
<div>{formatTimeAgo(media.updatedAt)}</div>
|
224 |
+
</div>}
|
225 |
+
</div>
|
226 |
+
</div>
|
227 |
+
</div>
|
228 |
+
</Link>
|
229 |
+
)
|
230 |
+
}
|
src/app/interface/track-list/index.tsx
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ComponentProps } from "react"
|
2 |
+
|
3 |
+
import { MediaList } from "../media-list"
|
4 |
+
|
5 |
+
export function TrackList(props: Omit<ComponentProps<typeof MediaList>, "type">) {
|
6 |
+
|
7 |
+
return (
|
8 |
+
<MediaList
|
9 |
+
{...props}
|
10 |
+
type="track"
|
11 |
+
/>
|
12 |
+
)
|
13 |
+
}
|
src/app/interface/video-card/index.tsx
CHANGED
@@ -1,12 +1,11 @@
|
|
1 |
"use client"
|
2 |
|
3 |
import { useEffect, useRef, useState } from "react"
|
4 |
-
import dynamic from "next/dynamic"
|
5 |
import Link from "next/link"
|
6 |
import { RiCheckboxCircleFill } from "react-icons/ri"
|
7 |
|
8 |
import { cn } from "@/lib/utils"
|
9 |
-
import { VideoInfo } from "@/types"
|
10 |
import { formatDuration } from "@/lib/formatDuration"
|
11 |
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
12 |
import { isCertifiedUser } from "@/app/certification"
|
@@ -14,29 +13,29 @@ import { transparentImage } from "@/lib/transparentImage"
|
|
14 |
import { DefaultAvatar } from "../default-avatar"
|
15 |
|
16 |
export function VideoCard({
|
17 |
-
|
18 |
className = "",
|
19 |
-
layout = "
|
20 |
onSelect,
|
21 |
index
|
22 |
}: {
|
23 |
-
|
24 |
className?: string
|
25 |
-
layout?:
|
26 |
-
onSelect?: (
|
27 |
index: number
|
28 |
}) {
|
29 |
const ref = useRef<HTMLVideoElement>(null)
|
30 |
const [duration, setDuration] = useState(0)
|
31 |
|
32 |
-
const [channelThumbnail, setChannelThumbnail] = useState(
|
33 |
-
const [
|
34 |
-
`https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${
|
35 |
)
|
36 |
-
const [
|
37 |
-
const [
|
38 |
|
39 |
-
const isCompact = layout === "
|
40 |
|
41 |
const handlePointerEnter = () => {
|
42 |
// ref.current?.load()
|
@@ -53,7 +52,7 @@ export function VideoCard({
|
|
53 |
}
|
54 |
|
55 |
const handleClick = () => {
|
56 |
-
onSelect?.(
|
57 |
}
|
58 |
|
59 |
const handleBadChannelThumbnail = () => {
|
@@ -68,12 +67,12 @@ export function VideoCard({
|
|
68 |
|
69 |
useEffect(() => {
|
70 |
setTimeout(() => {
|
71 |
-
|
72 |
}, index * 1500)
|
73 |
}, [index])
|
74 |
|
75 |
return (
|
76 |
-
<Link href={`/watch?v=${
|
77 |
<div
|
78 |
className={cn(
|
79 |
`w-full flex`,
|
@@ -98,7 +97,7 @@ export function VideoCard({
|
|
98 |
`relative w-full`,
|
99 |
isCompact ? `w-42 h-[94px]` : `aspect-video`
|
100 |
)}>
|
101 |
-
{
|
102 |
? <video
|
103 |
// mute the video
|
104 |
muted
|
@@ -107,7 +106,7 @@ export function VideoCard({
|
|
107 |
playsInline
|
108 |
|
109 |
ref={ref}
|
110 |
-
src={
|
111 |
className={cn(
|
112 |
`w-full h-full`,
|
113 |
`aspect-video`,
|
@@ -118,26 +117,26 @@ export function VideoCard({
|
|
118 |
|
119 |
/> : null}
|
120 |
<img
|
121 |
-
src={
|
122 |
className={cn(
|
123 |
`absolute`,
|
124 |
`aspect-video`,
|
125 |
// `aspect-video object-cover`,
|
126 |
`rounded-lg overflow-hidden`,
|
127 |
-
|
128 |
`hover:opacity-0 w-full h-full top-0 z-30`,
|
129 |
//`pointer-events-none`,
|
130 |
`transition-all duration-500 hover:delay-300 ease-in-out`,
|
131 |
)}
|
132 |
onMouseEnter={() => {
|
133 |
-
|
134 |
}}
|
135 |
onLoad={() => {
|
136 |
-
|
137 |
}}
|
138 |
onError={() => {
|
139 |
-
|
140 |
-
|
141 |
}}
|
142 |
/>
|
143 |
</div>
|
@@ -180,7 +179,7 @@ export function VideoCard({
|
|
180 |
</div>
|
181 |
</div>
|
182 |
: <DefaultAvatar
|
183 |
-
username={
|
184 |
bgColor="#fde047"
|
185 |
textColor="#1c1917"
|
186 |
width={36}
|
@@ -193,14 +192,14 @@ export function VideoCard({
|
|
193 |
<h3 className={cn(
|
194 |
`text-zinc-100 font-medium mb-0 line-clamp-2`,
|
195 |
isCompact ? `text-2xs md:text-xs lg:text-sm mb-1.5` : `text-base`
|
196 |
-
)}>{
|
197 |
<div className={cn(
|
198 |
`flex flex-row items-center`,
|
199 |
`text-neutral-400 font-normal space-x-1`,
|
200 |
isCompact ? `text-3xs md:text-2xs lg:text-xs` : `text-sm`
|
201 |
)}>
|
202 |
-
<div>{
|
203 |
-
{isCertifiedUser(
|
204 |
</div>
|
205 |
|
206 |
<div className={cn(
|
@@ -209,9 +208,9 @@ export function VideoCard({
|
|
209 |
isCompact ? `text-2xs lg:text-xs` : `text-sm`,
|
210 |
`space-x-1`
|
211 |
)}>
|
212 |
-
<div>{
|
213 |
<div className="font-semibold scale-125">·</div>
|
214 |
-
<div>{formatTimeAgo(
|
215 |
</div>
|
216 |
</div>
|
217 |
</div>
|
|
|
1 |
"use client"
|
2 |
|
3 |
import { useEffect, useRef, useState } from "react"
|
|
|
4 |
import Link from "next/link"
|
5 |
import { RiCheckboxCircleFill } from "react-icons/ri"
|
6 |
|
7 |
import { cn } from "@/lib/utils"
|
8 |
+
import { MediaDisplayLayout, VideoInfo } from "@/types"
|
9 |
import { formatDuration } from "@/lib/formatDuration"
|
10 |
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
11 |
import { isCertifiedUser } from "@/app/certification"
|
|
|
13 |
import { DefaultAvatar } from "../default-avatar"
|
14 |
|
15 |
export function VideoCard({
|
16 |
+
media,
|
17 |
className = "",
|
18 |
+
layout = "grid",
|
19 |
onSelect,
|
20 |
index
|
21 |
}: {
|
22 |
+
media: VideoInfo
|
23 |
className?: string
|
24 |
+
layout?: MediaDisplayLayout
|
25 |
+
onSelect?: (media: VideoInfo) => void
|
26 |
index: number
|
27 |
}) {
|
28 |
const ref = useRef<HTMLVideoElement>(null)
|
29 |
const [duration, setDuration] = useState(0)
|
30 |
|
31 |
+
const [channelThumbnail, setChannelThumbnail] = useState(media.channel.thumbnail)
|
32 |
+
const [mediaThumbnail, setMediaThumbnail] = useState(
|
33 |
+
`https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${media.id}.webp`
|
34 |
)
|
35 |
+
const [mediaThumbnailReady, setMediaThumbnailReady] = useState(false)
|
36 |
+
const [shouldLoadMedia, setShouldLoadMedia] = useState(false)
|
37 |
|
38 |
+
const isCompact = layout === "vertical"
|
39 |
|
40 |
const handlePointerEnter = () => {
|
41 |
// ref.current?.load()
|
|
|
52 |
}
|
53 |
|
54 |
const handleClick = () => {
|
55 |
+
onSelect?.(media)
|
56 |
}
|
57 |
|
58 |
const handleBadChannelThumbnail = () => {
|
|
|
67 |
|
68 |
useEffect(() => {
|
69 |
setTimeout(() => {
|
70 |
+
setShouldLoadMedia(true)
|
71 |
}, index * 1500)
|
72 |
}, [index])
|
73 |
|
74 |
return (
|
75 |
+
<Link href={`/watch?v=${media.id}`}>
|
76 |
<div
|
77 |
className={cn(
|
78 |
`w-full flex`,
|
|
|
97 |
`relative w-full`,
|
98 |
isCompact ? `w-42 h-[94px]` : `aspect-video`
|
99 |
)}>
|
100 |
+
{mediaThumbnailReady && shouldLoadMedia
|
101 |
? <video
|
102 |
// mute the video
|
103 |
muted
|
|
|
106 |
playsInline
|
107 |
|
108 |
ref={ref}
|
109 |
+
src={media.assetUrl}
|
110 |
className={cn(
|
111 |
`w-full h-full`,
|
112 |
`aspect-video`,
|
|
|
117 |
|
118 |
/> : null}
|
119 |
<img
|
120 |
+
src={mediaThumbnail}
|
121 |
className={cn(
|
122 |
`absolute`,
|
123 |
`aspect-video`,
|
124 |
// `aspect-video object-cover`,
|
125 |
`rounded-lg overflow-hidden`,
|
126 |
+
mediaThumbnailReady ? `opacity-100`: 'opacity-0',
|
127 |
`hover:opacity-0 w-full h-full top-0 z-30`,
|
128 |
//`pointer-events-none`,
|
129 |
`transition-all duration-500 hover:delay-300 ease-in-out`,
|
130 |
)}
|
131 |
onMouseEnter={() => {
|
132 |
+
setShouldLoadMedia(true)
|
133 |
}}
|
134 |
onLoad={() => {
|
135 |
+
setMediaThumbnailReady(true)
|
136 |
}}
|
137 |
onError={() => {
|
138 |
+
setMediaThumbnail(transparentImage)
|
139 |
+
setMediaThumbnailReady(false)
|
140 |
}}
|
141 |
/>
|
142 |
</div>
|
|
|
179 |
</div>
|
180 |
</div>
|
181 |
: <DefaultAvatar
|
182 |
+
username={media.channel.datasetUser}
|
183 |
bgColor="#fde047"
|
184 |
textColor="#1c1917"
|
185 |
width={36}
|
|
|
192 |
<h3 className={cn(
|
193 |
`text-zinc-100 font-medium mb-0 line-clamp-2`,
|
194 |
isCompact ? `text-2xs md:text-xs lg:text-sm mb-1.5` : `text-base`
|
195 |
+
)}>{media.label}</h3>
|
196 |
<div className={cn(
|
197 |
`flex flex-row items-center`,
|
198 |
`text-neutral-400 font-normal space-x-1`,
|
199 |
isCompact ? `text-3xs md:text-2xs lg:text-xs` : `text-sm`
|
200 |
)}>
|
201 |
+
<div>{media.channel.label}</div>
|
202 |
+
{isCertifiedUser(media.channel.datasetUser) ? <div><RiCheckboxCircleFill className="" /></div> : null}
|
203 |
</div>
|
204 |
|
205 |
<div className={cn(
|
|
|
208 |
isCompact ? `text-2xs lg:text-xs` : `text-sm`,
|
209 |
`space-x-1`
|
210 |
)}>
|
211 |
+
<div>{media.numberOfViews} views</div>
|
212 |
<div className="font-semibold scale-125">·</div>
|
213 |
+
<div>{formatTimeAgo(media.updatedAt)}</div>
|
214 |
</div>
|
215 |
</div>
|
216 |
</div>
|
src/app/interface/video-list/index.tsx
CHANGED
@@ -1,51 +1,13 @@
|
|
1 |
-
import {
|
2 |
-
import { VideoInfo } from "@/types"
|
3 |
|
4 |
-
import {
|
5 |
|
6 |
-
export function VideoList({
|
7 |
-
videos,
|
8 |
-
layout = "grid",
|
9 |
-
className = "",
|
10 |
-
onSelect,
|
11 |
-
}: {
|
12 |
-
videos: VideoInfo[]
|
13 |
-
|
14 |
-
/**
|
15 |
-
* Layout mode
|
16 |
-
*
|
17 |
-
* This isn't necessarily based on screen size, it can also be:
|
18 |
-
* - based on the device type (eg. a smart TV)
|
19 |
-
* - a design choice for a particular page
|
20 |
-
*/
|
21 |
-
layout?: "grid" | "horizontal" | "vertical"
|
22 |
-
|
23 |
-
className?: string
|
24 |
-
|
25 |
-
onSelect?: (video: VideoInfo) => void
|
26 |
-
}) {
|
27 |
|
28 |
return (
|
29 |
-
<
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
: layout === "vertical"
|
34 |
-
? `grid grid-cols-1 gap-2`
|
35 |
-
: `flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4`,
|
36 |
-
className,
|
37 |
-
)}
|
38 |
-
>
|
39 |
-
{videos.map((video, i) => (
|
40 |
-
<VideoCard
|
41 |
-
key={video.id}
|
42 |
-
video={video}
|
43 |
-
className="w-full"
|
44 |
-
layout={layout === "vertical" ? "compact" : "normal"}
|
45 |
-
onSelect={onSelect}
|
46 |
-
index={i}
|
47 |
-
/>
|
48 |
-
))}
|
49 |
-
</div>
|
50 |
)
|
51 |
}
|
|
|
1 |
+
import { ComponentProps } from "react"
|
|
|
2 |
|
3 |
+
import { MediaList } from "../media-list"
|
4 |
|
5 |
+
export function VideoList(props: Omit<ComponentProps<typeof MediaList>, "type">) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
return (
|
8 |
+
<MediaList
|
9 |
+
{...props}
|
10 |
+
type="video"
|
11 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
)
|
13 |
}
|
src/app/main.tsx
CHANGED
@@ -8,10 +8,12 @@ import { UserChannelView } from "./views/user-channel-view"
|
|
8 |
import { PublicVideoView } from "./views/public-video-view"
|
9 |
import { UserAccountView } from "./views/user-account-view"
|
10 |
import { NotFoundView } from "./views/not-found-view"
|
11 |
-
import { ChannelInfo, VideoInfo } from "@/types"
|
12 |
import { useEffect } from "react"
|
13 |
import { usePathname, useRouter } from "next/navigation"
|
14 |
import { TubeLayout } from "./interface/tube-layout"
|
|
|
|
|
15 |
|
16 |
// this is where we transition from the server-side space
|
17 |
// and the client-side space
|
@@ -22,15 +24,24 @@ import { TubeLayout } from "./interface/tube-layout"
|
|
22 |
// one benefit of doing this is that we will able to add some animations/transitions
|
23 |
// more easily
|
24 |
export function Main({
|
25 |
-
|
|
|
26 |
publicVideos,
|
|
|
|
|
|
|
27 |
channel,
|
28 |
}: {
|
29 |
// server side params
|
30 |
-
|
|
|
31 |
publicVideos?: VideoInfo[]
|
|
|
|
|
|
|
32 |
channel?: ChannelInfo
|
33 |
}) {
|
|
|
34 |
const pathname = usePathname()
|
35 |
const router = useRouter()
|
36 |
|
@@ -39,51 +50,80 @@ export function Main({
|
|
39 |
const setPathname = useStore(s => s.setPathname)
|
40 |
const setPublicChannel = useStore(s => s.setPublicChannel)
|
41 |
const setPublicVideos = useStore(s => s.setPublicVideos)
|
42 |
-
|
43 |
-
const
|
44 |
-
|
45 |
-
|
46 |
-
const publicVideoIds = (publicVideos || []).map(v => v.id || "").filter(x => x)
|
47 |
|
48 |
useEffect(() => {
|
49 |
if (!publicVideos?.length) { return }
|
50 |
// note: it is important to ALWAYS set the current video to videoId
|
51 |
// even if it's undefined
|
52 |
setPublicVideos(publicVideos)
|
53 |
-
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
|
55 |
useEffect(() => {
|
56 |
// note: it is important to ALWAYS set the current video to videoId
|
57 |
// even if it's undefined
|
58 |
-
|
59 |
-
|
60 |
-
if (
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
}
|
67 |
}
|
68 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
|
70 |
-
const channelId = `${channel?.id || ""}`
|
71 |
-
// console.log("Main video= "+ videoId)
|
72 |
|
73 |
useEffect(() => {
|
74 |
// note: it is important to ALWAYS set the current video to videoId
|
75 |
// even if it's undefined
|
76 |
setPublicChannel(channel)
|
77 |
|
78 |
-
if (
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
}
|
85 |
}
|
86 |
-
|
|
|
87 |
|
88 |
|
89 |
// this is critical: it sync the current route (coming from server-side)
|
@@ -99,6 +139,7 @@ export function Main({
|
|
99 |
<TubeLayout>
|
100 |
{view === "home" && <HomeView />}
|
101 |
{view === "public_video" && <PublicVideoView />}
|
|
|
102 |
{view === "public_channels" && <PublicChannelsView />}
|
103 |
{view === "public_channel" && <PublicChannelView />}
|
104 |
{/*view === "user_videos" && <UserVideosView />*/}
|
|
|
8 |
import { PublicVideoView } from "./views/public-video-view"
|
9 |
import { UserAccountView } from "./views/user-account-view"
|
10 |
import { NotFoundView } from "./views/not-found-view"
|
11 |
+
import { ChannelInfo, InterfaceView, VideoInfo } from "@/types"
|
12 |
import { useEffect } from "react"
|
13 |
import { usePathname, useRouter } from "next/navigation"
|
14 |
import { TubeLayout } from "./interface/tube-layout"
|
15 |
+
import { PublicMusicVideosView } from "./views/public-music-videos-view"
|
16 |
+
import { getCollectionKey } from "@/lib/getCollectionKey"
|
17 |
|
18 |
// this is where we transition from the server-side space
|
19 |
// and the client-side space
|
|
|
24 |
// one benefit of doing this is that we will able to add some animations/transitions
|
25 |
// more easily
|
26 |
export function Main({
|
27 |
+
// view,
|
28 |
+
publicVideo,
|
29 |
publicVideos,
|
30 |
+
publicChannelVideos,
|
31 |
+
publicTracks,
|
32 |
+
publicTrack,
|
33 |
channel,
|
34 |
}: {
|
35 |
// server side params
|
36 |
+
// view?: InterfaceView
|
37 |
+
publicVideo?: VideoInfo
|
38 |
publicVideos?: VideoInfo[]
|
39 |
+
publicChannelVideos?: VideoInfo[]
|
40 |
+
publicTracks?: VideoInfo[]
|
41 |
+
publicTrack?: VideoInfo
|
42 |
channel?: ChannelInfo
|
43 |
}) {
|
44 |
+
// this could be also a parameter of main, where we pass this manually
|
45 |
const pathname = usePathname()
|
46 |
const router = useRouter()
|
47 |
|
|
|
50 |
const setPathname = useStore(s => s.setPathname)
|
51 |
const setPublicChannel = useStore(s => s.setPublicChannel)
|
52 |
const setPublicVideos = useStore(s => s.setPublicVideos)
|
53 |
+
const setPublicChannelVideos = useStore(s => s.setPublicChannelVideos)
|
54 |
+
const setPublicTracks = useStore(s => s.setPublicTracks)
|
55 |
+
const setPublicTrack = useStore(s => s.setPublicTrack)
|
|
|
|
|
56 |
|
57 |
useEffect(() => {
|
58 |
if (!publicVideos?.length) { return }
|
59 |
// note: it is important to ALWAYS set the current video to videoId
|
60 |
// even if it's undefined
|
61 |
setPublicVideos(publicVideos)
|
62 |
+
}, [getCollectionKey(publicVideos)])
|
63 |
+
|
64 |
+
|
65 |
+
useEffect(() => {
|
66 |
+
if (!publicChannelVideos?.length) { return }
|
67 |
+
// note: it is important to ALWAYS set the current video to videoId
|
68 |
+
// even if it's undefined
|
69 |
+
setPublicChannelVideos(publicChannelVideos)
|
70 |
+
}, [getCollectionKey(publicChannelVideos)])
|
71 |
+
|
72 |
+
useEffect(() => {
|
73 |
+
if (!publicTracks?.length) { return }
|
74 |
+
// note: it is important to ALWAYS set the current video to videoId
|
75 |
+
// even if it's undefined
|
76 |
+
setPublicTracks(publicTracks)
|
77 |
+
}, [getCollectionKey(publicTracks)])
|
78 |
+
|
79 |
|
80 |
useEffect(() => {
|
81 |
// note: it is important to ALWAYS set the current video to videoId
|
82 |
// even if it's undefined
|
83 |
+
setPublicTrack(publicTrack)
|
84 |
+
|
85 |
+
if (!publicTrack || !publicTrack?.id) { return }
|
86 |
+
|
87 |
+
// this is a hack for hugging face:
|
88 |
+
// we allow the ?v=<id> param on the root of the domain
|
89 |
+
if (pathname !== "/music") {
|
90 |
+
// console.log("we are on huggingface apparently!")
|
91 |
+
router.replace(`/music?m=${publicTrack.id}`)
|
92 |
}
|
93 |
+
}, [publicTrack?.id])
|
94 |
+
|
95 |
+
useEffect(() => {
|
96 |
+
// note: it is important to ALWAYS set the current video to videoId
|
97 |
+
// even if it's undefined
|
98 |
+
setPublicVideo(publicVideo)
|
99 |
+
|
100 |
+
if (!publicVideo || !publicVideo?.id) { return }
|
101 |
+
|
102 |
+
// this is a hack for hugging face:
|
103 |
+
// we allow the ?v=<id> param on the root of the domain
|
104 |
+
if (pathname !== "/watch") {
|
105 |
+
// console.log("we are on huggingface apparently!")
|
106 |
+
router.replace(`/watch?v=${publicVideo.id}`)
|
107 |
+
}
|
108 |
+
|
109 |
+
}, [publicVideo?.id])
|
110 |
|
|
|
|
|
111 |
|
112 |
useEffect(() => {
|
113 |
// note: it is important to ALWAYS set the current video to videoId
|
114 |
// even if it's undefined
|
115 |
setPublicChannel(channel)
|
116 |
|
117 |
+
if (!channel || !channel?.id) { return }
|
118 |
+
|
119 |
+
// this is a hack for hugging face:
|
120 |
+
// we allow the ?v=<id> param on the root of the domain
|
121 |
+
if (pathname !== "/channel") {
|
122 |
+
// console.log("we are on huggingface apparently!")
|
123 |
+
router.replace(`/channel?v=${channel.id}`)
|
124 |
}
|
125 |
+
|
126 |
+
}, [channel?.id])
|
127 |
|
128 |
|
129 |
// this is critical: it sync the current route (coming from server-side)
|
|
|
139 |
<TubeLayout>
|
140 |
{view === "home" && <HomeView />}
|
141 |
{view === "public_video" && <PublicVideoView />}
|
142 |
+
{view === "public_music_videos" && <PublicMusicVideosView />}
|
143 |
{view === "public_channels" && <PublicChannelsView />}
|
144 |
{view === "public_channel" && <PublicChannelView />}
|
145 |
{/*view === "user_videos" && <UserVideosView />*/}
|
src/app/music/index.tsx
DELETED
@@ -1,9 +0,0 @@
|
|
1 |
-
|
2 |
-
import { Main } from "../main"
|
3 |
-
// import { getChannels } from "../server/actions/ai-tube-hf/getChannels"
|
4 |
-
|
5 |
-
export default async function MusicPage() {
|
6 |
-
// const channels = await getChannels()
|
7 |
-
|
8 |
-
return (<Main />)
|
9 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/music/page.tsx
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { AppQueryProps } from "@/types"
|
3 |
+
import { Main } from "../main"
|
4 |
+
import { getVideos } from "../server/actions/ai-tube-hf/getVideos"
|
5 |
+
import { getVideo } from "../server/actions/ai-tube-hf/getVideo"
|
6 |
+
import { Metadata } from "next"
|
7 |
+
|
8 |
+
|
9 |
+
// https://nextjs.org/docs/pages/building-your-application/optimizing/fonts
|
10 |
+
export async function generateMetadata(
|
11 |
+
{ params, searchParams: { m: mediaId } }: AppQueryProps,
|
12 |
+
// parent: ResolvingMetadata
|
13 |
+
): Promise<Metadata> {
|
14 |
+
// read route params
|
15 |
+
|
16 |
+
const metadataBase = new URL('https://huggingface.co/spaces/jbilcke-hf/ai-tube')
|
17 |
+
|
18 |
+
try {
|
19 |
+
const publicTrack = await getVideo({ videoId: mediaId, neverThrow: true })
|
20 |
+
|
21 |
+
if (!publicTrack) {
|
22 |
+
throw new Error("Video not found")
|
23 |
+
}
|
24 |
+
|
25 |
+
|
26 |
+
const openGraph = {
|
27 |
+
// 'music.song' | 'music.album' | 'music.playlist' | 'music.radio_station'
|
28 |
+
// | 'profile' | 'website' | 'video.tv_show' | 'video.other' | 'video.movie' | 'video.episode';
|
29 |
+
type: "music.song",
|
30 |
+
|
31 |
+
duration: publicTrack.duration,
|
32 |
+
|
33 |
+
// albums?: null | string | URL | OGAlbum | Array<string | URL | OGAlbum>;
|
34 |
+
musicians: [publicTrack.channel.label, "AI (MusicGen)"],
|
35 |
+
|
36 |
+
// url: "https://example.com",
|
37 |
+
title: `${publicTrack.channel.label} - ${publicTrack.label}` || "", // put the video title here
|
38 |
+
description: publicTrack.description || "", // put the vide description here
|
39 |
+
siteName: "AiTube Music",
|
40 |
+
images: [
|
41 |
+
`https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${publicTrack.id}.webp`
|
42 |
+
],
|
43 |
+
audio: [
|
44 |
+
{
|
45 |
+
"url": publicTrack.assetUrl
|
46 |
+
}
|
47 |
+
]
|
48 |
+
}
|
49 |
+
|
50 |
+
return {
|
51 |
+
title: `${publicTrack.label} - AiTube Music`,
|
52 |
+
metadataBase,
|
53 |
+
openGraph,
|
54 |
+
}
|
55 |
+
} catch (err) {
|
56 |
+
return {
|
57 |
+
title: "AiTube Music",
|
58 |
+
metadataBase,
|
59 |
+
openGraph: {
|
60 |
+
type: "website",
|
61 |
+
// url: "https://example.com",
|
62 |
+
title: "AiTube Music", // put the video title here
|
63 |
+
description: "", // put the vide description here
|
64 |
+
siteName: "AiTube Music",
|
65 |
+
|
66 |
+
videos: [],
|
67 |
+
images: [],
|
68 |
+
},
|
69 |
+
}
|
70 |
+
}
|
71 |
+
}
|
72 |
+
|
73 |
+
export default async function MusicPage({ searchParams: { m: mediaId } }: AppQueryProps) {
|
74 |
+
const publicTracks = await getVideos({
|
75 |
+
sortBy: "date",
|
76 |
+
mandatoryTags: ["music"],
|
77 |
+
maxVideos: 25,
|
78 |
+
neverThrow: true,
|
79 |
+
})
|
80 |
+
|
81 |
+
// at some point we will probably migrate to a getMedia({ mediaId }) instead
|
82 |
+
const publicTrack = await getVideo({
|
83 |
+
videoId: mediaId,
|
84 |
+
neverThrow: true
|
85 |
+
})
|
86 |
+
|
87 |
+
return (<Main publicTracks={publicTracks} publicTrack={publicTrack} />)
|
88 |
+
}
|
src/app/page.tsx
CHANGED
@@ -58,12 +58,12 @@ export async function generateMetadata(
|
|
58 |
}
|
59 |
} catch (err) {
|
60 |
return {
|
61 |
-
title: "AI Tube
|
62 |
metadataBase,
|
63 |
openGraph: {
|
64 |
type: "website",
|
65 |
// url: "https://example.com",
|
66 |
-
title: "AI Tube
|
67 |
description: "", // put the vide description here
|
68 |
siteName: "AI Tube",
|
69 |
|
@@ -77,8 +77,11 @@ export async function generateMetadata(
|
|
77 |
// we have routes but on Hugging Face we don't see them
|
78 |
// so.. let's use the work around
|
79 |
export default async function Page({ searchParams: { v: videoId } }: AppQueryProps) {
|
80 |
-
const
|
|
|
|
|
|
|
81 |
return (
|
82 |
-
<Main
|
83 |
)
|
84 |
}
|
|
|
58 |
}
|
59 |
} catch (err) {
|
60 |
return {
|
61 |
+
title: "AI Tube",
|
62 |
metadataBase,
|
63 |
openGraph: {
|
64 |
type: "website",
|
65 |
// url: "https://example.com",
|
66 |
+
title: "AI Tube", // put the video title here
|
67 |
description: "", // put the vide description here
|
68 |
siteName: "AI Tube",
|
69 |
|
|
|
77 |
// we have routes but on Hugging Face we don't see them
|
78 |
// so.. let's use the work around
|
79 |
export default async function Page({ searchParams: { v: videoId } }: AppQueryProps) {
|
80 |
+
const publicVideo = await getVideo({
|
81 |
+
videoId,
|
82 |
+
neverThrow: true
|
83 |
+
})
|
84 |
return (
|
85 |
+
<Main publicVideo={publicVideo} />
|
86 |
)
|
87 |
}
|
src/app/playlist/page.tsx
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { AppQueryProps } from "@/types"
|
3 |
+
import { Main } from "../main"
|
4 |
+
import { getVideos } from "../server/actions/ai-tube-hf/getVideos"
|
5 |
+
import { getVideo } from "../server/actions/ai-tube-hf/getVideo"
|
6 |
+
import { Metadata } from "next"
|
7 |
+
|
8 |
+
|
9 |
+
// https://nextjs.org/docs/pages/building-your-application/optimizing/fonts
|
10 |
+
export async function generateMetadata(
|
11 |
+
{ params, searchParams: { m: mediaId } }: AppQueryProps,
|
12 |
+
// parent: ResolvingMetadata
|
13 |
+
): Promise<Metadata> {
|
14 |
+
// read route params
|
15 |
+
|
16 |
+
const metadataBase = new URL('https://huggingface.co/spaces/jbilcke-hf/ai-tube')
|
17 |
+
|
18 |
+
try {
|
19 |
+
const publicTrack = await getVideo({ videoId: mediaId, neverThrow: true })
|
20 |
+
|
21 |
+
if (!publicTrack) {
|
22 |
+
throw new Error("Video not found")
|
23 |
+
}
|
24 |
+
|
25 |
+
|
26 |
+
const openGraph = {
|
27 |
+
// 'music.song' | 'music.album' | 'music.playlist' | 'music.radio_station'
|
28 |
+
// | 'profile' | 'website' | 'video.tv_show' | 'video.other' | 'video.movie' | 'video.episode';
|
29 |
+
type: "music.playlist",
|
30 |
+
|
31 |
+
songs: [], // TODO!
|
32 |
+
|
33 |
+
// albums?: null | string | URL | OGAlbum | Array<string | URL | OGAlbum>;
|
34 |
+
creators: [publicTrack.channel.label, "AI (MusicGen)"],
|
35 |
+
|
36 |
+
// url: "https://example.com",
|
37 |
+
title: `${publicTrack.channel.label} - ${publicTrack.label}` || "", // put the video title here
|
38 |
+
description: publicTrack.description || "", // put the vide description here
|
39 |
+
siteName: "AiTube Music",
|
40 |
+
images: [
|
41 |
+
`https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${publicTrack.id}.webp`
|
42 |
+
],
|
43 |
+
audio: [
|
44 |
+
{
|
45 |
+
"url": publicTrack.assetUrl
|
46 |
+
}
|
47 |
+
]
|
48 |
+
}
|
49 |
+
|
50 |
+
return {
|
51 |
+
title: `${publicTrack.label} - AiTube Music`,
|
52 |
+
metadataBase,
|
53 |
+
openGraph,
|
54 |
+
}
|
55 |
+
} catch (err) {
|
56 |
+
return {
|
57 |
+
title: "AiTube Music",
|
58 |
+
metadataBase,
|
59 |
+
openGraph: {
|
60 |
+
type: "website",
|
61 |
+
// url: "https://example.com",
|
62 |
+
title: "AiTube Music", // put the video title here
|
63 |
+
description: "", // put the vide description here
|
64 |
+
siteName: "AiTube Music",
|
65 |
+
|
66 |
+
videos: [],
|
67 |
+
images: [],
|
68 |
+
},
|
69 |
+
}
|
70 |
+
}
|
71 |
+
}
|
72 |
+
|
73 |
+
export default async function PlaylistPage({ searchParams: { m: mediaId } }: AppQueryProps) {
|
74 |
+
const publicTracks = await getVideos({
|
75 |
+
sortBy: "date",
|
76 |
+
mandatoryTags: ["music"],
|
77 |
+
maxVideos: 25,
|
78 |
+
neverThrow: true,
|
79 |
+
})
|
80 |
+
|
81 |
+
// at some point we will probably migrate to a getMedia({ mediaId }) instead
|
82 |
+
const publicTrack = await getVideo({
|
83 |
+
videoId: mediaId,
|
84 |
+
neverThrow: true
|
85 |
+
})
|
86 |
+
|
87 |
+
return (<Main publicTracks={publicTracks} publicTrack={publicTrack} />)
|
88 |
+
}
|
src/app/server/actions/ai-tube-hf/getChannelVideos.ts
CHANGED
@@ -12,68 +12,81 @@ import { orientationToWidthHeight } from "../utils/orientationToWidthHeight"
|
|
12 |
export async function getChannelVideos({
|
13 |
channel,
|
14 |
status,
|
|
|
15 |
}: {
|
16 |
channel?: ChannelInfo
|
17 |
|
18 |
// filter videos by status
|
19 |
status?: VideoStatus
|
|
|
|
|
20 |
}): Promise<VideoInfo[]> {
|
21 |
|
22 |
if (!channel) { return [] }
|
23 |
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
|
|
29 |
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
|
|
58 |
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
|
72 |
-
|
73 |
-
|
74 |
|
75 |
-
|
76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
|
78 |
-
|
|
|
79 |
}
|
|
|
12 |
export async function getChannelVideos({
|
13 |
channel,
|
14 |
status,
|
15 |
+
neverThrow,
|
16 |
}: {
|
17 |
channel?: ChannelInfo
|
18 |
|
19 |
// filter videos by status
|
20 |
status?: VideoStatus
|
21 |
+
|
22 |
+
neverThrow?: boolean
|
23 |
}): Promise<VideoInfo[]> {
|
24 |
|
25 |
if (!channel) { return [] }
|
26 |
|
27 |
+
try {
|
28 |
+
const videos = await getVideoRequestsFromChannel({
|
29 |
+
channel,
|
30 |
+
apiKey: adminApiKey,
|
31 |
+
renewCache: true
|
32 |
+
})
|
33 |
|
34 |
+
// TODO: use a database instead
|
35 |
+
// normally
|
36 |
+
const queued = await getVideoIndex({ status: "queued" })
|
37 |
+
const published = await getVideoIndex({ status: "published" })
|
38 |
|
39 |
+
const validVideos = videos.map(v => {
|
40 |
+
let video: VideoInfo = {
|
41 |
+
id: v.id,
|
42 |
+
status: "submitted",
|
43 |
+
label: v.label,
|
44 |
+
description: v.description,
|
45 |
+
prompt: v.prompt,
|
46 |
+
thumbnailUrl: v.thumbnailUrl,
|
47 |
+
model: v.model,
|
48 |
+
lora: v.lora,
|
49 |
+
style: v.style,
|
50 |
+
voice: v.voice,
|
51 |
+
music: v.music,
|
52 |
+
assetUrl: "",
|
53 |
+
numberOfViews: 0,
|
54 |
+
numberOfLikes: 0,
|
55 |
+
numberOfDislikes: 0,
|
56 |
+
updatedAt: v.updatedAt,
|
57 |
+
tags: v.tags,
|
58 |
+
channel,
|
59 |
+
duration: v.duration || 0,
|
60 |
+
orientation: v.orientation,
|
61 |
+
...orientationToWidthHeight(v.orientation),
|
62 |
+
}
|
63 |
|
64 |
+
if (queued[v.id]) {
|
65 |
+
video = queued[v.id]
|
66 |
+
} else if (published[v.id]) {
|
67 |
+
video = published[v.id]
|
68 |
+
}
|
69 |
|
70 |
+
return video
|
71 |
+
}).filter(video => {
|
72 |
+
// if no filter is requested, we always return the video
|
73 |
+
if (!status || typeof status === "undefined") {
|
74 |
+
return true
|
75 |
+
}
|
76 |
|
77 |
+
return video.status === status
|
78 |
+
})
|
79 |
|
80 |
+
// ask Redis for the freshest stats
|
81 |
+
const results = await extendVideosWithStats(validVideos)
|
82 |
+
|
83 |
+
return results
|
84 |
+
} catch (err) {
|
85 |
+
if (neverThrow) {
|
86 |
+
console.error("failed to get channel videos:", err)
|
87 |
+
return []
|
88 |
+
}
|
89 |
|
90 |
+
throw err
|
91 |
+
}
|
92 |
}
|
src/app/server/actions/ai-tube-hf/getVideos.ts
CHANGED
@@ -14,6 +14,7 @@ export async function getVideos({
|
|
14 |
sortBy = "date",
|
15 |
ignoreVideoIds = [],
|
16 |
maxVideos = HARD_LIMIT,
|
|
|
17 |
}: {
|
18 |
// the videos MUST include those tags
|
19 |
mandatoryTags?: string[]
|
@@ -29,73 +30,84 @@ export async function getVideos({
|
|
29 |
ignoreVideoIds?: string[]
|
30 |
|
31 |
maxVideos?: number
|
|
|
|
|
32 |
}): Promise<VideoInfo[]> {
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
|
|
46 |
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
|
53 |
-
|
54 |
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
|
|
61 |
)
|
62 |
-
|
63 |
-
}
|
64 |
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
|
|
71 |
)
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
}
|
90 |
-
|
91 |
-
|
92 |
|
93 |
-
|
94 |
-
|
95 |
|
96 |
|
97 |
-
|
98 |
-
|
99 |
|
100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
101 |
}
|
|
|
14 |
sortBy = "date",
|
15 |
ignoreVideoIds = [],
|
16 |
maxVideos = HARD_LIMIT,
|
17 |
+
neverThrow = false,
|
18 |
}: {
|
19 |
// the videos MUST include those tags
|
20 |
mandatoryTags?: string[]
|
|
|
30 |
ignoreVideoIds?: string[]
|
31 |
|
32 |
maxVideos?: number
|
33 |
+
|
34 |
+
neverThrow?: boolean
|
35 |
}): Promise<VideoInfo[]> {
|
36 |
+
try {
|
37 |
+
// the index is gonna grow more and more,
|
38 |
+
// but in the future we will use some DB eg. Prisma or sqlite
|
39 |
+
const published = await getVideoIndex({
|
40 |
+
status: "published",
|
41 |
+
renewCache: true
|
42 |
+
})
|
43 |
+
|
44 |
+
|
45 |
+
let allPotentiallyValidVideos = Object.values(published)
|
46 |
+
|
47 |
+
if (ignoreVideoIds.length) {
|
48 |
+
allPotentiallyValidVideos = allPotentiallyValidVideos.filter(video => !ignoreVideoIds.includes(video.id))
|
49 |
+
}
|
50 |
|
51 |
+
if (sortBy === "date") {
|
52 |
+
allPotentiallyValidVideos.sort(((a, b) => b.updatedAt.localeCompare(a.updatedAt)))
|
53 |
+
} else {
|
54 |
+
allPotentiallyValidVideos.sort(() => Math.random() - 0.5)
|
55 |
+
}
|
56 |
|
57 |
+
let videosMatchingFilters: VideoInfo[] = allPotentiallyValidVideos
|
58 |
|
59 |
+
// filter videos by mandatory tags, or else we return everything
|
60 |
+
const mandatoryTagsList = mandatoryTags.map(tag => tag.toLowerCase().trim()).filter(tag => tag)
|
61 |
+
if (mandatoryTagsList.length) {
|
62 |
+
videosMatchingFilters = allPotentiallyValidVideos.filter(video =>
|
63 |
+
video.tags.some(tag =>
|
64 |
+
mandatoryTagsList.includes(tag.toLowerCase().trim())
|
65 |
+
)
|
66 |
)
|
67 |
+
}
|
|
|
68 |
|
69 |
+
// filter videos by mandatory tags, or else we return everything
|
70 |
+
const niceToHaveTagsList = niceToHaveTags.map(tag => tag.toLowerCase().trim()).filter(tag => tag)
|
71 |
+
if (niceToHaveTagsList.length) {
|
72 |
+
videosMatchingFilters = videosMatchingFilters.filter(video =>
|
73 |
+
video.tags.some(tag =>
|
74 |
+
mandatoryTagsList.includes(tag.toLowerCase().trim())
|
75 |
+
)
|
76 |
)
|
77 |
+
|
78 |
+
// if we don't have enough videos
|
79 |
+
if (videosMatchingFilters.length < maxVideos) {
|
80 |
+
// count how many we need
|
81 |
+
const nbMissingVideos = maxVideos - videosMatchingFilters.length
|
82 |
+
|
83 |
+
// then we try to fill the gap with valid videos from other topics
|
84 |
+
const videosToUseAsFiller = allPotentiallyValidVideos
|
85 |
+
.filter(video => !videosMatchingFilters.some(v => v.id === video.id)) // of course we don't reuse the same
|
86 |
+
// .sort(() => Math.random() - 0.5) // randomize them
|
87 |
+
.slice(0, nbMissingVideos) // and only pick those we need
|
88 |
+
|
89 |
+
videosMatchingFilters = [
|
90 |
+
...videosMatchingFilters,
|
91 |
+
...videosToUseAsFiller,
|
92 |
+
]
|
93 |
+
}
|
94 |
}
|
95 |
+
|
|
|
96 |
|
97 |
+
// we enforce the max limit of HARD_LIMIT (eg. 100)
|
98 |
+
const cappedVideos = videosMatchingFilters.slice(0, Math.min(HARD_LIMIT, maxVideos))
|
99 |
|
100 |
|
101 |
+
// finally, we ask Redis for the freshest stats
|
102 |
+
const videosWithStats = await extendVideosWithStats(cappedVideos)
|
103 |
|
104 |
+
return videosWithStats
|
105 |
+
} catch (err) {
|
106 |
+
if (neverThrow) {
|
107 |
+
console.error("failed to get videos:", err)
|
108 |
+
return []
|
109 |
+
}
|
110 |
+
|
111 |
+
throw err
|
112 |
+
}
|
113 |
}
|
src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts
CHANGED
@@ -148,6 +148,7 @@ ${prompt}
|
|
148 |
assetUrl: "", // will be generated in async
|
149 |
numberOfViews: 0,
|
150 |
numberOfLikes: 0,
|
|
|
151 |
updatedAt: new Date().toISOString(),
|
152 |
tags,
|
153 |
channel,
|
|
|
148 |
assetUrl: "", // will be generated in async
|
149 |
numberOfViews: 0,
|
150 |
numberOfLikes: 0,
|
151 |
+
numberOfDislikes: 0,
|
152 |
updatedAt: new Date().toISOString(),
|
153 |
tags,
|
154 |
channel,
|
src/app/server/actions/stats.ts
CHANGED
@@ -2,8 +2,11 @@
|
|
2 |
|
3 |
import { Redis } from "@upstash/redis"
|
4 |
|
5 |
-
import { redisToken, redisUrl } from "./config"
|
6 |
import { developerMode } from "@/app/config"
|
|
|
|
|
|
|
|
|
7 |
|
8 |
const redis = new Redis({
|
9 |
url: redisUrl,
|
@@ -66,3 +69,86 @@ export async function watchVideo(videoId: string): Promise<number> {
|
|
66 |
return 0
|
67 |
}
|
68 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
import { Redis } from "@upstash/redis"
|
4 |
|
|
|
5 |
import { developerMode } from "@/app/config"
|
6 |
+
import { WhoAmIUser, whoAmI } from "@/huggingface/hub/src"
|
7 |
+
|
8 |
+
import { redisToken, redisUrl } from "./config"
|
9 |
+
import { VideoRating } from "@/types"
|
10 |
|
11 |
const redis = new Redis({
|
12 |
url: redisUrl,
|
|
|
69 |
return 0
|
70 |
}
|
71 |
}
|
72 |
+
|
73 |
+
export async function getVideoRating(videoId: string, apiKey?: string): Promise<VideoRating> {
|
74 |
+
let numberOfLikes = 0
|
75 |
+
let numberOfDislikes = 0
|
76 |
+
let isLikedByUser = false
|
77 |
+
let isDislikedByUser = false
|
78 |
+
|
79 |
+
try {
|
80 |
+
// update video likes counter
|
81 |
+
numberOfLikes = (await redis.get<number>(`videos:${videoId}:stats:likes`)) || 0
|
82 |
+
numberOfDislikes = (await redis.get<number>(`videos:${videoId}:stats:dislikes`)) || 0
|
83 |
+
} catch (err) {
|
84 |
+
}
|
85 |
+
|
86 |
+
// optional: determine if the user liked or disliked the content
|
87 |
+
if (apiKey) {
|
88 |
+
try {
|
89 |
+
const credentials = { accessToken: apiKey }
|
90 |
+
|
91 |
+
const user = await whoAmI({ credentials }) as unknown as WhoAmIUser
|
92 |
+
const isLiked = await redis.get(`users:${user.id}:tastes:videos:${videoId}`)
|
93 |
+
if (isLiked !== null) {
|
94 |
+
isLikedByUser = Boolean(isLiked)
|
95 |
+
isDislikedByUser = !isLikedByUser
|
96 |
+
}
|
97 |
+
} catch (err) {
|
98 |
+
console.error("failed to get user like status")
|
99 |
+
}
|
100 |
+
}
|
101 |
+
|
102 |
+
return {
|
103 |
+
isLikedByUser,
|
104 |
+
isDislikedByUser,
|
105 |
+
numberOfLikes,
|
106 |
+
numberOfDislikes,
|
107 |
+
}
|
108 |
+
}
|
109 |
+
|
110 |
+
export async function rateVideo(videoId: string, liked: boolean, apiKey: string): Promise<VideoRating> {
|
111 |
+
// note: we want the like to throw an exception if it failed
|
112 |
+
let numberOfLikes = 0
|
113 |
+
let numberOfDislikes = 0
|
114 |
+
let isLikedByUser = false
|
115 |
+
let isDislikedByUser = false
|
116 |
+
|
117 |
+
const credentials = { accessToken: apiKey }
|
118 |
+
|
119 |
+
const user = await whoAmI({ credentials }) as unknown as WhoAmIUser
|
120 |
+
|
121 |
+
const hasLiked = await redis.get<boolean>(`users:${user.id}:activity:videos:${videoId}:liked`)
|
122 |
+
const hasAlreadyRatedAndDifferently = hasLiked !== null && liked !== hasLiked
|
123 |
+
|
124 |
+
await redis.set(`users:${user.id}:activity:videos:${videoId}:liked`, liked)
|
125 |
+
|
126 |
+
isLikedByUser = liked
|
127 |
+
isDislikedByUser = !liked
|
128 |
+
|
129 |
+
// if user has already rated the content, and it's different from the desired value,
|
130 |
+
// then we need to undo the rating
|
131 |
+
|
132 |
+
try {
|
133 |
+
if (liked) {
|
134 |
+
// update video likes counter
|
135 |
+
numberOfLikes = await redis.incr(`videos:${videoId}:stats:likes`)
|
136 |
+
if (hasAlreadyRatedAndDifferently) {
|
137 |
+
numberOfDislikes = await redis.decr(`videos:${videoId}:stats:dislikes`)
|
138 |
+
}
|
139 |
+
} else {
|
140 |
+
numberOfDislikes = await redis.incr(`videos:${videoId}:stats:dislikes`)
|
141 |
+
if (hasAlreadyRatedAndDifferently) {
|
142 |
+
numberOfLikes = await redis.decr(`videos:${videoId}:stats:likes`)
|
143 |
+
}
|
144 |
+
}
|
145 |
+
} catch (err) {
|
146 |
+
} finally {
|
147 |
+
return {
|
148 |
+
numberOfLikes,
|
149 |
+
numberOfDislikes,
|
150 |
+
isLikedByUser,
|
151 |
+
isDislikedByUser,
|
152 |
+
}
|
153 |
+
}
|
154 |
+
}
|
src/app/server/actions/utils/parseDatasetPrompt.ts
CHANGED
@@ -27,7 +27,7 @@ export function parseDatasetPrompt(markdown: string, channel: ChannelInfo): Pars
|
|
27 |
title: typeof title === "string" && title ? title : "",
|
28 |
description: typeof description === "string" && description ? description : "",
|
29 |
tags:
|
30 |
-
tags && typeof tags === "string" ? tags.split("-").map(x => x.trim()).filter(x => x)
|
31 |
: (channel.tags || []),
|
32 |
prompt: typeof prompt === "string" && prompt ? prompt : "",
|
33 |
model: parseVideoModelName(model, channel.model),
|
|
|
27 |
title: typeof title === "string" && title ? title : "",
|
28 |
description: typeof description === "string" && description ? description : "",
|
29 |
tags:
|
30 |
+
tags && typeof tags === "string" ? tags.split("- ").map(x => x.trim()).filter(x => x)
|
31 |
: (channel.tags || []),
|
32 |
prompt: typeof prompt === "string" && prompt ? prompt : "",
|
33 |
model: parseVideoModelName(model, channel.model),
|
src/app/state/useStore.ts
CHANGED
@@ -47,7 +47,16 @@ export const useStore = create<{
|
|
47 |
setPublicVideo: (publicVideo?: VideoInfo) => void
|
48 |
|
49 |
publicVideos: VideoInfo[]
|
50 |
-
setPublicVideos: (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
|
52 |
userVideo?: VideoInfo
|
53 |
setUserVideo: (userVideo?: VideoInfo) => void
|
@@ -76,6 +85,7 @@ export const useStore = create<{
|
|
76 |
const routes: Record<string, InterfaceView> = {
|
77 |
"/": "home",
|
78 |
"/watch": "public_video",
|
|
|
79 |
"/channels": "public_channels",
|
80 |
"/channel": "public_channel",
|
81 |
"/account": "user_account",
|
@@ -151,6 +161,26 @@ export const useStore = create<{
|
|
151 |
})
|
152 |
},
|
153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
userVideo: undefined,
|
155 |
setUserVideo: (userVideo?: VideoInfo) => { set({ userVideo }) },
|
156 |
|
|
|
47 |
setPublicVideo: (publicVideo?: VideoInfo) => void
|
48 |
|
49 |
publicVideos: VideoInfo[]
|
50 |
+
setPublicVideos: (publicVideos: VideoInfo[]) => void
|
51 |
+
|
52 |
+
publicChannelVideos: VideoInfo[]
|
53 |
+
setPublicChannelVideos: (publicChannelVideos: VideoInfo[]) => void
|
54 |
+
|
55 |
+
publicTrack?: VideoInfo
|
56 |
+
setPublicTrack: (publicTrack?: VideoInfo) => void
|
57 |
+
|
58 |
+
publicTracks: VideoInfo[]
|
59 |
+
setPublicTracks: (publicTracks: VideoInfo[]) => void
|
60 |
|
61 |
userVideo?: VideoInfo
|
62 |
setUserVideo: (userVideo?: VideoInfo) => void
|
|
|
85 |
const routes: Record<string, InterfaceView> = {
|
86 |
"/": "home",
|
87 |
"/watch": "public_video",
|
88 |
+
"/music": "public_music_videos",
|
89 |
"/channels": "public_channels",
|
90 |
"/channel": "public_channel",
|
91 |
"/account": "user_account",
|
|
|
161 |
})
|
162 |
},
|
163 |
|
164 |
+
|
165 |
+
publicTrack: undefined,
|
166 |
+
setPublicTrack: (publicTrack?: VideoInfo) => {
|
167 |
+
set({ publicTrack })
|
168 |
+
},
|
169 |
+
|
170 |
+
publicTracks: [],
|
171 |
+
setPublicTracks: (publicTracks: VideoInfo[] = []) => {
|
172 |
+
set({
|
173 |
+
publicTracks: Array.isArray(publicTracks) ? publicTracks : []
|
174 |
+
})
|
175 |
+
},
|
176 |
+
|
177 |
+
publicChannelVideos: [],
|
178 |
+
setPublicChannelVideos: (publicChannelVideos: VideoInfo[] = []) => {
|
179 |
+
set({
|
180 |
+
publicVideos: Array.isArray(publicChannelVideos) ? publicChannelVideos : []
|
181 |
+
})
|
182 |
+
},
|
183 |
+
|
184 |
userVideo: undefined,
|
185 |
setUserVideo: (userVideo?: VideoInfo) => { set({ userVideo }) },
|
186 |
|
src/app/views/home-view/index.tsx
CHANGED
@@ -43,7 +43,7 @@ export function HomeView() {
|
|
43 |
`sm:pr-4`
|
44 |
)}>
|
45 |
<VideoList
|
46 |
-
|
47 |
onSelect={handleSelect}
|
48 |
/>
|
49 |
</div>
|
|
|
43 |
`sm:pr-4`
|
44 |
)}>
|
45 |
<VideoList
|
46 |
+
items={publicVideos}
|
47 |
onSelect={handleSelect}
|
48 |
/>
|
49 |
</div>
|
src/app/views/public-channel-view/index.tsx
CHANGED
@@ -5,14 +5,13 @@ import { useEffect, useState, useTransition } from "react"
|
|
5 |
import { useStore } from "@/app/state/useStore"
|
6 |
import { cn } from "@/lib/utils"
|
7 |
import { VideoList } from "@/app/interface/video-list"
|
8 |
-
import { getChannelVideos } from "@/app/server/actions/ai-tube-hf/getChannelVideos"
|
9 |
import { DefaultAvatar } from "@/app/interface/default-avatar"
|
10 |
|
11 |
export function PublicChannelView() {
|
12 |
const [_isPending, startTransition] = useTransition()
|
13 |
const publicChannel = useStore(s => s.publicChannel)
|
14 |
-
const
|
15 |
-
const
|
16 |
|
17 |
const [channelThumbnail, setChannelThumbnail] = useState(publicChannel?.thumbnail || "")
|
18 |
|
@@ -33,16 +32,23 @@ export function PublicChannelView() {
|
|
33 |
return
|
34 |
}
|
35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
startTransition(async () => {
|
37 |
-
const
|
38 |
channel: publicChannel,
|
39 |
status: "published",
|
40 |
})
|
41 |
-
console.log("
|
42 |
-
|
43 |
})
|
|
|
44 |
|
45 |
-
setPublicVideos([])
|
46 |
}, [publicChannel, publicChannel?.id])
|
47 |
|
48 |
if (!publicChannel) { return null }
|
@@ -102,7 +108,7 @@ export function PublicChannelView() {
|
|
102 |
</div>
|
103 |
|
104 |
<VideoList
|
105 |
-
|
106 |
/>
|
107 |
</div>
|
108 |
)
|
|
|
5 |
import { useStore } from "@/app/state/useStore"
|
6 |
import { cn } from "@/lib/utils"
|
7 |
import { VideoList } from "@/app/interface/video-list"
|
|
|
8 |
import { DefaultAvatar } from "@/app/interface/default-avatar"
|
9 |
|
10 |
export function PublicChannelView() {
|
11 |
const [_isPending, startTransition] = useTransition()
|
12 |
const publicChannel = useStore(s => s.publicChannel)
|
13 |
+
const publicChannelVideos = useStore(s => s.publicChannelVideos)
|
14 |
+
const setPublicChannelVideos = useStore(s => s.setPublicChannelVideos)
|
15 |
|
16 |
const [channelThumbnail, setChannelThumbnail] = useState(publicChannel?.thumbnail || "")
|
17 |
|
|
|
32 |
return
|
33 |
}
|
34 |
|
35 |
+
// we already have all the videos we need (eg. they were rendered server-side)
|
36 |
+
// if (publicChannelVideos.length) { return }
|
37 |
+
|
38 |
+
// setPublicChannelVideos([])
|
39 |
+
|
40 |
+
// do we really need this? normally this was computed server-side
|
41 |
+
/*
|
42 |
startTransition(async () => {
|
43 |
+
const newPublicChannelVideos = await getChannelVideos({
|
44 |
channel: publicChannel,
|
45 |
status: "published",
|
46 |
})
|
47 |
+
console.log("publicChannelVideos:", newPublicChannelVideos)
|
48 |
+
setPublicChannelVideos(newPublicChannelVideos)
|
49 |
})
|
50 |
+
*/
|
51 |
|
|
|
52 |
}, [publicChannel, publicChannel?.id])
|
53 |
|
54 |
if (!publicChannel) { return null }
|
|
|
108 |
</div>
|
109 |
|
110 |
<VideoList
|
111 |
+
items={publicChannelVideos}
|
112 |
/>
|
113 |
</div>
|
114 |
)
|
src/app/views/public-music-videos-view/index.tsx
CHANGED
@@ -6,28 +6,31 @@ import { useStore } from "@/app/state/useStore"
|
|
6 |
import { cn } from "@/lib/utils"
|
7 |
import { VideoInfo } from "@/types"
|
8 |
import { getVideos } from "@/app/server/actions/ai-tube-hf/getVideos"
|
9 |
-
import {
|
10 |
|
11 |
export function PublicMusicVideosView() {
|
12 |
const [_isPending, startTransition] = useTransition()
|
13 |
const setView = useStore(s => s.setView)
|
14 |
-
const
|
15 |
-
const
|
16 |
-
const
|
17 |
|
18 |
useEffect(() => {
|
|
|
|
|
19 |
startTransition(async () => {
|
20 |
-
const
|
21 |
sortBy: "date",
|
22 |
-
mandatoryTags:["music"],
|
23 |
maxVideos: 25
|
24 |
})
|
25 |
|
26 |
-
setPublicVideos(
|
27 |
})
|
|
|
28 |
}, [])
|
29 |
|
30 |
-
const handleSelect = (
|
31 |
//
|
32 |
// setView("public_video")
|
33 |
// setPublicVideo(video)
|
@@ -38,9 +41,10 @@ export function PublicMusicVideosView() {
|
|
38 |
<div className={cn(
|
39 |
`sm:pr-4`
|
40 |
)}>
|
41 |
-
<
|
42 |
-
|
43 |
onSelect={handleSelect}
|
|
|
44 |
/>
|
45 |
</div>
|
46 |
)
|
|
|
6 |
import { cn } from "@/lib/utils"
|
7 |
import { VideoInfo } from "@/types"
|
8 |
import { getVideos } from "@/app/server/actions/ai-tube-hf/getVideos"
|
9 |
+
import { TrackList } from "@/app/interface/track-list"
|
10 |
|
11 |
export function PublicMusicVideosView() {
|
12 |
const [_isPending, startTransition] = useTransition()
|
13 |
const setView = useStore(s => s.setView)
|
14 |
+
const setPublicTracks = useStore(s => s.setPublicTracks)
|
15 |
+
const setPublicTrack = useStore(s => s.setPublicTrack)
|
16 |
+
const publicTracks = useStore(s => s.publicTracks)
|
17 |
|
18 |
useEffect(() => {
|
19 |
+
|
20 |
+
/*
|
21 |
startTransition(async () => {
|
22 |
+
const newTracks = await getVideos({
|
23 |
sortBy: "date",
|
24 |
+
mandatoryTags: ["music"],
|
25 |
maxVideos: 25
|
26 |
})
|
27 |
|
28 |
+
setPublicVideos(newTracks)
|
29 |
})
|
30 |
+
*/
|
31 |
}, [])
|
32 |
|
33 |
+
const handleSelect = (media: VideoInfo) => {
|
34 |
//
|
35 |
// setView("public_video")
|
36 |
// setPublicVideo(video)
|
|
|
41 |
<div className={cn(
|
42 |
`sm:pr-4`
|
43 |
)}>
|
44 |
+
<TrackList
|
45 |
+
items={publicTracks}
|
46 |
onSelect={handleSelect}
|
47 |
+
layout="table"
|
48 |
/>
|
49 |
</div>
|
50 |
)
|
src/app/views/public-video-view/index.tsx
CHANGED
@@ -11,14 +11,17 @@ import { BiCameraMovie } from "react-icons/bi"
|
|
11 |
import { useStore } from "@/app/state/useStore"
|
12 |
import { cn } from "@/lib/utils"
|
13 |
import { VideoPlayer } from "@/app/interface/video-player"
|
14 |
-
|
15 |
import { ActionButton, actionButtonClassName } from "@/app/interface/action-button"
|
16 |
import { RecommendedVideos } from "@/app/interface/recommended-videos"
|
17 |
import { isCertifiedUser } from "@/app/certification"
|
18 |
import { watchVideo } from "@/app/server/actions/stats"
|
19 |
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
20 |
import { DefaultAvatar } from "@/app/interface/default-avatar"
|
21 |
-
|
|
|
|
|
|
|
22 |
export function PublicVideoView() {
|
23 |
const [_pending, startTransition] = useTransition()
|
24 |
const video = useStore(s => s.publicVideo)
|
@@ -199,6 +202,9 @@ export function PublicVideoView() {
|
|
199 |
`items-center`,
|
200 |
`space-x-2`
|
201 |
)}>
|
|
|
|
|
|
|
202 |
{/* SHARE */}
|
203 |
<div className={cn(
|
204 |
`flex flex-row`,
|
@@ -249,6 +255,9 @@ export function PublicVideoView() {
|
|
249 |
<LuScrollText />
|
250 |
<span>See prompt</span>
|
251 |
</ActionButton>
|
|
|
|
|
|
|
252 |
</div>
|
253 |
|
254 |
</div>
|
|
|
11 |
import { useStore } from "@/app/state/useStore"
|
12 |
import { cn } from "@/lib/utils"
|
13 |
import { VideoPlayer } from "@/app/interface/video-player"
|
14 |
+
|
15 |
import { ActionButton, actionButtonClassName } from "@/app/interface/action-button"
|
16 |
import { RecommendedVideos } from "@/app/interface/recommended-videos"
|
17 |
import { isCertifiedUser } from "@/app/certification"
|
18 |
import { watchVideo } from "@/app/server/actions/stats"
|
19 |
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
20 |
import { DefaultAvatar } from "@/app/interface/default-avatar"
|
21 |
+
import { LikeButton } from "@/app/interface/like-button"
|
22 |
+
|
23 |
+
import { ReportModal } from "../report-modal"
|
24 |
+
|
25 |
export function PublicVideoView() {
|
26 |
const [_pending, startTransition] = useTransition()
|
27 |
const video = useStore(s => s.publicVideo)
|
|
|
202 |
`items-center`,
|
203 |
`space-x-2`
|
204 |
)}>
|
205 |
+
|
206 |
+
<LikeButton video={video} />
|
207 |
+
|
208 |
{/* SHARE */}
|
209 |
<div className={cn(
|
210 |
`flex flex-row`,
|
|
|
255 |
<LuScrollText />
|
256 |
<span>See prompt</span>
|
257 |
</ActionButton>
|
258 |
+
|
259 |
+
<ReportModal video={video} />
|
260 |
+
|
261 |
</div>
|
262 |
|
263 |
</div>
|
src/app/views/report-modal/index.tsx
ADDED
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ReactNode, useState } from "react"
|
2 |
+
import { LuShieldAlert } from "react-icons/lu"
|
3 |
+
|
4 |
+
import { Button } from "@/components/ui/button"
|
5 |
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTrigger } from "@/components/ui/dialog"
|
6 |
+
|
7 |
+
import { ChannelInfo, VideoInfo } from "@/types"
|
8 |
+
import { ActionButton } from "@/app/interface/action-button"
|
9 |
+
|
10 |
+
// modal to report a video or channel
|
11 |
+
export function ReportModal({
|
12 |
+
video,
|
13 |
+
channel,
|
14 |
+
children,
|
15 |
+
}: {
|
16 |
+
video?: VideoInfo
|
17 |
+
channel?: ChannelInfo
|
18 |
+
children?: ReactNode
|
19 |
+
}) {
|
20 |
+
const [isOpen, setOpen] = useState(false)
|
21 |
+
|
22 |
+
return (
|
23 |
+
<Dialog open={isOpen} onOpenChange={(open) => {
|
24 |
+
if (!open) {
|
25 |
+
setOpen(open)
|
26 |
+
}
|
27 |
+
}}>
|
28 |
+
<DialogTrigger asChild>
|
29 |
+
<ActionButton onClick={() => setOpen(true)}>
|
30 |
+
<LuShieldAlert className="w-4 h-4" />
|
31 |
+
<span>Report</span>
|
32 |
+
</ActionButton>
|
33 |
+
</DialogTrigger>
|
34 |
+
<DialogContent className="sm:max-w-[512px] text-zinc-200">
|
35 |
+
<DialogHeader>
|
36 |
+
<DialogDescription className="w-full text-center text-lg font-normal text-stone-300">
|
37 |
+
Report an issue with the video
|
38 |
+
</DialogDescription>
|
39 |
+
</DialogHeader>
|
40 |
+
|
41 |
+
<div className="flex flex-col w-full space-y-4">
|
42 |
+
<p className="text-sm">If you believe there is an issue with the prompt, you can ask the author to remove it or change its prompt, by creating a pull request explaining why:</p>
|
43 |
+
|
44 |
+
<div className="flex flex-row py-2">
|
45 |
+
{video && video.id ? <ActionButton
|
46 |
+
href={
|
47 |
+
`https://huggingface.co/datasets/${
|
48 |
+
video.channel.datasetUser
|
49 |
+
}/${
|
50 |
+
video.channel.datasetName
|
51 |
+
}/delete/main/prompt_${
|
52 |
+
video.id
|
53 |
+
}.md`
|
54 |
+
}
|
55 |
+
>
|
56 |
+
<span>Request author for prompt removal</span>
|
57 |
+
</ActionButton>
|
58 |
+
: null}
|
59 |
+
</div>
|
60 |
+
|
61 |
+
<p className="text-sm">
|
62 |
+
If the prompt is in violation of <a href="https://huggingface.co/content-guidelines" target="_blank">our content guidelines</a>,
|
63 |
+
you can flag the channel from the Hugging Face dataset page:
|
64 |
+
</p>
|
65 |
+
|
66 |
+
<div className="flex flex-row py-2">
|
67 |
+
{video && video.id ? <ActionButton
|
68 |
+
href={
|
69 |
+
`https://huggingface.co/datasets/${
|
70 |
+
video.channel.datasetUser
|
71 |
+
}/${
|
72 |
+
video.channel.datasetName
|
73 |
+
}`
|
74 |
+
}
|
75 |
+
>
|
76 |
+
<span>Click here to open the dataset</span>
|
77 |
+
</ActionButton>
|
78 |
+
: null}
|
79 |
+
|
80 |
+
|
81 |
+
{channel && channel.id ? <ActionButton
|
82 |
+
href={
|
83 |
+
`https://huggingface.co/datasets/${
|
84 |
+
channel.datasetUser
|
85 |
+
}/${
|
86 |
+
channel.datasetName
|
87 |
+
}`
|
88 |
+
}
|
89 |
+
>
|
90 |
+
<span>Click here to open the dataset</span>
|
91 |
+
</ActionButton>
|
92 |
+
: null}
|
93 |
+
</div>
|
94 |
+
|
95 |
+
<div className="flex flex-col items-center justify-center">
|
96 |
+
<img src="/report.jpg" className="rounded w-[300px]" />
|
97 |
+
</div>
|
98 |
+
|
99 |
+
<p className="text-sm">
|
100 |
+
Finally, if you believe the content violates or infringes your intellectual property rights,
|
101 |
+
you may <span className="text-medium">send your complaint</span> to <a href="mailto:[email protected]" target="_blank" className="font-mono text-xs bg-neutral-200 rounded-lg text-neutral-700 px-1 py-0.5">[email protected]</a> with detailed and accurate information supporting your claim,
|
102 |
+
in addition to the possibility of flagging the allegedly infringing Content.
|
103 |
+
|
104 |
+
You also represent and warrant that you will not knowingly provide misleading information to support your claim.
|
105 |
+
</p>
|
106 |
+
</div>
|
107 |
+
<DialogFooter>
|
108 |
+
<Button
|
109 |
+
type="button"
|
110 |
+
variant="outline"
|
111 |
+
onClick={() => {
|
112 |
+
setOpen(false)
|
113 |
+
}}
|
114 |
+
>
|
115 |
+
Close
|
116 |
+
</Button>
|
117 |
+
</DialogFooter>
|
118 |
+
</DialogContent>
|
119 |
+
</Dialog>
|
120 |
+
)
|
121 |
+
}
|
src/app/watch/page.tsx
CHANGED
@@ -24,14 +24,16 @@ export async function generateMetadata(
|
|
24 |
}
|
25 |
|
26 |
return {
|
27 |
-
title: `${video.label} -
|
28 |
metadataBase,
|
29 |
openGraph: {
|
30 |
-
|
|
|
|
|
31 |
// url: "https://example.com",
|
32 |
title: video.label || "", // put the video title here
|
33 |
-
description: video.description || "", // put the
|
34 |
-
siteName: "
|
35 |
images: [
|
36 |
`https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${video.id}.webp`
|
37 |
],
|
@@ -45,14 +47,14 @@ export async function generateMetadata(
|
|
45 |
}
|
46 |
} catch (err) {
|
47 |
return {
|
48 |
-
title: "
|
49 |
metadataBase,
|
50 |
openGraph: {
|
51 |
type: "website",
|
52 |
// url: "https://example.com",
|
53 |
-
title: "
|
54 |
description: "", // put the vide description here
|
55 |
-
siteName: "
|
56 |
|
57 |
videos: [],
|
58 |
images: [],
|
@@ -63,9 +65,9 @@ export async function generateMetadata(
|
|
63 |
|
64 |
|
65 |
export default async function WatchPage({ searchParams: { v: videoId } }: AppQueryProps) {
|
66 |
-
const
|
67 |
// console.log("WatchPage: --> " + video?.id)
|
68 |
return (
|
69 |
-
<Main
|
70 |
)
|
71 |
}
|
|
|
24 |
}
|
25 |
|
26 |
return {
|
27 |
+
title: `${video.label} - AiTube`,
|
28 |
metadataBase,
|
29 |
openGraph: {
|
30 |
+
// some cool stuff we could use here:
|
31 |
+
// 'video.tv_show' | 'video.other' | 'video.movie' | 'video.episode';
|
32 |
+
type: "video.other",
|
33 |
// url: "https://example.com",
|
34 |
title: video.label || "", // put the video title here
|
35 |
+
description: video.description || "", // put the video description here
|
36 |
+
siteName: "AiTube",
|
37 |
images: [
|
38 |
`https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${video.id}.webp`
|
39 |
],
|
|
|
47 |
}
|
48 |
} catch (err) {
|
49 |
return {
|
50 |
+
title: "AiTube",
|
51 |
metadataBase,
|
52 |
openGraph: {
|
53 |
type: "website",
|
54 |
// url: "https://example.com",
|
55 |
+
title: "AiTube", // put the video title here
|
56 |
description: "", // put the vide description here
|
57 |
+
siteName: "AiTube",
|
58 |
|
59 |
videos: [],
|
60 |
images: [],
|
|
|
65 |
|
66 |
|
67 |
export default async function WatchPage({ searchParams: { v: videoId } }: AppQueryProps) {
|
68 |
+
const publicVideo = await getVideo({ videoId, neverThrow: true })
|
69 |
// console.log("WatchPage: --> " + video?.id)
|
70 |
return (
|
71 |
+
<Main publicVideo={publicVideo} />
|
72 |
)
|
73 |
}
|
src/components/ui/dialog.tsx
CHANGED
@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
|
|
41 |
<DialogPrimitive.Content
|
42 |
ref={ref}
|
43 |
className={cn(
|
44 |
-
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full dark:border-neutral-800 dark:bg-neutral-
|
45 |
className
|
46 |
)}
|
47 |
{...props}
|
|
|
41 |
<DialogPrimitive.Content
|
42 |
ref={ref}
|
43 |
className={cn(
|
44 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full dark:border-neutral-800 dark:bg-neutral-900",
|
45 |
className
|
46 |
)}
|
47 |
{...props}
|
src/lib/getCollectionKey.ts
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// compute a "collection key" that we can use in our useEffects and such
|
2 |
+
export function getCollectionKey(input?: any): string {
|
3 |
+
const collection: any[] = Array.isArray(input) ? input : [`${input || ""}`]
|
4 |
+
return (collection || []).map((v: any) => (v as any)?.id || JSON.stringify(v)).filter(x => x).join("--")
|
5 |
+
}
|
src/types.ts
CHANGED
@@ -380,6 +380,13 @@ export type VideoInfo = {
|
|
380 |
*/
|
381 |
numberOfLikes: number
|
382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
383 |
/**
|
384 |
* When was the video updated
|
385 |
*/
|
@@ -632,7 +639,21 @@ export type AppQueryProps = {
|
|
632 |
params: { id: string }
|
633 |
searchParams: {
|
634 |
v?: string | string[],
|
|
|
635 |
c?: string | string[],
|
636 |
[key: string]: string | string[] | undefined
|
637 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
638 |
}
|
|
|
380 |
*/
|
381 |
numberOfLikes: number
|
382 |
|
383 |
+
/**
|
384 |
+
* Counter for the number of dislikes
|
385 |
+
*
|
386 |
+
* Note: should be managed by the index to prevent cheating
|
387 |
+
*/
|
388 |
+
numberOfDislikes: number
|
389 |
+
|
390 |
/**
|
391 |
* When was the video updated
|
392 |
*/
|
|
|
639 |
params: { id: string }
|
640 |
searchParams: {
|
641 |
v?: string | string[],
|
642 |
+
m?: string | string[],
|
643 |
c?: string | string[],
|
644 |
[key: string]: string | string[] | undefined
|
645 |
}
|
646 |
+
}
|
647 |
+
|
648 |
+
export type MediaDisplayLayout =
|
649 |
+
| "grid" // default mode, items goas back to the line
|
650 |
+
| "horizontal" // will be used for a "Netflix" horizontal sliding mode
|
651 |
+
| "vertical" // used in the right recommendation panel
|
652 |
+
| "table" // used when shown in a table mode
|
653 |
+
|
654 |
+
export type VideoRating = {
|
655 |
+
isLikedByUser: boolean
|
656 |
+
isDislikedByUser: boolean
|
657 |
+
numberOfLikes: number
|
658 |
+
numberOfDislikes: number
|
659 |
}
|
tailwind.config.js
CHANGED
@@ -47,6 +47,8 @@ module.exports = {
|
|
47 |
'print': { 'raw': 'print' },
|
48 |
},
|
49 |
height: {
|
|
|
|
|
50 |
17: '4.25rem', // 68px
|
51 |
18: '4.5rem', // 72px
|
52 |
19: '4.75rem', // 76px
|
@@ -57,6 +59,8 @@ module.exports = {
|
|
57 |
26: '6.5rem', // 104px
|
58 |
},
|
59 |
width: {
|
|
|
|
|
60 |
17: '4.25rem', // 68px
|
61 |
18: '4.5rem', // 72px
|
62 |
19: '4.75rem', // 76px
|
|
|
47 |
'print': { 'raw': 'print' },
|
48 |
},
|
49 |
height: {
|
50 |
+
'6.5': '1.625rem', // 26px
|
51 |
+
7: '1.75rem', // 28px
|
52 |
17: '4.25rem', // 68px
|
53 |
18: '4.5rem', // 72px
|
54 |
19: '4.75rem', // 76px
|
|
|
59 |
26: '6.5rem', // 104px
|
60 |
},
|
61 |
width: {
|
62 |
+
'6.5': '1.625rem', // 26px
|
63 |
+
7: '1.75rem', // 28px
|
64 |
17: '4.25rem', // 68px
|
65 |
18: '4.5rem', // 72px
|
66 |
19: '4.75rem', // 76px
|