Split shorts from regular videos with a vertical shorts feed
- Detect shorts by duration (SHORTS_MAX_DURATION_SECONDS, default 180)
and expose is_short in feed, video and channel-activity DTOs.
- Feed gains type=all|long|short and anchor; lists show a two-mode
'Обычные | Shorts' filter (no 'Все') persisted in the URL.
- Clicking a short opens /shorts/🆔 a vertical scroll-snap feed with
autoplay for the active slide, context-aware endpoints and infinite
loading.
- Keep the sound choice across swipes, syncing with the player's own
mute control and guarding against the widget's stale isMuted() reads.
This commit is contained in:
parent
43adec5224
commit
252f38597d
25 changed files with 1297 additions and 88 deletions
40
frontend/src/utils/videoLinks.ts
Normal file
40
frontend/src/utils/videoLinks.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { FeedVideoDto } from '../api/client'
|
||||
|
||||
interface LocationLike {
|
||||
pathname: string
|
||||
search: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a video card links to: Shorts open the vertical feed at
|
||||
* `/shorts/:id` (carrying the current list context so the feed keeps the
|
||||
* same filter), everything else keeps the existing `/video/:id` page.
|
||||
*/
|
||||
export function videoHref(video: FeedVideoDto, location: LocationLike): string {
|
||||
if (!video.is_short) return `/video/${video.youtube_video_id}`
|
||||
|
||||
const params = new URLSearchParams()
|
||||
const { pathname, search } = location
|
||||
const searchParams = new URLSearchParams(search)
|
||||
|
||||
if (pathname.startsWith('/category/')) {
|
||||
const categoryId = pathname.split('/')[2]
|
||||
if (categoryId) params.set('category', categoryId)
|
||||
} else if (pathname === '/uncategorized') {
|
||||
params.set('uncategorized', 'true')
|
||||
} else if (pathname === '/local') {
|
||||
params.set('downloaded', 'true')
|
||||
const categoryId = searchParams.get('category')
|
||||
if (categoryId) params.set('category', categoryId)
|
||||
if (searchParams.get('uncategorized') === 'true') params.set('uncategorized', 'true')
|
||||
} else if (pathname === '/search') {
|
||||
const query = searchParams.get('q')
|
||||
if (query) params.set('q', query)
|
||||
} else if (pathname.startsWith('/channels/') && pathname.endsWith('/videos')) {
|
||||
const channelId = pathname.split('/')[2]
|
||||
if (channelId) params.set('channel', channelId)
|
||||
}
|
||||
|
||||
const query = params.toString()
|
||||
return `/shorts/${video.youtube_video_id}${query ? `?${query}` : ''}`
|
||||
}
|
||||
52
frontend/src/utils/youtube.ts
Normal file
52
frontend/src/utils/youtube.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export interface YoutubePlayerApi {
|
||||
seekTo(seconds: number, allowSeekAhead: boolean): void
|
||||
getCurrentTime(): number
|
||||
getDuration(): number
|
||||
mute?(): void
|
||||
unMute?(): void
|
||||
isMuted?(): boolean
|
||||
getVolume?(): number
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
export interface YoutubePlayerCtorOptions {
|
||||
videoId: string
|
||||
host?: string
|
||||
playerVars?: Record<string, string | number>
|
||||
events?: {
|
||||
onReady?: (event: { target: YoutubePlayerApi }) => void
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
YT?: {
|
||||
Player: new (element: HTMLElement, options: YoutubePlayerCtorOptions) => YoutubePlayerApi
|
||||
}
|
||||
onYouTubeIframeAPIReady?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
let youTubeApiLoadPromise: Promise<void> | null = null
|
||||
|
||||
export function loadYouTubeIframeApi(): Promise<void> {
|
||||
if (window.YT?.Player) return Promise.resolve()
|
||||
if (youTubeApiLoadPromise) return youTubeApiLoadPromise
|
||||
youTubeApiLoadPromise = new Promise<void>((resolve, reject) => {
|
||||
const previous = window.onYouTubeIframeAPIReady
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
previous?.()
|
||||
resolve()
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://www.youtube.com/iframe_api'
|
||||
script.async = true
|
||||
script.onerror = () => reject(new Error('YouTube IFrame API script failed to load'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
youTubeApiLoadPromise = youTubeApiLoadPromise.catch((error: unknown) => {
|
||||
youTubeApiLoadPromise = null
|
||||
throw error
|
||||
})
|
||||
return youTubeApiLoadPromise
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue