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
|
|
@ -97,10 +97,11 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
|
|||
.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
|
||||
.download-action-error { color: var(--error); font-size: 12px; }
|
||||
.load-more { display: flex; margin: 34px auto 0; }
|
||||
.view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
|
||||
.view-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; }
|
||||
.view-toggle a:hover { color: var(--text); background: var(--hover); }
|
||||
.view-toggle a.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.page-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.view-toggle, .type-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
|
||||
.view-toggle a, .type-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; }
|
||||
.view-toggle a:hover, .type-toggle a:hover { color: var(--text); background: var(--hover); }
|
||||
.view-toggle a.active, .type-toggle a.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; }
|
||||
.channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); }
|
||||
.channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; }
|
||||
|
|
@ -252,6 +253,36 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
|
|||
.app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; padding: 30px; color: var(--muted); text-align: center; }
|
||||
.app-loading h1 { font-size: 24px; }
|
||||
.app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
|
||||
.shorts-page { position: relative; background: #000; }
|
||||
.shorts-feed { position: relative; height: calc(100dvh - 66px); overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scroll-behavior: smooth; background: #000; outline: none; }
|
||||
.shorts-feed:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.shorts-feed.shorts-centered { display: grid; place-items: center; }
|
||||
.shorts-slide { position: relative; height: 100%; overflow: hidden; scroll-snap-align: start; scroll-snap-stop: always; background: #000; }
|
||||
.shorts-player, .shorts-poster { position: absolute; inset: 0; }
|
||||
.shorts-video { width: 100%; height: 100%; border: 0; object-fit: contain; background: #000; }
|
||||
.shorts-yt-host > div { width: 100%; height: 100%; }
|
||||
/* YT.Player replaces our imperatively-created child with an <iframe
|
||||
width=640 height=360>; size it back to the full slide. */
|
||||
.shorts-yt-host iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||
.shorts-poster img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.shorts-poster .video-thumbnail-placeholder { width: 100%; height: 100%; }
|
||||
.shorts-overlay { position: absolute; inset: auto 0 0 0; z-index: 2; display: flex; flex-direction: column; gap: 6px; max-width: min(760px, 82%); padding: 22px clamp(28px, 4vw, 54px) 28px; background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.72) 70%); pointer-events: none; }
|
||||
.shorts-overlay a { pointer-events: auto; }
|
||||
.shorts-channel { align-self: flex-start; color: #fff; font-size: 15px; font-weight: 700; text-decoration: none; text-shadow: 0 1px 6px rgba(0,0,0,.8); }
|
||||
.shorts-channel:hover { color: var(--accent); }
|
||||
.shorts-title { margin: 0; color: #fff; font-size: clamp(16px, 1.6vw, 21px); line-height: 1.35; text-shadow: 0 1px 8px rgba(0,0,0,.8); }
|
||||
.shorts-meta { display: flex; align-items: center; gap: 14px; color: rgba(255,255,255,.75); font-size: 12px; }
|
||||
.shorts-external { color: rgba(255,255,255,.85); text-decoration: none; font-weight: 600; }
|
||||
.shorts-external:hover { color: var(--accent); text-decoration: underline; }
|
||||
.shorts-mute { position: absolute; z-index: 3; right: clamp(16px, 3vw, 34px); bottom: 26px; display: grid; place-items: center; width: 46px; height: 46px; border: 1px solid rgba(255,255,255,.28); border-radius: 50%; background: rgba(0,0,0,.5); color: #fff; }
|
||||
.shorts-mute:hover { background: rgba(0,0,0,.75); }
|
||||
.shorts-mute:disabled { opacity: .45; cursor: default; }
|
||||
.shorts-back { position: absolute; z-index: 5; top: 14px; left: 14px; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 14px 0 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: 13px; font-weight: 600; }
|
||||
.shorts-back:hover { background: rgba(0,0,0,.8); }
|
||||
.shorts-sentinel { height: 1px; scroll-snap-align: none; }
|
||||
.shorts-loading { position: absolute; z-index: 4; left: 50%; bottom: 22px; transform: translateX(-50%); padding: 8px 16px; border-radius: 999px; background: rgba(0,0,0,.66); color: #fff; font-size: 13px; }
|
||||
.shorts-skeleton .skeleton-thumbnail { border-radius: 0; position: absolute; inset: 0; }
|
||||
.shorts-skeleton .shorts-overlay { z-index: 1; }
|
||||
@media (min-width: 1800px) { .video-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
|
||||
@media (min-width: 1400px) and (max-width: 1799px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
@media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
|
|
@ -286,7 +317,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
|
|||
.page { padding: 24px 16px 58px; }
|
||||
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
|
||||
.page-heading h1 { font-size: 27px; }
|
||||
.view-toggle a { min-height: 38px; padding: 0 12px; }
|
||||
.view-toggle a, .type-toggle a { min-height: 38px; padding: 0 12px; }
|
||||
.mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
|
||||
.channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
|
||||
|
|
@ -317,15 +348,22 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
|
|||
.category-count { flex-basis: 100%; margin-left: 21px; }
|
||||
.category-edit-form input { flex-basis: 100%; }
|
||||
.sync-popover { right: -7px; max-width: calc(100vw - 30px); }
|
||||
.page-heading { flex-wrap: wrap; }
|
||||
.page-controls { justify-content: flex-start; }
|
||||
.shorts-feed { height: calc(100dvh - 112px); }
|
||||
.shorts-overlay { max-width: 100%; padding: 18px 16px 26px; }
|
||||
.shorts-back { top: 10px; left: 10px; }
|
||||
.shorts-mute { right: 16px; bottom: 20px; }
|
||||
}
|
||||
@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
|
||||
@media (pointer: coarse) {
|
||||
.video-actions .button-secondary, .download-badge, .video-page-actions .button-secondary, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; }
|
||||
.sidebar-badge { display: inline-flex; align-items: center; }
|
||||
.view-toggle a { min-height: 44px; }
|
||||
.view-toggle a, .type-toggle a { min-height: 44px; }
|
||||
.reorder-buttons .icon-button { width: 40px; height: 40px; }
|
||||
.popover-create input, .popover-create button { height: 44px; }
|
||||
.popover-create button { width: 44px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
|
||||
@media (prefers-reduced-motion: reduce) { .tap-seek-indicator { animation: none; opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .shorts-feed { scroll-snap-type: none; } }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Feed from './pages/Feed'
|
|||
import Channels from './pages/Channels'
|
||||
import ChannelVideos from './pages/ChannelVideos'
|
||||
import VideoPage from './pages/VideoPage'
|
||||
import ShortsFeed from './pages/ShortsFeed'
|
||||
import Categories from './pages/Categories'
|
||||
import Settings from './pages/Settings'
|
||||
import './App.css'
|
||||
|
|
@ -29,6 +30,7 @@ function App() {
|
|||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
|
||||
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
|
||||
<Route path="/shorts/:youtubeVideoId" element={<ShortsFeed />} />
|
||||
<Route path="/settings" element={<Settings email={data.email} />} />
|
||||
<Route path="/settings/categories" element={<Categories />} />
|
||||
<Route path="/categories" element={<Navigate to="/settings/categories" replace />} />
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ export function syncVideos() {
|
|||
return request<SyncStatus>('/api/sync/videos', { method: 'POST' })
|
||||
}
|
||||
|
||||
export type VideoType = 'all' | 'long' | 'short'
|
||||
|
||||
export interface FeedVideoDto {
|
||||
youtube_video_id: string
|
||||
title: string
|
||||
|
|
@ -183,6 +185,7 @@ export interface FeedVideoDto {
|
|||
thumbnail_url: string | null
|
||||
published_at: string
|
||||
duration_seconds: number | null
|
||||
is_short: boolean
|
||||
youtube_url: string
|
||||
categories: { id: number; name: string }[]
|
||||
local: {
|
||||
|
|
@ -233,6 +236,8 @@ export function getFeed(
|
|||
cursor?: string
|
||||
limit?: number
|
||||
newOnly?: boolean
|
||||
type?: VideoType
|
||||
anchor?: string
|
||||
} = {},
|
||||
) {
|
||||
const qs = new URLSearchParams()
|
||||
|
|
@ -242,6 +247,8 @@ export function getFeed(
|
|||
if (params.downloaded) qs.set('downloaded', 'true')
|
||||
if (params.search) qs.set('search', params.search)
|
||||
if (params.newOnly) qs.set('new_only', 'true')
|
||||
if (params.type) qs.set('type', params.type)
|
||||
if (params.anchor) qs.set('anchor', params.anchor)
|
||||
if (params.cursor) qs.set('cursor', params.cursor)
|
||||
if (params.limit != null) qs.set('limit', String(params.limit))
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
||||
|
|
|
|||
|
|
@ -64,12 +64,21 @@ function Sidebar({ close }: { close: () => void }) {
|
|||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||
const categories = categoriesQuery.data ?? []
|
||||
const location = useLocation()
|
||||
// Keep the current view mode ("Лента | Каналы") when switching between
|
||||
// "Все видео" and category links; badges (?new=1) are feed-specific.
|
||||
const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : ''
|
||||
// Keep the current view mode ("Лента | Каналы") and the video-type filter
|
||||
// ("Обычные | Shorts") when switching between "Все видео" and category
|
||||
// links; badges (?new=1) are feed-specific. Only `?type=short` is carried —
|
||||
// "Обычные" is the default and has no parameter.
|
||||
const currentParams = new URLSearchParams(location.search)
|
||||
const preservedParams = new URLSearchParams()
|
||||
if (currentParams.get('view') === 'channels') preservedParams.set('view', 'channels')
|
||||
const activeType = currentParams.get('type') === 'short' ? 'short' : null
|
||||
if (activeType) preservedParams.set('type', activeType)
|
||||
const viewQuery = preservedParams.toString() ? `?${preservedParams.toString()}` : ''
|
||||
const typeQuery = activeType ? '?type=short' : ''
|
||||
const typeSuffix = activeType ? '&type=short' : ''
|
||||
const links = [
|
||||
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
|
||||
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
|
||||
{ to: `/local${typeQuery}`, icon: 'server' as const, text: 'На сервере' },
|
||||
]
|
||||
return <nav className="sidebar-nav" aria-label="Основная навигация">
|
||||
<div className="sidebar-group">
|
||||
|
|
@ -79,7 +88,7 @@ function Sidebar({ close }: { close: () => void }) {
|
|||
<div className="sidebar-heading">Категории</div>
|
||||
{categories.map((category) => <div key={category.id} className="sidebar-category-row">
|
||||
<NavLink to={`/category/${category.id}${viewQuery}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
|
||||
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
|
||||
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1${typeSuffix}`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
|
||||
</div>)}
|
||||
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
|
||||
<Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { FeedVideoDto } from '../api/client'
|
||||
import { formatDuration, formatRelativeTime } from '../utils/format'
|
||||
import { videoHref } from '../utils/videoLinks'
|
||||
|
||||
interface Props {
|
||||
video: FeedVideoDto
|
||||
|
|
@ -8,8 +9,8 @@ interface Props {
|
|||
|
||||
function CompactVideoCard({ video }: Props) {
|
||||
const duration = formatDuration(video.duration_seconds)
|
||||
const href = `/video/${video.youtube_video_id}`
|
||||
const location = useLocation()
|
||||
const href = videoHref(video, location)
|
||||
const from = location.pathname + location.search
|
||||
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { SVGProps } from 'react'
|
||||
|
||||
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down'
|
||||
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down' | 'volume' | 'volume-off'
|
||||
|
||||
const paths: Record<IconName, React.ReactNode> = {
|
||||
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
|
||||
|
|
@ -25,6 +25,8 @@ const paths: Record<IconName, React.ReactNode> = {
|
|||
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6m4-6v6" /></>,
|
||||
up: <><path d="m6 15 6-6 6 6" /></>,
|
||||
down: <><path d="m6 9 6 6 6-6" /></>,
|
||||
volume: <><path d="M11 5 6 9H3v6h3l5 4V5Z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /></>,
|
||||
'volume-off': <><path d="M11 5 6 9H3v6h3l5 4V5Z" /><path d="m16 9 5 6M21 9l-5 6" /></>,
|
||||
}
|
||||
|
||||
export default function Icon({ name, size = 20, ...props }: SVGProps<SVGSVGElement> & { name: IconName; size?: number }) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { FeedVideoDto } from '../api/client'
|
||||
import { recheckLocal } from '../api/client'
|
||||
import type { YoutubePlayerApi } from '../utils/youtube'
|
||||
import { loadYouTubeIframeApi } from '../utils/youtube'
|
||||
|
||||
const DOUBLE_TAP_WINDOW_MS = 300
|
||||
const DOUBLE_TAP_MAX_DISTANCE_PX = 40
|
||||
|
|
@ -10,55 +12,6 @@ const SEEK_STEP_SECONDS = 10
|
|||
const INDICATOR_DURATION_MS = 750
|
||||
const YT_API_TIMEOUT_MS = 6000
|
||||
|
||||
interface YoutubePlayerApi {
|
||||
seekTo(seconds: number, allowSeekAhead: boolean): void
|
||||
getCurrentTime(): number
|
||||
getDuration(): number
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function clampSeekTarget(current: number, delta: number, duration: number): number {
|
||||
const target = current + delta
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
|
|
|
|||
205
frontend/src/components/ShortsPlayer.tsx
Normal file
205
frontend/src/components/ShortsPlayer.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { FeedVideoDto } from '../api/client'
|
||||
import type { YoutubePlayerApi } from '../utils/youtube'
|
||||
import { loadYouTubeIframeApi } from '../utils/youtube'
|
||||
import Icon from './Icon'
|
||||
|
||||
const YT_API_TIMEOUT_MS = 6000
|
||||
const YT_MUTE_POLL_MS = 1000
|
||||
// mute()/unMute() do not update the widget's cached state immediately (the
|
||||
// iframe is only polled by www-widgetapi every ~250ms), so sightings right after
|
||||
// a programmatic change can be stale. Ignore polls for this window.
|
||||
const YT_MUTE_SUPPRESS_MS = 600
|
||||
|
||||
interface Props {
|
||||
video: FeedVideoDto
|
||||
muted: boolean
|
||||
onToggleMute: () => void
|
||||
onMutedChange: (muted: boolean) => void
|
||||
}
|
||||
|
||||
// The player for a single active Shorts slide. It is mounted only for the
|
||||
// active slide, so unmounting is what stops playback (no manual pause()).
|
||||
// Sound is controlled by the parent (`muted` prop) and survives swipes.
|
||||
function ShortsPlayer({ video, muted, onToggleMute, onMutedChange }: Props) {
|
||||
const [localFailed, setLocalFailed] = useState(false)
|
||||
const [ytFailed, setYtFailed] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const ytRef = useRef<YoutubePlayerApi | null>(null)
|
||||
const ytReadyRef = useRef(false)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
// Read inside the player-creation effect without making `muted` a dependency
|
||||
// (that would recreate YT.Player and reload the video on every toggle).
|
||||
const mutedRef = useRef(muted)
|
||||
// Timestamp until which the poll must not trust isMuted().
|
||||
const suppressPollUntilRef = useRef(0)
|
||||
|
||||
const useLocal = video.local.available && !!video.local.media_url && !localFailed
|
||||
|
||||
useEffect(() => {
|
||||
if (useLocal) return
|
||||
let cancelled = false
|
||||
let timedOut = false
|
||||
let child: HTMLDivElement | null = null
|
||||
let timeoutId: number | null = null
|
||||
ytRef.current?.destroy()
|
||||
ytRef.current = null
|
||||
ytReadyRef.current = false
|
||||
|
||||
// Fall back to a plain iframe if the IFrame API does not come up in time.
|
||||
timeoutId = window.setTimeout(() => {
|
||||
timeoutId = null
|
||||
timedOut = true
|
||||
ytReadyRef.current = false
|
||||
ytRef.current?.destroy()
|
||||
ytRef.current = null
|
||||
if (child?.parentNode) child.remove()
|
||||
child = null
|
||||
if (!cancelled) setYtFailed(true)
|
||||
}, YT_API_TIMEOUT_MS)
|
||||
|
||||
loadYouTubeIframeApi()
|
||||
.then(() => {
|
||||
if (cancelled || timedOut || !window.YT?.Player) return
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
child = document.createElement('div')
|
||||
host.appendChild(child)
|
||||
const player = new window.YT.Player(child, {
|
||||
videoId: video.youtube_video_id,
|
||||
host: 'https://www.youtube-nocookie.com',
|
||||
// Start from the feed-level sound choice. On the first mount this is
|
||||
// muted, as browser autoplay policies block sound on programmatic
|
||||
// play; the visible button lets the user opt into sound.
|
||||
playerVars: { playsinline: 1, autoplay: 1, mute: mutedRef.current ? 1 : 0 },
|
||||
events: {
|
||||
onReady: () => {
|
||||
if (cancelled || timedOut) return
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
setYtFailed(false)
|
||||
ytReadyRef.current = true
|
||||
// Apply the current value, not the one captured at mount: the
|
||||
// user may have toggled sound before the player became ready.
|
||||
if (mutedRef.current) player.mute?.()
|
||||
else player.unMute?.()
|
||||
// isMuted() lags behind the call above; ignore polls briefly.
|
||||
suppressPollUntilRef.current = Date.now() + YT_MUTE_SUPPRESS_MS
|
||||
},
|
||||
},
|
||||
})
|
||||
ytRef.current = player
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || timedOut) return
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
setYtFailed(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
ytReadyRef.current = false
|
||||
if (timeoutId !== null) window.clearTimeout(timeoutId)
|
||||
ytRef.current?.destroy()
|
||||
ytRef.current = null
|
||||
if (child?.parentNode) child.remove()
|
||||
child = null
|
||||
}
|
||||
}, [useLocal, video.youtube_video_id])
|
||||
|
||||
// Keep the ref current (read by onReady) and apply sound changes to an
|
||||
// already-created YT player. Kept separate from the creation effect so
|
||||
// toggling never recreates the player. On mount it may run before the player
|
||||
// exists — initial state is covered by playerVars and onReady.
|
||||
useEffect(() => {
|
||||
mutedRef.current = muted
|
||||
const player = ytRef.current
|
||||
if (!player) return
|
||||
if (muted) player.mute?.()
|
||||
else player.unMute?.()
|
||||
// The player's own isMuted() is stale until the next infoDelivery; don't
|
||||
// let the poll read it and revert this change.
|
||||
suppressPollUntilRef.current = Date.now() + YT_MUTE_SUPPRESS_MS
|
||||
}, [muted])
|
||||
|
||||
// Poll the YouTube player's own mute state so sound changes made with the
|
||||
// native YouTube control (not our button) propagate to the feed-level state.
|
||||
// Skips until onReady (isMuted() before ready can throw), stops on unmount,
|
||||
// and ignores the stale window right after a programmatic change.
|
||||
useEffect(() => {
|
||||
if (useLocal) return
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (Date.now() < suppressPollUntilRef.current) return
|
||||
const player = ytRef.current
|
||||
if (!ytReadyRef.current || !player?.isMuted) return
|
||||
let observed: unknown
|
||||
try {
|
||||
observed = player.isMuted()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// isMuted() may be undefined until the first infoDelivery.
|
||||
if (typeof observed !== 'boolean') return
|
||||
if (observed === mutedRef.current) return
|
||||
// Record before notifying the parent so a re-render cannot re-report it.
|
||||
mutedRef.current = observed
|
||||
onMutedChange(observed)
|
||||
}, YT_MUTE_POLL_MS)
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [useLocal, onMutedChange])
|
||||
|
||||
// The plain-iframe fallback always autoplays muted with no JS control, so the
|
||||
// UI reports "muted" there regardless of the feed-level choice.
|
||||
const effectivelyMuted = !useLocal && ytFailed ? true : muted
|
||||
|
||||
return (
|
||||
<div className="shorts-player">
|
||||
{useLocal ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="shorts-video"
|
||||
autoPlay
|
||||
playsInline
|
||||
loop
|
||||
muted={muted}
|
||||
controls
|
||||
src={video.local.media_url!}
|
||||
onVolumeChange={(event) => {
|
||||
// Reflect native-control changes back into the feed-level state so
|
||||
// the next slide keeps the sound choice. "Muted" is only the muted
|
||||
// flag, matching YouTube; volume 0 is not treated as muted.
|
||||
const nextMuted = event.currentTarget.muted
|
||||
if (nextMuted !== muted) onMutedChange(nextMuted)
|
||||
}}
|
||||
onError={() => setLocalFailed(true)}
|
||||
/>
|
||||
) : ytFailed ? (
|
||||
<iframe
|
||||
className="shorts-video"
|
||||
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}?autoplay=1&playsinline=1&mute=1`}
|
||||
title={video.title}
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<div ref={hostRef} className="shorts-video shorts-yt-host" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="shorts-mute"
|
||||
onClick={onToggleMute}
|
||||
disabled={!useLocal && ytFailed}
|
||||
aria-label={effectivelyMuted ? 'Включить звук' : 'Выключить звук'}
|
||||
>
|
||||
<Icon name={effectivelyMuted ? 'volume-off' : 'volume'} size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShortsPlayer
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { FeedVideoDto } from '../api/client'
|
||||
import { formatDuration, formatRelativeTime } from '../utils/format'
|
||||
import { videoHref } from '../utils/videoLinks'
|
||||
import DownloadButton from './DownloadButton'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -10,8 +11,8 @@ interface Props {
|
|||
|
||||
function VideoCard({ video, showActions = false }: Props) {
|
||||
const duration = formatDuration(video.duration_seconds)
|
||||
const href = `/video/${video.youtube_video_id}`
|
||||
const location = useLocation()
|
||||
const href = videoHref(video, location)
|
||||
const from = location.pathname + location.search
|
||||
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,50 @@
|
|||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { getChannel, getFeed } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
import { formatSubscriberCount } from '../utils/format'
|
||||
|
||||
// Same two-mode filter as the feed: `long` is the default (no URL param),
|
||||
// `short` is `?type=short`; any other value normalizes to `long`.
|
||||
type TypeFilter = 'long' | 'short'
|
||||
|
||||
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
|
||||
{ value: 'long', label: 'Обычные' },
|
||||
{ value: 'short', label: 'Shorts' },
|
||||
]
|
||||
|
||||
function ChannelVideos() {
|
||||
const { channelId } = useParams<{ channelId: string }>()
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const id = Number(channelId)
|
||||
const valid = Number.isInteger(id) && id > 0
|
||||
const videoType: TypeFilter = searchParams.get('type') === 'short' ? 'short' : 'long'
|
||||
const typeLink = (value: TypeFilter) => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (value === 'short') params.set('type', 'short')
|
||||
else params.delete('type')
|
||||
const qs = params.toString()
|
||||
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
|
||||
}
|
||||
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
|
||||
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
|
||||
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id, videoType], queryFn: ({ pageParam }) => getFeed({ channelId: id, type: videoType, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
|
||||
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||
const subscriberLabel = channelQuery.data ? formatSubscriberCount(channelQuery.data.subscriber_count) : null
|
||||
const typeEmptyTitle = videoType === 'short' ? 'У канала пока нет Shorts' : 'У канала пока нет обычных видео'
|
||||
const typeEmptyText = videoType === 'short' ? 'Короткие ролики появятся здесь, когда попадут в ленту.' : 'Обычные ролики появятся здесь, когда попадут в ленту.'
|
||||
return <section className="page">
|
||||
<Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
|
||||
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div></div>
|
||||
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div>
|
||||
<div className="page-controls"><div className="type-toggle" role="group" aria-label="Тип видео">
|
||||
{TYPE_OPTIONS.map((option) => <Link key={option.value} to={typeLink(option.value)} className={videoType === option.value ? 'active' : ''} aria-current={videoType === option.value ? 'page' : undefined}>{option.label}</Link>)}
|
||||
</div></div>
|
||||
</div>
|
||||
{(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
|
||||
{feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>Пока нет видео</h2><p>Обнови ленту, чтобы получить новые ролики канала.</p></div>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>{typeEmptyTitle}</h2><p>{typeEmptyText}</p><Link className="button-primary" to={typeLink(videoType === 'short' ? 'long' : 'short')}>{videoType === 'short' ? 'Смотреть обычные' : 'Смотреть Shorts'}</Link></div>}
|
||||
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
||||
</section>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ import ChannelActivityList from '../components/ChannelActivityList'
|
|||
import Icon from '../components/Icon'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
|
||||
// The UI exposes exactly two mutually exclusive modes. `long` is the default
|
||||
// and has no URL parameter; `short` is `?type=short`. The REST endpoint still
|
||||
// accepts the unfiltered value for future mobile clients, but the UI never
|
||||
// sends it.
|
||||
type TypeFilter = 'long' | 'short'
|
||||
|
||||
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
|
||||
{ value: 'long', label: 'Обычные' },
|
||||
{ value: 'short', label: 'Shorts' },
|
||||
]
|
||||
|
||||
function VideoSkeleton() {
|
||||
return <li className="video-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
|
||||
}
|
||||
|
|
@ -20,8 +31,37 @@ function Feed() {
|
|||
const isUncategorized = location.pathname === '/uncategorized'
|
||||
const supportsViewToggle = !isLocal && location.pathname !== '/search'
|
||||
const isChannelsView = supportsViewToggle && searchParams.get('view') === 'channels'
|
||||
const viewQuery = isChannelsView ? 'view=channels' : ''
|
||||
const withView = (path: string) => viewQuery ? `${path}?${viewQuery}` : path
|
||||
// Any value other than `short` (missing, `all`, `long`, garbage) is
|
||||
// normalized to the default `long` mode.
|
||||
const videoType: TypeFilter = searchParams.get('type') === 'short' ? 'short' : 'long'
|
||||
// Query params carried across sidebar/mobile-category links: the view mode
|
||||
// and the video-type filter. Only `?type=short` is ever written; `long` is
|
||||
// the default and has no parameter. `?new=1` stays feed-specific.
|
||||
const preservedParams = new URLSearchParams()
|
||||
if (isChannelsView) preservedParams.set('view', 'channels')
|
||||
if (videoType === 'short') preservedParams.set('type', 'short')
|
||||
const preservedQuery = preservedParams.toString()
|
||||
const withPreserved = (path: string) => (preservedQuery ? `${path}${path.includes('?') ? '&' : '?'}${preservedQuery}` : path)
|
||||
// "На сервере" has no channels view, so the view mode must not leak into its
|
||||
// URL; keep only the type filter.
|
||||
const localNavPath = `/local${videoType === 'short' ? '?type=short' : ''}`
|
||||
const typeLink = (value: TypeFilter) => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (value === 'short') params.set('type', 'short')
|
||||
else params.delete('type')
|
||||
const qs = params.toString()
|
||||
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
|
||||
}
|
||||
const feedViewLink = () => {
|
||||
const params = new URLSearchParams()
|
||||
if (videoType === 'short') params.set('type', 'short')
|
||||
const qs = params.toString()
|
||||
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
|
||||
}
|
||||
const channelsViewSearch = new URLSearchParams({ view: 'channels' })
|
||||
// The filter is not applied in the channels view; carrying `?type=short`
|
||||
// only restores the user's choice when they switch back to the feed.
|
||||
if (videoType === 'short') channelsViewSearch.set('type', 'short')
|
||||
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
|
||||
const localCategoryRaw = isLocal ? searchParams.get('category') : null
|
||||
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
|
||||
|
|
@ -36,13 +76,14 @@ function Feed() {
|
|||
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
|
||||
})
|
||||
const feedQuery = useInfiniteQuery({
|
||||
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly],
|
||||
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly, videoType],
|
||||
queryFn: ({ pageParam }) => getFeed({
|
||||
categoryId: isLocal ? localCategory : categoryNumber,
|
||||
uncategorized: isUncategorized || localUncategorized,
|
||||
downloaded: isLocal,
|
||||
search,
|
||||
newOnly,
|
||||
type: videoType,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
|
|
@ -85,25 +126,46 @@ function Feed() {
|
|||
}
|
||||
}, [isChannelsView, feedQuery.isLoading, location.pathname, location.search])
|
||||
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
|
||||
// Empty-state content is derived from the active type filter together with
|
||||
// the context, so the heading and the hint never contradict each other.
|
||||
const typeEmptyTitle = videoType === 'short' ? 'Здесь пока нет Shorts' : 'Здесь пока нет обычных видео'
|
||||
const typeEmptyText = videoType === 'short' ? 'Короткие ролики появятся здесь, когда попадут в ленту.' : 'Обычные ролики появятся здесь, когда попадут в ленту.'
|
||||
// Offer switching to the other type when this context does have content,
|
||||
// just not of the selected type.
|
||||
const otherTypeLink = videoType === 'short' ? typeLink('long') : typeLink('short')
|
||||
const otherTypeLabel = videoType === 'short' ? 'Смотреть обычные' : 'Смотреть Shorts'
|
||||
// `/local`: tell "nothing saved at all" apart from "nothing of this type".
|
||||
const noSavedVideos = savedCountsQuery.data?.all === 0
|
||||
// `/uncategorized`: tell "all channels already categorized" apart from
|
||||
// "uncategorized channels exist but have no videos of this type".
|
||||
const noUncategorizedChannels = countQuery.data !== undefined && countQuery.data.length === 0
|
||||
// Root feed with zero subscriptions: onboarding takes priority over the
|
||||
// type-specific empty-state.
|
||||
const noSubscriptions = location.pathname === '/' && countQuery.data !== undefined && countQuery.data.length === 0
|
||||
|
||||
return <section className="page">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div>
|
||||
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
|
||||
<Link to={location.pathname} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
|
||||
<Link to={{ pathname: location.pathname, search: 'view=channels' }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
|
||||
</div>}
|
||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={withPreserved(`/category/${categoryNumber}`)}>Показать все</Link></>}</p></div>
|
||||
<div className="page-controls">
|
||||
{!isChannelsView && <div className="type-toggle" role="group" aria-label="Тип видео">
|
||||
{TYPE_OPTIONS.map((option) => <Link key={option.value} to={typeLink(option.value)} className={videoType === option.value ? 'active' : ''} aria-current={videoType === option.value ? 'page' : undefined}>{option.label}</Link>)}
|
||||
</div>}
|
||||
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
|
||||
<Link to={feedViewLink()} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
|
||||
<Link to={{ pathname: location.pathname, search: `?${channelsViewSearch.toString()}` }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mobile-category-nav" aria-label="Категории">
|
||||
<Link to={withView('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||
<Link to={withView('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||||
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withView(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
|
||||
<Link to={withPreserved('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||
<Link to={withPreserved('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||||
<Link to={localNavPath} className={isLocal ? 'active' : ''}>На сервере</Link>
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
|
||||
</nav>
|
||||
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
|
||||
<Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
|
||||
<Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/local?category=${item.id}`} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
|
||||
<Link to={withPreserved('/local')} className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
|
||||
<Link to={withPreserved('/local?uncategorized=true')} className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/local?category=${item.id}`)} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
|
||||
</nav>}
|
||||
{syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
|
||||
{location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>}
|
||||
|
|
@ -111,7 +173,7 @@ function Feed() {
|
|||
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
|
||||
{feedQuery.isError && <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить видео</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => feedQuery.refetch()}>Повторить</button></div>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} showActions={isLocal} />)}</ul>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? (noSavedVideos ? 'Пока нет сохранённых видео' : typeEmptyTitle) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы распределены' : typeEmptyTitle) : noSubscriptions ? 'Здесь пока нет видео' : typeEmptyTitle}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? (noSavedVideos ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : typeEmptyText) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы уже распределены по категориям.' : typeEmptyText) : noSubscriptions ? 'Добавь каналы в категорию или обнови ленту.' : typeEmptyText}</p>{!search && !syncRunning && (isLocal ? (!noSavedVideos && <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>) : noSubscriptions ? <Link className="button-primary" to="/channels">Перейти к каналам</Link> : isUncategorized && noUncategorizedChannels ? null : <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>)}</div>}
|
||||
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
||||
</>}
|
||||
</section>
|
||||
|
|
|
|||
194
frontend/src/pages/ShortsFeed.tsx
Normal file
194
frontend/src/pages/ShortsFeed.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { getFeed } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
import ShortsPlayer from '../components/ShortsPlayer'
|
||||
import { formatRelativeTime } from '../utils/format'
|
||||
|
||||
function ShortsSkeleton() {
|
||||
return <div className="shorts-slide shorts-skeleton" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="shorts-overlay"><div className="skeleton-line short" /><div className="skeleton-line wide" /></div></div>
|
||||
}
|
||||
|
||||
function ShortsFeed() {
|
||||
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const from = (location.state as { from?: string } | null)?.from
|
||||
|
||||
// The context the feed was opened from travels in the URL, so refresh and
|
||||
// sharing keep the same filter. See analytics doc, "Соглашение о контексте".
|
||||
const context = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const categoryRaw = params.get('category')
|
||||
const channelRaw = params.get('channel')
|
||||
return {
|
||||
categoryId: categoryRaw && /^\d+$/.test(categoryRaw) ? Number(categoryRaw) : undefined,
|
||||
channelId: channelRaw && /^\d+$/.test(channelRaw) ? Number(channelRaw) : undefined,
|
||||
uncategorized: params.get('uncategorized') === 'true',
|
||||
downloaded: params.get('downloaded') === 'true',
|
||||
search: params.get('q') ?? undefined,
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['feed', 'shorts', context, youtubeVideoId],
|
||||
queryFn: ({ pageParam }) => getFeed({
|
||||
type: 'short',
|
||||
...context,
|
||||
// `anchor` only opens the first page at the requested video; later
|
||||
// pages use the regular cursor.
|
||||
anchor: pageParam ? undefined : youtubeVideoId,
|
||||
cursor: pageParam,
|
||||
limit: 8,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||
})
|
||||
|
||||
const items = useMemo(() => query.data?.pages.flatMap((page) => page.items) ?? [], [query.data])
|
||||
const { fetchNextPage, hasNextPage, isFetchingNextPage } = query
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
// Sound state lives in the feed, not in the player: the player is mounted
|
||||
// only for the active slide and would otherwise reset to muted on every
|
||||
// swipe. Not persisted across reloads on purpose (autoplay policy).
|
||||
const [muted, setMuted] = useState(true)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const slideRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
const focusedRef = useRef(false)
|
||||
const lastFocusKeyRef = useRef('')
|
||||
|
||||
// Reset to the first (anchor) slide when the context/route changes. Doing
|
||||
// it during render (instead of in an effect) avoids a stale-frame flicker.
|
||||
const routeKey = `${youtubeVideoId ?? ''}|${location.search}`
|
||||
const [lastRouteKey, setLastRouteKey] = useState(routeKey)
|
||||
if (lastRouteKey !== routeKey) {
|
||||
setLastRouteKey(routeKey)
|
||||
setActiveIndex(0)
|
||||
}
|
||||
|
||||
// Focus the scroll container once, so ArrowUp/ArrowDown work without an
|
||||
// extra Tab. preventScroll keeps the anchor slide in place.
|
||||
useEffect(() => {
|
||||
if (lastFocusKeyRef.current !== routeKey) {
|
||||
lastFocusKeyRef.current = routeKey
|
||||
focusedRef.current = false
|
||||
}
|
||||
if (!focusedRef.current && containerRef.current && items.length > 0) {
|
||||
containerRef.current.focus({ preventScroll: true })
|
||||
focusedRef.current = true
|
||||
}
|
||||
}, [routeKey, items.length])
|
||||
|
||||
// A slide becomes active once it is at least 60% visible.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
||||
const index = Number((entry.target as HTMLElement).dataset.index)
|
||||
if (!Number.isNaN(index)) setActiveIndex(index)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0.6 },
|
||||
)
|
||||
slideRefs.current.forEach((slide) => { if (slide) observer.observe(slide) })
|
||||
return () => observer.disconnect()
|
||||
}, [routeKey, items.length])
|
||||
|
||||
// Fetch the next page when the sentinel at the end of the list is near.
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
const container = containerRef.current
|
||||
if (!sentinel || !container) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting) && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: '0px 0px 400px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [routeKey, items.length, hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
const goToSlide = useCallback((index: number) => {
|
||||
const slides = slideRefs.current
|
||||
if (slides.length === 0) return
|
||||
const target = slides[Math.max(0, Math.min(index, slides.length - 1))]
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
target?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' })
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'PageDown') {
|
||||
event.preventDefault()
|
||||
goToSlide(activeIndex + 1)
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'PageUp') {
|
||||
event.preventDefault()
|
||||
goToSlide(activeIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const back = () => { if (from && window.history.length > 1) navigate(-1); else navigate(from ?? '/') }
|
||||
|
||||
if (query.isError) {
|
||||
return <div className="shorts-page"><div className="shorts-feed shorts-centered"><div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить Shorts</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => query.refetch()}>Повторить</button></div></div></div>
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return <div className="shorts-page"><div className="shorts-feed">{Array.from({ length: 2 }, (_, index) => <ShortsSkeleton key={index} />)}</div></div>
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="shorts-page"><div className="shorts-feed shorts-centered"><div className="empty-state"><span className="empty-icon"><Icon name="youtube" size={28} /></span><h2>Здесь пока нет Shorts</h2><p>В этой подборке нет коротких роликов. Вернись к ленте и выбери другой фильтр.</p><Link className="button-primary" to="/">Ко всем видео</Link></div></div></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shorts-page">
|
||||
<button type="button" className="shorts-back" onClick={back}><Icon name="arrow" size={17} /> Назад</button>
|
||||
<div className="shorts-feed" ref={containerRef} tabIndex={0} onKeyDown={onKeyDown} aria-label="Лента Shorts">
|
||||
{items.map((video, index) => (
|
||||
<div
|
||||
key={video.youtube_video_id}
|
||||
data-index={index}
|
||||
ref={(element) => { slideRefs.current[index] = element }}
|
||||
className={`shorts-slide ${index === activeIndex ? 'is-active' : ''}`}
|
||||
role="group"
|
||||
aria-label={`${video.title} — ${video.channel.title}`}
|
||||
>
|
||||
{index === activeIndex ? (
|
||||
<ShortsPlayer video={video} muted={muted} onToggleMute={() => setMuted((value) => !value)} onMutedChange={setMuted} />
|
||||
) : (
|
||||
<div className="shorts-poster">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="video-thumbnail-placeholder" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="shorts-overlay" inert={index === activeIndex ? undefined : true}>
|
||||
<Link to={`/channels/${video.channel.id}/videos`} className="shorts-channel" title={video.channel.title}>{video.channel.title}</Link>
|
||||
<h2 className="shorts-title">{video.title}</h2>
|
||||
<div className="shorts-meta">
|
||||
<span>{formatRelativeTime(video.published_at)}</span>
|
||||
<a href={video.youtube_url} target="_blank" rel="noreferrer" className="shorts-external">Открыть на YouTube</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={sentinelRef} className="shorts-sentinel" aria-hidden="true" />
|
||||
</div>
|
||||
{hasNextPage && isFetchingNextPage && <div className="shorts-loading" role="status">Загрузка…</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShortsFeed
|
||||
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