275 lines
8.2 KiB
TypeScript
275 lines
8.2 KiB
TypeScript
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')}`
|
|
}
|