player components
This commit is contained in:
@@ -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')}`
|
||||
}
|
||||
Reference in New Issue
Block a user