This commit is contained in:
truewhile
2026-09-24 22:27:09 +08:00
parent e273b5e87d
commit 065652d758
14 changed files with 161 additions and 199 deletions
-2
View File
@@ -71,7 +71,6 @@ export interface DanmakuConfig {
font_size: string
area: string
volume: number
playback_rate: number
/** 当前用户是否已经看过 VR 全景播放的首次操作说明(按用户存储)。 */
vr360_guide_seen: boolean
/** Per-user preference: merge the same episode's multiple sources. */
@@ -90,7 +89,6 @@ export interface DanmakuSettingsPatch {
area?: number
merge_sources?: boolean
volume?: number
playback_rate?: number
/** 看过 VR 操作说明后置为 true,之后不再弹出。 */
vr360_guide_seen?: boolean
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function ExternalPlayerButton({
className={
compact
? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap'
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
: 'btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
}
>
<PlaySquare size={compact ? 13 : 14} className="mr-1 inline" />
+4 -24
View File
@@ -133,10 +133,9 @@ type PlayerControlsProps = {
volume?: number
onVolumeChange?: (volume: number) => void
onVolumeCommit?: (volume: number) => void
/** 用户级播放倍速,由播放页从数据库读取。 */
/** 播放倍速(1x = 原速)。只作用于当前视频,不落库。 */
playbackRate?: number
onPlaybackRateChange?: (rate: number) => void
onPlaybackRateCommit?: (rate: number) => void
uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void
/** 外部浮层(VR 工具条/设置面板)正被鼠标悬停:此时控制栏不自动隐藏。 */
@@ -213,7 +212,6 @@ export function PlayerControls({
onVolumeCommit,
playbackRate: playbackRateProp = 1,
onPlaybackRateChange,
onPlaybackRateCommit,
uiVisible,
onUiVisibleChange,
uiHold = false,
@@ -381,7 +379,7 @@ export function PlayerControls({
setMuted(next === 0)
}, [video, volumeProp])
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。
// 倍速只影响当前视频:挂载、切换剧集或切换播放源后同步到当前 video。
useEffect(() => {
const el = video()
if (!el) return
@@ -626,15 +624,9 @@ export function PlayerControls({
}, PLAYBACK_RATE_HINT_MS)
}, [onPlaybackRateChange])
const commitPlaybackRate = useCallback(() => {
onPlaybackRateCommit?.(playbackRateRef.current)
}, [onPlaybackRateCommit])
const selectPlaybackRate = useCallback((next: number) => {
const normalized = normalizePlaybackRate(next)
changePlaybackRate(normalized)
onPlaybackRateCommit?.(normalized)
}, [changePlaybackRate, onPlaybackRateCommit])
changePlaybackRate(normalizePlaybackRate(next))
}, [changePlaybackRate])
const applyAbsoluteSeek = useCallback(
(absolute: number) => {
@@ -787,18 +779,6 @@ export function PlayerControls({
return () => window.removeEventListener('keydown', onKey, true)
}, [changePlaybackRate, queueRelativeSeek])
useEffect(() => {
const onKeyUp = (e: KeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
if (shouldIgnorePlayerSeekShortcut(e.target)) return
e.preventDefault()
commitPlaybackRate()
}
window.addEventListener('keyup', onKeyUp, true)
return () => window.removeEventListener('keyup', onKeyUp, true)
}, [commitPlaybackRate])
useEffect(() => {
return () => {
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
+30 -24
View File
@@ -1,4 +1,4 @@
import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } from 'lucide-react'
import { Database, FileText, FolderInput, Pencil, Search, ShieldAlert, Sparkles, Trash2 } from 'lucide-react'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import type { Media } from '../types'
@@ -34,50 +34,56 @@ export function MediaDetailAdminPanel({
}
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
{isEpisodeArtworkTarget(media) && (
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
onChange={onScrapeEpisodeArtworkChange}
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
className="h-10"
/>
)}
<div className="flex flex-wrap gap-2">
<button onClick={onSmartScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/50">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200/60 px-4 py-3 sm:px-5">
<p className="inline-flex items-center gap-2 text-2xs font-bold uppercase tracking-[0.18em] text-gray-500">
<ShieldAlert size={14} className="text-brand-500" />
系统后台高级控制面板
</p>
{isEpisodeArtworkTarget(media) && (
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
onChange={onScrapeEpisodeArtworkChange}
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
className="h-9 text-xs"
/>
)}
</div>
<div className="flex flex-wrap items-center gap-2 px-4 py-4 sm:px-5">
<button onClick={onSmartScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span>
</button>
<button onClick={onManualScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<button onClick={onManualScrape} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span>
</button>
<button onClick={onMetadataEdit} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Pencil size={13} className="text-gray-600" />
<button onClick={onMetadataEdit} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Pencil size={13} className="text-brand-500" />
<span>编辑元数据</span>
</button>
<button onClick={onOrganize} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<button onClick={onOrganize} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FolderInput size={13} className="text-[#c9954a]" />
<span>整理入库</span>
</button>
<button onClick={onProbe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" />
<button onClick={onProbe} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-brand-500" />
<span>探测媒体轨 (ffprobe)</span>
</button>
<button onClick={onExportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FileText size={13} />
<button onClick={onExportNFO} className="btn-outline h-9 rounded-lg px-3 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FileText size={13} className="text-brand-500" />
<span>写出本地 NFO 属性</span>
</button>
<button
onClick={onDelete}
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
className="btn-outline ml-auto h-9 rounded-lg px-3 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
>
<Trash2 size={13} />
<Trash2 size={13} className="text-red-500" />
<span>删除</span>
</button>
</div>
</div>
</section>
)
}
+47 -23
View File
@@ -9,6 +9,10 @@ type MediaDetailMetadataProps = {
export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
const heading = media.episode_title?.trim() || media.title
const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading)
const genres = parseCSV(media.genres)
const languages = parseCSV(media.languages)
const countries = parseCSV(media.countries)
const hasTaxonomy = genres.length > 0 || languages.length > 0 || countries.length > 0
return (
<>
@@ -21,26 +25,26 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
{media.title}
</p>
)}
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
<div className="flex flex-wrap items-center gap-2">
{media.year > 0 && (
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide gap-1.5 text-gray-700">
<Calendar size={13} className="text-brand-500" />
<span>{media.year} 年</span>
</span>
)}
{media.width > 0 && (
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
{media.width > 0 && media.height > 0 && (
<span className="inline-flex h-7 items-center rounded-lg border border-brand-100/50 bg-brand-50 px-2.5 text-2xs font-bold uppercase tracking-wide text-brand-700">
<span>{media.width} × {media.height}</span>
</span>
)}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
{fmtSize(media.size_bytes)}
</span>
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
{fmtDuration(media.duration_sec)}
</span>
{media.size_bytes > 0 && (
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtSize(media.size_bytes)}</span>
)}
{media.duration_sec > 0 && (
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700">{fmtDuration(media.duration_sec)}</span>
)}
{media.container && (
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
<span className="inline-flex h-7 items-center rounded-lg border border-gray-200/60 bg-gray-100 px-2.5 text-2xs font-bold uppercase tracking-wide text-gray-700 font-mono">
{media.container}
</span>
)}
@@ -56,24 +60,44 @@ export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
</div>
)}
<div className="space-y-4">
<MetadataTags label="类型流派" values={parseCSV(media.genres)} primary />
<MetadataTags label="语言" values={parseCSV(media.languages)} />
<MetadataTags label="国家/地区" values={parseCSV(media.countries)} />
</div>
{hasTaxonomy && (
<div className="space-y-3">
<MetadataRow label="类型流派" values={parseCSV(media.genres)} tone="brand" />
<MetadataRow label="语言" values={parseCSV(media.languages)} />
<MetadataRow label="国家/地区" values={parseCSV(media.countries)} />
</div>
)}
</>
)
}
function MetadataTags({ label, values, primary = false }: { label: string; values: string[]; primary?: boolean }) {
/**
* One "label - values" row of the taxonomy block.
*
* All rows share the same label gutter, so every value column starts on the
* same vertical line. Pills keep one geometry and differ by tone only.
*/
function MetadataRow({
label,
values,
tone = 'neutral',
}: {
label: string
values: string[]
tone?: 'neutral' | 'brand'
}) {
if (values.length === 0) return null
const tagClass = primary
? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider'
: 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold'
const tagClass = [
'inline-flex items-center rounded-lg border px-2.5 py-1 text-2xs font-bold uppercase tracking-wide',
tone === 'brand'
? 'border-brand-100/50 bg-brand-50 text-brand-700'
: 'border-gray-200/60 bg-gray-100 text-gray-600',
].join(' ')
return (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">{label}</span>
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-[4.5rem_1fr] items-start gap-2 sm:grid-cols-[5.5rem_1fr]">
<span className="pt-1 text-xs font-semibold text-gray-500">{label}</span>
<div className="flex flex-wrap gap-1.5">
{values.map((value) => (
<span key={value} className={tagClass}>
{value}
+29 -25
View File
@@ -99,19 +99,23 @@ export function MediaDetailPlaybackActions({
}: MediaDetailPlaybackActionsProps) {
const targetId = media.id || playTargetId || ''
return (
<div className="flex flex-wrap gap-3">
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
<div className="flex flex-wrap items-center gap-2.5">
<Link
to={`/play/${targetId}`}
state={{ from: `/media/${media.id}` }}
className="btn-primary h-11 px-6 shadow-sm"
>
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
{!isDirectStreamMedia(media) && (
<Link
to={`/play/${targetId}?mode=hls`}
state={{ from: `/media/${media.id}` }}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
to={`/play/${targetId}?mode=hls`}
state={{ from: `/media/${media.id}` }}
className="btn-outline h-11 border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50"
>
<RefreshCw size={14} />
<span>HLS 兼容转码播放</span>
</Link>
)}
@@ -121,7 +125,7 @@ export function MediaDetailPlaybackActions({
<button
onClick={onToggleFavourite}
className={
'btn-outline gap-2 ' +
'btn-outline h-11 gap-2 ' +
(favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
@@ -154,10 +158,10 @@ export function MediaDetailMainContent({
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
<MediaDetailPoster media={media} />
<div className="flex-1 space-y-6">
<div className="min-w-0 flex-1 space-y-5">
<MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" />
<div className="flex flex-col gap-5">
<div className="divider" />
<div className="space-y-4">
<MediaDetailPlaybackActions
media={media}
favourite={favourite}
@@ -165,21 +169,21 @@ export function MediaDetailMainContent({
playTargetId={playTargetId}
/>
<MediaVersionSwitcher media={media} />
{isAdmin && (
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
onSmartScrape={onSmartScrape}
onManualScrape={onManualScrape}
onMetadataEdit={onMetadataEdit}
onOrganize={onOrganize}
onProbe={onProbe}
onExportNFO={onExportNFO}
onDelete={onDelete}
/>
)}
</div>
{isAdmin && (
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
onSmartScrape={onSmartScrape}
onManualScrape={onManualScrape}
onMetadataEdit={onMetadataEdit}
onOrganize={onOrganize}
onProbe={onProbe}
onExportNFO={onExportNFO}
onDelete={onDelete}
/>
)}
</div>
</div>
)
+10 -39
View File
@@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
import { normalizePlaybackRate } from '../utils/playbackRate'
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import {
@@ -139,16 +139,13 @@ export function PlayerPage() {
const [playerVolume, setPlayerVolume] = useState(() =>
normalizePlayerVolume(authUser?.player_volume),
)
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
normalizePlaybackRate(authUser?.player_playback_rate),
)
// 播放倍速只作用于当前视频:不入库、不跨视频沿用(换集/换片会重置回 1x)。
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() => DEFAULT_PLAYBACK_RATE)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
// VR 全景播放的首次操作说明是否已经看过:null = 服务端配置还没读回来,
// 这时不弹说明,避免给老用户闪一下。
const [vr360GuideSeen, setVr360GuideSeen] = useState<boolean | null>(null)
const playerVolumeTouchedRef = useRef(false)
const playerPlaybackRateTouchedRef = useRef(false)
const playerPlaybackRateSaveSeqRef = useRef(0)
const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
const [hlsUnavailable, setHlsUnavailable] = useState(false)
@@ -285,10 +282,6 @@ export function PlayerPage() {
if (!playerVolumeTouchedRef.current) {
setPlayerVolume(volume)
}
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
if (!playerPlaybackRateTouchedRef.current) {
setPlayerPlaybackRate(playbackRate)
}
setDanmakuEnabled(cfg.enabled)
setDanmakuOpacity(Number(cfg.opacity) || 1)
setDanmakuFontSize(Number(cfg.font_size) || 24)
@@ -300,10 +293,6 @@ export function PlayerPage() {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_volume: volume })
}
if (!playerPlaybackRateTouchedRef.current) {
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
}
})
.catch(() => {
// 配置读取失败时保持本地默认值,不影响播放。
@@ -338,34 +327,11 @@ export function PlayerPage() {
[setAuthUser],
)
// 倍速只改本机播放状态:不写回服务端,也不跨视频沿用。
const changePlayerPlaybackRate = useCallback((next: number) => {
playerPlaybackRateTouchedRef.current = true
setPlayerPlaybackRate(normalizePlaybackRate(next))
}, [])
const commitPlayerPlaybackRate = useCallback(
(next: number) => {
playerPlaybackRateTouchedRef.current = true
const playbackRate = normalizePlaybackRate(next)
setPlayerPlaybackRate(playbackRate)
const saveSeq = ++playerPlaybackRateSaveSeqRef.current
void danmakuAPI
.updateSettings({ playback_rate: playbackRate })
.then((cfg) => {
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
const saved = normalizePlaybackRate(cfg.playback_rate)
setPlayerPlaybackRate(saved)
const current = useAuthStore.getState().user
if (current) setAuthUser({ ...current, player_playback_rate: saved })
})
.catch(() => {
if (saveSeq !== playerPlaybackRateSaveSeqRef.current) return
toast.error('倍速保存失败,请重试')
})
},
[setAuthUser],
)
const saveDanmakuAdvanced = useCallback(
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
const patch: DanmakuSettingsPatch = {
@@ -674,6 +640,12 @@ export function PlayerPage() {
const mediaId = media?.id
const playbackProvider = playbackInfo?.provider
// 倍速只影响当前视频:换到另一集/另一部片时回到 1x,避免上一个视频的
// 倍速被顺手带到下一集。
useEffect(() => {
setPlayerPlaybackRate(DEFAULT_PLAYBACK_RATE)
}, [mediaId])
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => {
if (!mediaId) return
@@ -1907,7 +1879,6 @@ export function PlayerPage() {
onPlayerVolumeCommit={commitPlayerVolume}
playerPlaybackRate={playerPlaybackRate}
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize}
-3
View File
@@ -184,7 +184,6 @@ type PlayerVideoStageProps = {
onPlayerVolumeCommit: (volume: number) => void
playerPlaybackRate: number
onPlayerPlaybackRateChange: (rate: number) => void
onPlayerPlaybackRateCommit: (rate: number) => void
danmakuEnabled: boolean
danmakuOpacity: number
danmakuFontSize: number
@@ -268,7 +267,6 @@ export function PlayerVideoStage({
onPlayerVolumeCommit,
playerPlaybackRate,
onPlayerPlaybackRateChange,
onPlayerPlaybackRateCommit,
danmakuEnabled,
danmakuOpacity,
danmakuFontSize,
@@ -892,7 +890,6 @@ export function PlayerVideoStage({
onVolumeCommit={onPlayerVolumeCommit}
playbackRate={playerPlaybackRate}
onPlaybackRateChange={onPlayerPlaybackRateChange}
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered}
-2
View File
@@ -12,8 +12,6 @@ export interface User {
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
player_volume?: number
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
player_playback_rate?: number
force_password_reset: boolean
is_active: boolean
allowed_library_ids?: string[]
+3
View File
@@ -3,6 +3,9 @@ export const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]
export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0]
export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1]
/** 默认倍速(1x)。倍速不落库,每个视频都从这个值开始。 */
export const DEFAULT_PLAYBACK_RATE = 1
export function normalizePlaybackRate(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1