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

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