player components

This commit is contained in:
2026-03-29 06:40:45 +03:00
parent 02d65fbeeb
commit 9a4f5a4bf5
25 changed files with 4795 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
import { motion } from 'framer-motion'
import type { ButtonHTMLAttributes } from 'react'
import {
ArrowLeft,
AudioLines,
Bookmark,
Download,
Info,
List,
ListDetails,
Moon,
PictureInPicture2,
Search,
Subtitles,
Users,
} from '../../lib/icons'
import SpeedMenu from './SpeedMenu'
import QualityMenu, { type QualityOption } from './QualityMenu'
import TrackMenu from './TrackMenu'
import SubtitleStyleMenu from './SubtitleStyleMenu'
import PictureMenu from './PictureMenu'
import type { BaseItemDto } from '../../api/types'
interface TrackInfo {
Index?: number
Codec?: string | null
Profile?: string | null
Language?: string | null
Title?: string | null
Channels?: number | null
ChannelLayout?: string | null
IsDefault?: boolean | null
IsForced?: boolean | null
IsHearingImpaired?: boolean | null
IsExternal?: boolean | null
AudioSpatialFormat?: string | null
Type?: string
}
interface Props {
item: BaseItemDto | null | undefined
playbackRate: number
onPlaybackRateChange: (rate: number) => void
qualityKey: string
onQualitySelect: (q: QualityOption) => void
onPictureInPicture: () => void
audioTracks: TrackInfo[]
audioIndex: number | null
onAudioSelect: (i: number | null) => void
subtitleTracks: TrackInfo[]
subtitleIndex: number | null
onSubtitleSelect: (i: number | null) => void
hasSeries: boolean
episodesOpen: boolean
onToggleEpisodes: () => void
hasChapters: boolean
chaptersOpen: boolean
onToggleChapters: () => void
bookmarksOpen: boolean
onToggleBookmarks: () => void
subSearchOpen: boolean
onToggleSubSearch: () => void
syncPlayOpen: boolean
onToggleSyncPlay: () => void
syncPlayActive: boolean
videoBrightness: number
videoContrast: number
videoSaturation: number
onPictureChange: (key: 'brightness' | 'contrast' | 'saturation', value: number) => void
onPictureReset: () => void
streamInfoOpen: boolean
onToggleStreamInfo: () => void
onBack: () => void
sleepRemainingSec?: number
onDownload?: () => void
isDownloaded?: boolean
}
export default function PlayerTopBar({
item,
playbackRate,
onPlaybackRateChange,
qualityKey,
onQualitySelect,
onPictureInPicture,
audioTracks,
audioIndex,
onAudioSelect,
subtitleTracks,
subtitleIndex,
onSubtitleSelect,
hasSeries,
episodesOpen,
onToggleEpisodes,
hasChapters,
chaptersOpen,
onToggleChapters,
bookmarksOpen,
onToggleBookmarks,
subSearchOpen,
onToggleSubSearch,
syncPlayOpen,
onToggleSyncPlay,
syncPlayActive,
videoBrightness,
videoContrast,
videoSaturation,
onPictureChange,
onPictureReset,
streamInfoOpen,
onToggleStreamInfo,
onBack,
sleepRemainingSec,
onDownload,
isDownloaded,
}: Props) {
return (
<motion.div
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
data-player-top-bar
className="bg-gradient-to-b from-black/75 via-black/30 to-transparent pt-5 pb-12 px-7 flex items-start justify-between pointer-events-auto"
>
<button
onClick={onBack}
className="group flex items-center gap-3 text-white/85 hover:text-white transition-colors focus-ring rounded-md p-1 -m-1"
>
<span className="w-9 h-9 rounded-full bg-black/40 backdrop-blur grid place-items-center group-hover:bg-black/60 transition-colors">
<ArrowLeft size={17} strokeWidth={2.25} />
</span>
<span className="hidden sm:flex flex-col items-start leading-tight">
<span className="text-[10px] uppercase tracking-[0.14em] text-white/55 font-semibold">
Now playing
</span>
<span className="text-[14px] font-medium tracking-tight">{item?.Name || 'Untitled'}</span>
</span>
</button>
<div className="flex items-center gap-1.5">
<SpeedMenu speed={playbackRate} onChange={onPlaybackRateChange} />
<QualityMenu selectedKey={qualityKey} onSelect={onQualitySelect} />
{onDownload && (
<ChromeButton
onClick={onDownload}
aria-label={isDownloaded ? 'Downloaded' : 'Download'}
title={isDownloaded ? 'Downloaded' : 'Download'}
className={isDownloaded ? 'text-accent' : ''}
>
<Download size={16} />
</ChromeButton>
)}
{sleepRemainingSec != null && sleepRemainingSec > 0 && (
<span
className="hidden sm:inline-flex items-center gap-1 h-7 px-2 rounded-full bg-black/40 backdrop-blur text-[10.5px] text-white/70 font-mono tabular-nums"
title="Sleep timer active"
>
<Moon size={12} />
{formatSleep(sleepRemainingSec)}
</span>
)}
<ChromeButton onClick={onPictureInPicture} aria-label="Picture in picture">
<PictureInPicture2 size={17} />
</ChromeButton>
<TrackMenu
trigger={<AudioLines size={17} />}
title="Audio"
tracks={audioTracks}
selectedIndex={audioIndex ?? (audioTracks.find(t => t.IsDefault)?.Index ?? null)}
onSelect={onAudioSelect}
variant="audio"
/>
<TrackMenu
trigger={<Subtitles size={17} />}
title="Subtitles"
tracks={subtitleTracks}
selectedIndex={subtitleIndex}
onSelect={onSubtitleSelect}
variant="subtitle"
/>
<SubtitleStyleMenu />
{subtitleIndex != null && (
<ChromeButton
onClick={onToggleSubSearch}
aria-label="Search subtitles"
aria-pressed={subSearchOpen}
className={subSearchOpen ? 'bg-white/15 text-white' : ''}
>
<Search size={17} />
</ChromeButton>
)}
{hasSeries && (
<ChromeButton
onClick={onToggleEpisodes}
aria-label="Episodes"
aria-pressed={episodesOpen}
className={episodesOpen ? 'bg-white/15 text-white' : ''}
>
<ListDetails size={17} />
</ChromeButton>
)}
{hasChapters && (
<ChromeButton
onClick={onToggleChapters}
aria-label="Chapters"
aria-pressed={chaptersOpen}
className={chaptersOpen ? 'bg-white/15 text-white' : ''}
>
<List size={17} />
</ChromeButton>
)}
<ChromeButton
onClick={onToggleBookmarks}
aria-label="Bookmarks"
aria-pressed={bookmarksOpen}
className={bookmarksOpen ? 'bg-white/15 text-white' : ''}
>
<Bookmark size={17} />
</ChromeButton>
<ChromeButton
onClick={onToggleSyncPlay}
aria-label="Watch party"
aria-pressed={syncPlayOpen}
className={syncPlayOpen || syncPlayActive ? 'bg-white/15 text-white' : ''}
>
<span className="relative">
<Users size={17} />
{syncPlayActive && (
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
)}
</span>
</ChromeButton>
<PictureMenu
brightness={videoBrightness}
contrast={videoContrast}
saturation={videoSaturation}
onChange={onPictureChange}
onReset={onPictureReset}
/>
<ChromeButton
onClick={onToggleStreamInfo}
aria-label="Stream info"
aria-pressed={streamInfoOpen}
className={streamInfoOpen ? 'bg-white/15 text-white' : ''}
>
<Info size={17} />
</ChromeButton>
</div>
</motion.div>
)
}
function ChromeButton({
children,
className = '',
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={`w-9 h-9 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur grid place-items-center text-white/85 hover:text-white transition-colors focus-ring ${className}`}
>
{children}
</button>
)
}
function formatSleep(sec: number): string {
const m = Math.floor(sec / 60)
const s = sec % 60
if (m >= 60) return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`
return `${m}:${String(s).padStart(2, '0')}`
}