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

@ -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>