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:
vrubelroman 2026-09-28 09:33:35 +00:00
parent 43adec5224
commit 252f38597d
25 changed files with 1297 additions and 88 deletions

View 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}` : ''}`
}

View 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
}