jbilcke-hf HF staff commited on
Commit
8f2b05f
1 Parent(s): e3d26ad

add like button and report button

Browse files
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 publicVideos = await getChannelVideos({
10
  channel: channel,
11
  status: "published",
 
12
  })
13
 
14
- return (<Main channel={channel} publicVideos={publicVideos} />)
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 { HiOutlineMusicNote } from "react-icons/hi"
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={<HiOutlineMusicNote className="h-5 w-5" />}
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
- videos={recommendedVideos}
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
- "scale-80 ml-1 mb-2",
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
- video,
18
  className = "",
19
- layout = "normal",
20
  onSelect,
21
  index
22
  }: {
23
- video: VideoInfo
24
  className?: string
25
- layout?: "normal" | "compact"
26
- onSelect?: (video: VideoInfo) => void
27
  index: number
28
  }) {
29
  const ref = useRef<HTMLVideoElement>(null)
30
  const [duration, setDuration] = useState(0)
31
 
32
- const [channelThumbnail, setChannelThumbnail] = useState(video.channel.thumbnail)
33
- const [videoThumbnail, setVideoThumbnail] = useState(
34
- `https://huggingface.co/datasets/jbilcke-hf/ai-tube-index/resolve/main/videos/${video.id}.webp`
35
  )
36
- const [videoThumbnailReady, setVideoThumbnailReady] = useState(false)
37
- const [shouldLoadVideo, setShouldLoadVideo] = useState(false)
38
 
39
- const isCompact = layout === "compact"
40
 
41
  const handlePointerEnter = () => {
42
  // ref.current?.load()
@@ -53,7 +52,7 @@ export function VideoCard({
53
  }
54
 
55
  const handleClick = () => {
56
- onSelect?.(video)
57
  }
58
 
59
  const handleBadChannelThumbnail = () => {
@@ -68,12 +67,12 @@ export function VideoCard({
68
 
69
  useEffect(() => {
70
  setTimeout(() => {
71
- setShouldLoadVideo(true)
72
  }, index * 1500)
73
  }, [index])
74
 
75
  return (
76
- <Link href={`/watch?v=${video.id}`}>
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
- {videoThumbnailReady && shouldLoadVideo
102
  ? <video
103
  // mute the video
104
  muted
@@ -107,7 +106,7 @@ export function VideoCard({
107
  playsInline
108
 
109
  ref={ref}
110
- src={video.assetUrl}
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={videoThumbnail}
122
  className={cn(
123
  `absolute`,
124
  `aspect-video`,
125
  // `aspect-video object-cover`,
126
  `rounded-lg overflow-hidden`,
127
- videoThumbnailReady ? `opacity-100`: 'opacity-0',
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
- setShouldLoadVideo(true)
134
  }}
135
  onLoad={() => {
136
- setVideoThumbnailReady(true)
137
  }}
138
  onError={() => {
139
- setVideoThumbnail(transparentImage)
140
- setVideoThumbnailReady(false)
141
  }}
142
  />
143
  </div>
@@ -180,7 +179,7 @@ export function VideoCard({
180
  </div>
181
  </div>
182
  : <DefaultAvatar
183
- username={video.channel.datasetUser}
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
- )}>{video.label}</h3>
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>{video.channel.label}</div>
203
- {isCertifiedUser(video.channel.datasetUser) ? <div><RiCheckboxCircleFill className="" /></div> : null}
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>{video.numberOfViews} views</div>
213
  <div className="font-semibold scale-125">·</div>
214
- <div>{formatTimeAgo(video.updatedAt)}</div>
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 { cn } from "@/lib/utils"
2
- import { VideoInfo } from "@/types"
3
 
4
- import { VideoCard } from "../video-card"
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
- <div
30
- className={cn(
31
- layout === "grid"
32
- ? `grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`
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
- video,
 
26
  publicVideos,
 
 
 
27
  channel,
28
  }: {
29
  // server side params
30
- video?: VideoInfo
 
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 videoId = `${video?.id || ""}`
44
- // console.log("Main video= "+ videoId)
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
- }, publicVideoIds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  useEffect(() => {
56
  // note: it is important to ALWAYS set the current video to videoId
57
  // even if it's undefined
58
- setPublicVideo(video)
59
-
60
- if (videoId) {
61
- // this is a hack for hugging face:
62
- // we allow the ?v=<id> param on the root of the domain
63
- if (pathname !== "/watch") {
64
- // console.log("we are on huggingface apparently!")
65
- router.replace(`/watch?v=${videoId}`)
66
- }
67
  }
68
- }, [videoId])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (channelId) {
79
- // this is a hack for hugging face:
80
- // we allow the ?v=<id> param on the root of the domain
81
- if (pathname !== "/channel") {
82
- // console.log("we are on huggingface apparently!")
83
- router.replace(`/channel?v=${channelId}`)
84
- }
85
  }
86
- }, [channelId])
 
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 - 404 Video Not Found",
62
  metadataBase,
63
  openGraph: {
64
  type: "website",
65
  // url: "https://example.com",
66
- title: "AI Tube - 404 Not Found", // put the video title here
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 video = await getVideo({ videoId, neverThrow: true })
 
 
 
81
  return (
82
- <Main video={video} />
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
- const videos = await getVideoRequestsFromChannel({
25
- channel,
26
- apiKey: adminApiKey,
27
- renewCache: true
28
- })
 
29
 
30
- // TODO: use a database instead
31
- // normally
32
- const queued = await getVideoIndex({ status: "queued" })
33
- const published = await getVideoIndex({ status: "published" })
34
 
35
- const validVideos = videos.map(v => {
36
- let video: VideoInfo = {
37
- id: v.id,
38
- status: "submitted",
39
- label: v.label,
40
- description: v.description,
41
- prompt: v.prompt,
42
- thumbnailUrl: v.thumbnailUrl,
43
- model: v.model,
44
- lora: v.lora,
45
- style: v.style,
46
- voice: v.voice,
47
- music: v.music,
48
- assetUrl: "",
49
- numberOfViews: 0,
50
- numberOfLikes: 0,
51
- updatedAt: v.updatedAt,
52
- tags: v.tags,
53
- channel,
54
- duration: v.duration || 0,
55
- orientation: v.orientation,
56
- ...orientationToWidthHeight(v.orientation),
57
- }
 
58
 
59
- if (queued[v.id]) {
60
- video = queued[v.id]
61
- } else if (published[v.id]) {
62
- video = published[v.id]
63
- }
64
 
65
- return video
66
- }).filter(video => {
67
- // if no filter is requested, we always return the video
68
- if (!status || typeof status === "undefined") {
69
- return true
70
- }
71
 
72
- return video.status === status
73
- })
74
 
75
- // ask Redis for the freshest stats
76
- const results = await extendVideosWithStats(validVideos)
 
 
 
 
 
 
 
77
 
78
- return results
 
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
- // the index is gonna grow more and more,
34
- // but in the future we will use some DB eg. Prisma or sqlite
35
- const published = await getVideoIndex({
36
- status: "published",
37
- renewCache: true
38
- })
39
-
40
-
41
- let allPotentiallyValidVideos = Object.values(published)
42
-
43
- if (ignoreVideoIds.length) {
44
- allPotentiallyValidVideos = allPotentiallyValidVideos.filter(video => !ignoreVideoIds.includes(video.id))
45
- }
 
46
 
47
- if (sortBy === "date") {
48
- allPotentiallyValidVideos.sort(((a, b) => b.updatedAt.localeCompare(a.updatedAt)))
49
- } else {
50
- allPotentiallyValidVideos.sort(() => Math.random() - 0.5)
51
- }
52
 
53
- let videosMatchingFilters: VideoInfo[] = allPotentiallyValidVideos
54
 
55
- // filter videos by mandatory tags, or else we return everything
56
- const mandatoryTagsList = mandatoryTags.map(tag => tag.toLowerCase().trim()).filter(tag => tag)
57
- if (mandatoryTagsList.length) {
58
- videosMatchingFilters = allPotentiallyValidVideos.filter(video =>
59
- video.tags.some(tag =>
60
- mandatoryTagsList.includes(tag.toLowerCase().trim())
 
61
  )
62
- )
63
- }
64
 
65
- // filter videos by mandatory tags, or else we return everything
66
- const niceToHaveTagsList = niceToHaveTags.map(tag => tag.toLowerCase().trim()).filter(tag => tag)
67
- if (niceToHaveTagsList.length) {
68
- videosMatchingFilters = videosMatchingFilters.filter(video =>
69
- video.tags.some(tag =>
70
- mandatoryTagsList.includes(tag.toLowerCase().trim())
 
71
  )
72
- )
73
-
74
- // if we don't have enough videos
75
- if (videosMatchingFilters.length < maxVideos) {
76
- // count how many we need
77
- const nbMissingVideos = maxVideos - videosMatchingFilters.length
78
-
79
- // then we try to fill the gap with valid videos from other topics
80
- const videosToUseAsFiller = allPotentiallyValidVideos
81
- .filter(video => !videosMatchingFilters.some(v => v.id === video.id)) // of course we don't reuse the same
82
- // .sort(() => Math.random() - 0.5) // randomize them
83
- .slice(0, nbMissingVideos) // and only pick those we need
84
-
85
- videosMatchingFilters = [
86
- ...videosMatchingFilters,
87
- ...videosToUseAsFiller,
88
- ]
89
  }
90
- }
91
-
92
 
93
- // we enforce the max limit of HARD_LIMIT (eg. 100)
94
- const cappedVideos = videosMatchingFilters.slice(0, Math.min(HARD_LIMIT, maxVideos))
95
 
96
 
97
- // finally, we ask Redis for the freshest stats
98
- const videosWithStats = await extendVideosWithStats(cappedVideos)
99
 
100
- return videosWithStats
 
 
 
 
 
 
 
 
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: (userVideos: VideoInfo[]) => void
 
 
 
 
 
 
 
 
 
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
- videos={publicVideos}
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 publicVideos = useStore(s => s.publicVideos)
15
- const setPublicVideos = useStore(s => s.setPublicVideos)
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 videos = await getChannelVideos({
38
  channel: publicChannel,
39
  status: "published",
40
  })
41
- console.log("videos:", videos)
42
- setPublicVideos(videos)
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
- videos={publicVideos}
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 { VideoList } from "@/app/interface/video-list"
10
 
11
  export function PublicMusicVideosView() {
12
  const [_isPending, startTransition] = useTransition()
13
  const setView = useStore(s => s.setView)
14
- const setPublicVideos = useStore(s => s.setPublicVideos)
15
- const setPublicVideo = useStore(s => s.setPublicVideo)
16
- const publicVideos = useStore(s => s.publicVideos)
17
 
18
  useEffect(() => {
 
 
19
  startTransition(async () => {
20
- const videos = await getVideos({
21
  sortBy: "date",
22
- mandatoryTags:["music"],
23
  maxVideos: 25
24
  })
25
 
26
- setPublicVideos(videos)
27
  })
 
28
  }, [])
29
 
30
- const handleSelect = (video: VideoInfo) => {
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
- <VideoList
42
- videos={publicVideos}
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
- import { VideoInfo } from "@/types"
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} - AI Tube`,
28
  metadataBase,
29
  openGraph: {
30
- type: "website",
 
 
31
  // url: "https://example.com",
32
  title: video.label || "", // put the video title here
33
- description: video.description || "", // put the vide description here
34
- siteName: "AI Tube",
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: "AI Tube - 404 Video Not Found",
49
  metadataBase,
50
  openGraph: {
51
  type: "website",
52
  // url: "https://example.com",
53
- title: "AI Tube - 404 Not Found", // put the video title here
54
  description: "", // put the vide description here
55
- siteName: "AI Tube",
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 video = await getVideo({ videoId, neverThrow: true })
67
  // console.log("WatchPage: --> " + video?.id)
68
  return (
69
- <Main video={video} />
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-950",
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