mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
优化,添加滚动记忆
This commit is contained in:
@@ -112,7 +112,8 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 200", w.Code)
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
var cfg struct {
|
var cfg struct {
|
||||||
MergeSources bool `json:"merge_sources"`
|
MergeSources bool `json:"merge_sources"`
|
||||||
|
PlaybackRate float64 `json:"playback_rate"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
@@ -120,6 +121,9 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
|||||||
if cfg.MergeSources {
|
if cfg.MergeSources {
|
||||||
t.Fatal("default merge preference should be false")
|
t.Fatal("default merge preference should be false")
|
||||||
}
|
}
|
||||||
|
if cfg.PlaybackRate != 1 {
|
||||||
|
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
|
||||||
|
}
|
||||||
|
|
||||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||||
t.Fatalf("set: %v", err)
|
t.Fatalf("set: %v", err)
|
||||||
@@ -137,7 +141,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
|
|||||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||||
svc := newDanmakuSettingsService(t)
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||||
updateDanmakuSettingsHandler(svc)(c)
|
updateDanmakuSettingsHandler(svc)(c)
|
||||||
|
|
||||||
@@ -154,7 +158,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
|||||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||||
t.Fatalf("unexpected render config: %+v", cfg)
|
t.Fatalf("unexpected render config: %+v", cfg)
|
||||||
}
|
}
|
||||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
|
||||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||||
}
|
}
|
||||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||||
@@ -165,11 +169,30 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
|||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
t.Fatalf("read persisted user: %v", err)
|
t.Fatalf("read persisted user: %v", err)
|
||||||
}
|
}
|
||||||
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
|
||||||
t.Fatalf("preferences not persisted: %+v", user)
|
t.Fatalf("preferences not persisted: %+v", user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
|
||||||
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||||
|
`{"playback_rate":4}`, "user-1")
|
||||||
|
updateDanmakuSettingsHandler(svc)(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||||
|
if err != nil || user == nil {
|
||||||
|
t.Fatalf("read user: %v", err)
|
||||||
|
}
|
||||||
|
if user.PlayerPlaybackRate != 1 {
|
||||||
|
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
||||||
svc := newDanmakuSettingsService(t)
|
svc := newDanmakuSettingsService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type User struct {
|
|||||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||||
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
|
||||||
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
|
||||||
|
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
|
||||||
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
|
||||||
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
|
||||||
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ const (
|
|||||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
||||||
|
|
||||||
|
const (
|
||||||
|
minPlayerPlaybackRate = 0.5
|
||||||
|
maxPlayerPlaybackRate = 3.0
|
||||||
|
defaultPlayerPlaybackRate = 1.0
|
||||||
|
)
|
||||||
|
|
||||||
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
// danmakuOfficialBase is where identification (/api/v2/match) and the
|
||||||
// comment/search fallback always go, regardless of the configured source.
|
// comment/search fallback always go, regardless of the configured source.
|
||||||
// A package var (not a const) so tests can point it at a local server.
|
// A package var (not a const) so tests can point it at a local server.
|
||||||
@@ -63,6 +69,8 @@ type DanmakuRenderConfig struct {
|
|||||||
FontSize string `json:"font_size"`
|
FontSize string `json:"font_size"`
|
||||||
Area string `json:"area"`
|
Area string `json:"area"`
|
||||||
Volume float64 `json:"volume"`
|
Volume float64 `json:"volume"`
|
||||||
|
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
|
||||||
|
PlaybackRate float64 `json:"playback_rate"`
|
||||||
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
|
||||||
MergeSources bool `json:"merge_sources"`
|
MergeSources bool `json:"merge_sources"`
|
||||||
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
|
||||||
@@ -89,6 +97,7 @@ type DanmakuSettingsPatch struct {
|
|||||||
Area *float64 `json:"area"`
|
Area *float64 `json:"area"`
|
||||||
MergeSources *bool `json:"merge_sources"`
|
MergeSources *bool `json:"merge_sources"`
|
||||||
Volume *float64 `json:"volume"`
|
Volume *float64 `json:"volume"`
|
||||||
|
PlaybackRate *float64 `json:"playback_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type danmakuUserContextKey struct{}
|
type danmakuUserContextKey struct{}
|
||||||
@@ -345,10 +354,11 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
|
|||||||
// Config reads danmaku settings from the runtime settings table.
|
// Config reads danmaku settings from the runtime settings table.
|
||||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||||
cfg := DanmakuRenderConfig{
|
cfg := DanmakuRenderConfig{
|
||||||
Opacity: "1",
|
Opacity: "1",
|
||||||
FontSize: "24",
|
FontSize: "24",
|
||||||
Area: "1",
|
Area: "1",
|
||||||
Volume: 1,
|
Volume: 1,
|
||||||
|
PlaybackRate: defaultPlayerPlaybackRate,
|
||||||
}
|
}
|
||||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||||
return cfg
|
return cfg
|
||||||
@@ -399,7 +409,14 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
|
|||||||
|
|
||||||
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
|
return DanmakuRenderConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Opacity: "1",
|
||||||
|
FontSize: "24",
|
||||||
|
Area: "1",
|
||||||
|
Volume: 1,
|
||||||
|
PlaybackRate: defaultPlayerPlaybackRate,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
opacity := user.DanmakuOpacity
|
opacity := user.DanmakuOpacity
|
||||||
if opacity < 0.1 || opacity > 1 {
|
if opacity < 0.1 || opacity > 1 {
|
||||||
@@ -417,6 +434,10 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
|||||||
if volume < 0 || volume > 1 {
|
if volume < 0 || volume > 1 {
|
||||||
volume = 1
|
volume = 1
|
||||||
}
|
}
|
||||||
|
playbackRate := user.PlayerPlaybackRate
|
||||||
|
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
|
||||||
|
playbackRate = defaultPlayerPlaybackRate
|
||||||
|
}
|
||||||
return DanmakuRenderConfig{
|
return DanmakuRenderConfig{
|
||||||
Enabled: user.DanmakuEnabled,
|
Enabled: user.DanmakuEnabled,
|
||||||
Source: strings.TrimSpace(user.DanmakuSource),
|
Source: strings.TrimSpace(user.DanmakuSource),
|
||||||
@@ -425,6 +446,7 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
|
|||||||
FontSize: strconv.Itoa(fontSize),
|
FontSize: strconv.Itoa(fontSize),
|
||||||
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
Area: strconv.FormatFloat(area, 'f', -1, 64),
|
||||||
Volume: volume,
|
Volume: volume,
|
||||||
|
PlaybackRate: playbackRate,
|
||||||
MergeSources: user.DanmakuMergeSources,
|
MergeSources: user.DanmakuMergeSources,
|
||||||
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
|
||||||
}
|
}
|
||||||
@@ -495,6 +517,17 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
|
|||||||
}
|
}
|
||||||
updates["player_volume"] = *patch.Volume
|
updates["player_volume"] = *patch.Volume
|
||||||
}
|
}
|
||||||
|
if patch.PlaybackRate != nil {
|
||||||
|
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
|
||||||
|
return DanmakuRenderConfig{}, fmt.Errorf(
|
||||||
|
"%w: playback_rate must be between %.2f and %.2f",
|
||||||
|
ErrInvalidDanmakuSettings,
|
||||||
|
minPlayerPlaybackRate,
|
||||||
|
maxPlayerPlaybackRate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updates["player_playback_rate"] = *patch.PlaybackRate
|
||||||
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
|||||||
user.DanmakuFontSize = 30
|
user.DanmakuFontSize = 30
|
||||||
user.DanmakuArea = 0.7
|
user.DanmakuArea = 0.7
|
||||||
user.PlayerVolume = 0.42
|
user.PlayerVolume = 0.42
|
||||||
|
user.PlayerPlaybackRate = 1.25
|
||||||
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
require.NoError(t, svc.repo.User.Create(ctx, &user))
|
||||||
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
|
||||||
|
|
||||||
@@ -430,6 +431,7 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
|
|||||||
require.Contains(t, res.Raw, "弹幕A")
|
require.Contains(t, res.Raw, "弹幕A")
|
||||||
require.Equal(t, srv.URL(), res.Source)
|
require.Equal(t, srv.URL(), res.Source)
|
||||||
require.Equal(t, 0.42, res.Volume)
|
require.Equal(t, 0.42, res.Volume)
|
||||||
|
require.Equal(t, 1.25, res.PlaybackRate)
|
||||||
require.Equal(t, "0.65", res.Opacity)
|
require.Equal(t, "0.65", res.Opacity)
|
||||||
require.Equal(t, "30", res.FontSize)
|
require.Equal(t, "30", res.FontSize)
|
||||||
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface DanmakuConfig {
|
|||||||
font_size: string
|
font_size: string
|
||||||
area: string
|
area: string
|
||||||
volume: number
|
volume: number
|
||||||
|
playback_rate: number
|
||||||
/** Per-user preference: merge the same episode's multiple sources. */
|
/** Per-user preference: merge the same episode's multiple sources. */
|
||||||
merge_sources: boolean
|
merge_sources: boolean
|
||||||
}
|
}
|
||||||
@@ -87,6 +88,7 @@ export interface DanmakuSettingsPatch {
|
|||||||
area?: number
|
area?: number
|
||||||
merge_sources?: boolean
|
merge_sources?: boolean
|
||||||
volume?: number
|
volume?: number
|
||||||
|
playback_rate?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ export function Layout() {
|
|||||||
pathname={location.pathname}
|
pathname={location.pathname}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
|
<LayoutWorkspace
|
||||||
|
routeKey={location.pathname}
|
||||||
|
userKey={user?.id}
|
||||||
|
showMobileBottomNav={showMobileBottomNav}
|
||||||
|
/>
|
||||||
{showMobileBottomNav && (
|
{showMobileBottomNav && (
|
||||||
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
|||||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||||
import { isPlayerRoute } from './layoutNavigation'
|
import { isPlayerRoute } from './layoutNavigation'
|
||||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||||
|
import { useScrollMemory } from '../hooks/useScrollMemory'
|
||||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||||
|
|
||||||
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
||||||
@@ -30,6 +31,7 @@ type LayoutSidebarsProps = Omit<
|
|||||||
|
|
||||||
type LayoutWorkspaceProps = {
|
type LayoutWorkspaceProps = {
|
||||||
routeKey: string
|
routeKey: string
|
||||||
|
userKey?: string
|
||||||
showMobileBottomNav?: boolean
|
showMobileBottomNav?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +111,13 @@ export function LayoutSidebars({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
|
export function LayoutWorkspace({
|
||||||
|
routeKey,
|
||||||
|
userKey = 'anonymous',
|
||||||
|
showMobileBottomNav = false,
|
||||||
|
}: LayoutWorkspaceProps) {
|
||||||
|
useScrollMemory(routeKey, userKey)
|
||||||
|
|
||||||
const bottomPad = showMobileBottomNav
|
const bottomPad = showMobileBottomNav
|
||||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||||
: ''
|
: ''
|
||||||
|
|||||||
@@ -18,11 +18,18 @@ import {
|
|||||||
Rewind,
|
Rewind,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
|
Timer,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { SubtitleTrack } from '../api/subtitles'
|
import type { SubtitleTrack } from '../api/subtitles'
|
||||||
import type { PlaybackQuality } from '../types'
|
import type { PlaybackQuality } from '../types'
|
||||||
|
import {
|
||||||
|
formatPlaybackRate,
|
||||||
|
normalizePlaybackRate,
|
||||||
|
PLAYBACK_RATE_OPTIONS,
|
||||||
|
stepPlaybackRate,
|
||||||
|
} from '../utils/playbackRate'
|
||||||
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||||
import {
|
import {
|
||||||
SUBTITLE_POSITION_OPTIONS,
|
SUBTITLE_POSITION_OPTIONS,
|
||||||
@@ -47,18 +54,28 @@ const SEEK_STEP_SEC = 10
|
|||||||
const SEEK_REPEAT_MS = 160
|
const SEEK_REPEAT_MS = 160
|
||||||
const SEEK_APPLY_MS = 220
|
const SEEK_APPLY_MS = 220
|
||||||
const SEEK_HINT_MS = 700
|
const SEEK_HINT_MS = 700
|
||||||
|
const PLAYBACK_RATE_REPEAT_MS = 140
|
||||||
|
const PLAYBACK_RATE_HINT_MS = 900
|
||||||
|
|
||||||
type SeekHint = {
|
type SeekHint = {
|
||||||
dir: 'back' | 'forward'
|
dir: 'back' | 'forward'
|
||||||
seconds: number
|
seconds: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEditableTarget(target: EventTarget | null): boolean {
|
function isPlayerSeekRange(target: EventTarget | null): boolean {
|
||||||
|
return target instanceof HTMLInputElement && target.dataset.playerSeekRange === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnorePlayerSeekShortcut(target: EventTarget | null): boolean {
|
||||||
if (!(target instanceof HTMLElement)) return false
|
if (!(target instanceof HTMLElement)) return false
|
||||||
return Boolean(
|
const control = target.closest<HTMLElement>(
|
||||||
target.closest('input, textarea, select, [contenteditable="true"]') ||
|
'input, textarea, select, [contenteditable="true"]',
|
||||||
target.isContentEditable,
|
|
||||||
)
|
)
|
||||||
|
if (control instanceof HTMLInputElement) {
|
||||||
|
// 进度条需要继续走播放器的 10 秒快捷键;其它输入控件保留原生键盘行为。
|
||||||
|
return !isPlayerSeekRange(control)
|
||||||
|
}
|
||||||
|
return Boolean(control || target.isContentEditable)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayerControlsProps = {
|
type PlayerControlsProps = {
|
||||||
@@ -67,6 +84,10 @@ type PlayerControlsProps = {
|
|||||||
volume?: number
|
volume?: number
|
||||||
onVolumeChange?: (volume: number) => void
|
onVolumeChange?: (volume: number) => void
|
||||||
onVolumeCommit?: (volume: number) => void
|
onVolumeCommit?: (volume: number) => void
|
||||||
|
/** 用户级播放倍速,由播放页从数据库读取。 */
|
||||||
|
playbackRate?: number
|
||||||
|
onPlaybackRateChange?: (rate: number) => void
|
||||||
|
onPlaybackRateCommit?: (rate: number) => void
|
||||||
uiVisible: boolean
|
uiVisible: boolean
|
||||||
onUiVisibleChange: (visible: boolean) => void
|
onUiVisibleChange: (visible: boolean) => void
|
||||||
subs: SubtitleTrack[]
|
subs: SubtitleTrack[]
|
||||||
@@ -108,6 +129,9 @@ export function PlayerControls({
|
|||||||
volume: volumeProp = 1,
|
volume: volumeProp = 1,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
onVolumeCommit,
|
onVolumeCommit,
|
||||||
|
playbackRate: playbackRateProp = 1,
|
||||||
|
onPlaybackRateChange,
|
||||||
|
onPlaybackRateCommit,
|
||||||
uiVisible,
|
uiVisible,
|
||||||
onUiVisibleChange,
|
onUiVisibleChange,
|
||||||
subs,
|
subs,
|
||||||
@@ -159,11 +183,14 @@ export function PlayerControls({
|
|||||||
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
|
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [qualityMenuOpen, setQualityMenuOpen] = useState(false)
|
const [qualityMenuOpen, setQualityMenuOpen] = useState(false)
|
||||||
const qualityMenuRef = useRef<HTMLDivElement | null>(null)
|
const qualityMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [speedMenuOpen, setSpeedMenuOpen] = useState(false)
|
||||||
|
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const controlsHoveredRef = useRef(false)
|
const controlsHoveredRef = useRef(false)
|
||||||
const isScrubbingRef = useRef(false)
|
const isScrubbingRef = useRef(false)
|
||||||
const subtitleMenuOpenRef = useRef(false)
|
const subtitleMenuOpenRef = useRef(false)
|
||||||
const qualityMenuOpenRef = useRef(false)
|
const qualityMenuOpenRef = useRef(false)
|
||||||
|
const speedMenuOpenRef = useRef(false)
|
||||||
const danmakuOpenRef = useRef(false)
|
const danmakuOpenRef = useRef(false)
|
||||||
const playlistOpenRef = useRef(false)
|
const playlistOpenRef = useRef(false)
|
||||||
const pendingSeekRef = useRef<number | null>(null)
|
const pendingSeekRef = useRef<number | null>(null)
|
||||||
@@ -173,7 +200,11 @@ export function PlayerControls({
|
|||||||
const seekApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const seekApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const seekHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const seekHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const lastSeekAtRef = useRef(0)
|
const lastSeekAtRef = useRef(0)
|
||||||
|
const playbackRateRef = useRef(normalizePlaybackRate(playbackRateProp))
|
||||||
|
const playbackRateHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lastPlaybackRateAtRef = useRef(0)
|
||||||
const [seekHint, setSeekHint] = useState<SeekHint | null>(null)
|
const [seekHint, setSeekHint] = useState<SeekHint | null>(null)
|
||||||
|
const [playbackRateHint, setPlaybackRateHint] = useState<number | null>(null)
|
||||||
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -192,6 +223,10 @@ export function PlayerControls({
|
|||||||
qualityMenuOpenRef.current = qualityMenuOpen
|
qualityMenuOpenRef.current = qualityMenuOpen
|
||||||
}, [qualityMenuOpen])
|
}, [qualityMenuOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
speedMenuOpenRef.current = speedMenuOpen
|
||||||
|
}, [speedMenuOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
danmakuOpenRef.current = danmakuOpen
|
danmakuOpenRef.current = danmakuOpen
|
||||||
}, [danmakuOpen])
|
}, [danmakuOpen])
|
||||||
@@ -204,6 +239,10 @@ export function PlayerControls({
|
|||||||
durationRef.current = duration
|
durationRef.current = duration
|
||||||
}, [duration])
|
}, [duration])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
playbackRateRef.current = normalizePlaybackRate(playbackRateProp)
|
||||||
|
}, [playbackRateProp])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStageEl(container())
|
setStageEl(container())
|
||||||
}, [container])
|
}, [container])
|
||||||
@@ -219,9 +258,19 @@ export function PlayerControls({
|
|||||||
setMuted(next === 0)
|
setMuted(next === 0)
|
||||||
}, [video, volumeProp])
|
}, [video, volumeProp])
|
||||||
|
|
||||||
// 点击控制栏外部时关闭字幕/画质菜单
|
// 倍速由播放页按用户持久化;配置加载、切换剧集或切换播放源后同步到当前 video。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subtitleMenuOpen && !qualityMenuOpen) return
|
const el = video()
|
||||||
|
if (!el) return
|
||||||
|
const next = normalizePlaybackRate(playbackRateProp)
|
||||||
|
if (Math.abs(el.playbackRate - next) > 0.001) {
|
||||||
|
el.playbackRate = next
|
||||||
|
}
|
||||||
|
}, [video, playbackRateProp])
|
||||||
|
|
||||||
|
// 点击控制栏外部时关闭字幕/画质/倍速菜单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen) return
|
||||||
const onDocClick = (e: MouseEvent) => {
|
const onDocClick = (e: MouseEvent) => {
|
||||||
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||||
setSubtitleMenuOpen(false)
|
setSubtitleMenuOpen(false)
|
||||||
@@ -229,10 +278,13 @@ export function PlayerControls({
|
|||||||
if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) {
|
if (qualityMenuRef.current && !qualityMenuRef.current.contains(e.target as Node)) {
|
||||||
setQualityMenuOpen(false)
|
setQualityMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
if (speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setSpeedMenuOpen(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onDocClick)
|
document.addEventListener('mousedown', onDocClick)
|
||||||
return () => document.removeEventListener('mousedown', onDocClick)
|
return () => document.removeEventListener('mousedown', onDocClick)
|
||||||
}, [subtitleMenuOpen, qualityMenuOpen])
|
}, [subtitleMenuOpen, qualityMenuOpen, speedMenuOpen])
|
||||||
|
|
||||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
|
// 播放时 3 秒无操作自动隐藏控制栏;暂停/悬停/拖动进度条/打开菜单时保持显示。
|
||||||
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
|
// 监听挂在整个播放器舞台容器(data-player-stage)上,避免光标移到控制栏时因离开视频画面而误触发 mouseleave。
|
||||||
@@ -249,6 +301,7 @@ export function PlayerControls({
|
|||||||
!isScrubbingRef.current &&
|
!isScrubbingRef.current &&
|
||||||
!subtitleMenuOpenRef.current &&
|
!subtitleMenuOpenRef.current &&
|
||||||
!qualityMenuOpenRef.current &&
|
!qualityMenuOpenRef.current &&
|
||||||
|
!speedMenuOpenRef.current &&
|
||||||
!danmakuOpenRef.current &&
|
!danmakuOpenRef.current &&
|
||||||
!playlistOpenRef.current
|
!playlistOpenRef.current
|
||||||
) {
|
) {
|
||||||
@@ -258,6 +311,7 @@ export function PlayerControls({
|
|||||||
!isScrubbingRef.current &&
|
!isScrubbingRef.current &&
|
||||||
!subtitleMenuOpenRef.current &&
|
!subtitleMenuOpenRef.current &&
|
||||||
!qualityMenuOpenRef.current &&
|
!qualityMenuOpenRef.current &&
|
||||||
|
!speedMenuOpenRef.current &&
|
||||||
!danmakuOpenRef.current &&
|
!danmakuOpenRef.current &&
|
||||||
!playlistOpenRef.current
|
!playlistOpenRef.current
|
||||||
) {
|
) {
|
||||||
@@ -360,7 +414,7 @@ export function PlayerControls({
|
|||||||
|
|
||||||
// 当悬停或菜单状态改变时,更新控制栏计时器
|
// 当悬停或菜单状态改变时,更新控制栏计时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || danmakuOpen || playlistOpen) {
|
if (controlsHovered || isScrubbing || subtitleMenuOpen || qualityMenuOpen || speedMenuOpen || danmakuOpen || playlistOpen) {
|
||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
} else {
|
} else {
|
||||||
@@ -370,7 +424,7 @@ export function PlayerControls({
|
|||||||
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
}, [controlsHovered, isScrubbing, subtitleMenuOpen, qualityMenuOpen, speedMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const el = video()
|
const el = video()
|
||||||
@@ -379,6 +433,30 @@ export function PlayerControls({
|
|||||||
else el.pause()
|
else el.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changePlaybackRate = useCallback((next: number) => {
|
||||||
|
const normalized = normalizePlaybackRate(next)
|
||||||
|
playbackRateRef.current = normalized
|
||||||
|
onPlaybackRateChange?.(normalized)
|
||||||
|
setPlaybackRateHint(normalized)
|
||||||
|
if (playbackRateHintTimerRef.current) {
|
||||||
|
clearTimeout(playbackRateHintTimerRef.current)
|
||||||
|
}
|
||||||
|
playbackRateHintTimerRef.current = setTimeout(() => {
|
||||||
|
setPlaybackRateHint(null)
|
||||||
|
playbackRateHintTimerRef.current = null
|
||||||
|
}, 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])
|
||||||
|
|
||||||
const applyAbsoluteSeek = useCallback(
|
const applyAbsoluteSeek = useCallback(
|
||||||
(absolute: number) => {
|
(absolute: number) => {
|
||||||
const el = video()
|
const el = video()
|
||||||
@@ -454,7 +532,7 @@ export function PlayerControls({
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}, [onUiVisibleChange, video])
|
}, [onUiVisibleChange, video])
|
||||||
|
|
||||||
const queueRelativeSeek = useCallback((delta: number) => {
|
const queueRelativeSeek = useCallback((delta: number, immediate: boolean) => {
|
||||||
const el = video()
|
const el = video()
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (!seekBurstRef.current) {
|
if (!seekBurstRef.current) {
|
||||||
@@ -489,6 +567,16 @@ export function PlayerControls({
|
|||||||
|
|
||||||
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
||||||
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
|
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
|
||||||
|
if (immediate) {
|
||||||
|
if (seekApplyTimerRef.current) {
|
||||||
|
clearTimeout(seekApplyTimerRef.current)
|
||||||
|
seekApplyTimerRef.current = null
|
||||||
|
}
|
||||||
|
const burst = seekBurstRef.current
|
||||||
|
seekBurstRef.current = null
|
||||||
|
applySeekRef.current(burst.base + burst.delta)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||||
seekApplyTimerRef.current = setTimeout(() => {
|
seekApplyTimerRef.current = setTimeout(() => {
|
||||||
const burst = seekBurstRef.current
|
const burst = seekBurstRef.current
|
||||||
@@ -502,23 +590,47 @@ export function PlayerControls({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||||
if (isEditableTarget(e.target)) return
|
if (shouldIgnorePlayerSeekShortcut(e.target)) return
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
const now = Date.now()
|
||||||
|
if (e.repeat && now - lastPlaybackRateAtRef.current < PLAYBACK_RATE_REPEAT_MS) return
|
||||||
|
lastPlaybackRateAtRef.current = now
|
||||||
|
changePlaybackRate(
|
||||||
|
stepPlaybackRate(playbackRateRef.current, e.key === 'ArrowUp' ? 1 : -1),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return
|
if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return
|
||||||
|
const immediate = !e.repeat && now - lastSeekAtRef.current >= SEEK_APPLY_MS
|
||||||
lastSeekAtRef.current = now
|
lastSeekAtRef.current = now
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC)
|
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC, immediate)
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey, true)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey, true)
|
||||||
}, [queueRelativeSeek])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
|
||||||
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
|
||||||
|
if (playbackRateHintTimerRef.current) clearTimeout(playbackRateHintTimerRef.current)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -612,9 +724,24 @@ export function PlayerControls({
|
|||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const playbackRateOverlay = playbackRateHint && stageEl
|
||||||
|
? createPortal(
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-black/65 px-5 py-3 text-white shadow-lg backdrop-blur-sm">
|
||||||
|
<Timer size={22} />
|
||||||
|
<span className="text-sm font-semibold tabular-nums">
|
||||||
|
{formatPlaybackRate(playbackRateHint)} 倍速
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
stageEl,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{seekOverlay}
|
{seekOverlay}
|
||||||
|
{playbackRateOverlay}
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
|
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
|
||||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
@@ -665,6 +792,7 @@ export function PlayerControls({
|
|||||||
<div className="order-2 flex min-w-0 basis-full items-center gap-2 sm:order-none sm:basis-auto sm:flex-1">
|
<div className="order-2 flex min-w-0 basis-full items-center gap-2 sm:order-none sm:basis-auto sm:flex-1">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
data-player-seek-range="true"
|
||||||
min={0}
|
min={0}
|
||||||
max={duration || 0}
|
max={duration || 0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
@@ -697,6 +825,7 @@ export function PlayerControls({
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubtitleMenuOpen(false)
|
setSubtitleMenuOpen(false)
|
||||||
|
setSpeedMenuOpen(false)
|
||||||
setQualityMenuOpen((v) => !v)
|
setQualityMenuOpen((v) => !v)
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
|
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
|
||||||
@@ -754,11 +883,55 @@ export function PlayerControls({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="relative" ref={speedMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSubtitleMenuOpen(false)
|
||||||
|
setQualityMenuOpen(false)
|
||||||
|
setSpeedMenuOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded-full p-1.5 transition hover:bg-white/15"
|
||||||
|
title="播放速度(↑/↓ 调节)"
|
||||||
|
>
|
||||||
|
<Timer size={18} className={speedMenuOpen ? 'text-rose-400' : 'text-white/80'} />
|
||||||
|
<span className="text-[10px] font-semibold tabular-nums text-white/80">
|
||||||
|
{formatPlaybackRate(playbackRateProp)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{speedMenuOpen && (
|
||||||
|
<div className="absolute bottom-11 right-0 z-30 min-w-36 overflow-hidden rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||||
|
<p className="px-3 pb-1 pt-2 text-[10px] uppercase tracking-wide text-white/40">
|
||||||
|
播放速度
|
||||||
|
</p>
|
||||||
|
{PLAYBACK_RATE_OPTIONS.map((rate) => {
|
||||||
|
const current = normalizePlaybackRate(playbackRateProp) === rate
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={rate}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
selectPlaybackRate(rate)
|
||||||
|
setSpeedMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition ${
|
||||||
|
current ? 'text-rose-400 hover:bg-white/10' : 'text-white/85 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="tabular-nums">{formatPlaybackRate(rate)}</span>
|
||||||
|
{current && <Check size={13} className="ml-auto text-rose-400" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{subs.length > 0 && (
|
{subs.length > 0 && (
|
||||||
<div className="relative" ref={subtitleMenuRef}>
|
<div className="relative" ref={subtitleMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQualityMenuOpen(false)
|
setQualityMenuOpen(false)
|
||||||
|
setSpeedMenuOpen(false)
|
||||||
setSubtitleMenuOpen((v) => !v)
|
setSubtitleMenuOpen((v) => !v)
|
||||||
}}
|
}}
|
||||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { shouldRememberScroll } from './useScrollMemory.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`useScrollMemory: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
check('home remembers scroll', shouldRememberScroll('/'))
|
||||||
|
check('library list remembers scroll', shouldRememberScroll('/libraries'))
|
||||||
|
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||||
|
check('media detail remembers scroll', shouldRememberScroll('/media/media-1'))
|
||||||
|
check('settings does not remember scroll', !shouldRememberScroll('/settings'))
|
||||||
|
check('player does not remember scroll', !shouldRememberScroll('/play/media-1'))
|
||||||
|
|
||||||
|
console.log('useScrollMemory.test.ts ok')
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useLayoutEffect } from 'react'
|
||||||
|
|
||||||
|
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
|
||||||
|
const scrollPositions = new Map<string, number>()
|
||||||
|
|
||||||
|
function readScrollPosition(key: string): number {
|
||||||
|
const cached = scrollPositions.get(key)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
if (typeof window === 'undefined') return 0
|
||||||
|
try {
|
||||||
|
const value = Number(window.sessionStorage.getItem(SCROLL_STORAGE_PREFIX + key))
|
||||||
|
if (Number.isFinite(value) && value >= 0) {
|
||||||
|
scrollPositions.set(key, value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage 不可用时仍可使用内存缓存。
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeScrollPosition(key: string, value: number): void {
|
||||||
|
const next = Math.max(0, Math.round(value))
|
||||||
|
scrollPositions.set(key, next)
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(SCROLL_STORAGE_PREFIX + key, String(next))
|
||||||
|
} catch {
|
||||||
|
// 私密模式等场景忽略存储失败,不影响页面滚动。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRememberScroll(pathname: string): boolean {
|
||||||
|
return (
|
||||||
|
pathname === '/' ||
|
||||||
|
pathname === '/libraries' ||
|
||||||
|
pathname.startsWith('/library/') ||
|
||||||
|
pathname.startsWith('/media/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
|
||||||
|
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
|
||||||
|
*/
|
||||||
|
export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = document.getElementById('app-main-scroll')
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
if (!shouldRememberScroll(pathname)) {
|
||||||
|
el.scrollTop = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${userKey}:${pathname}`
|
||||||
|
const saved = readScrollPosition(key)
|
||||||
|
let restoring = saved > 0
|
||||||
|
let observer: ResizeObserver | null = null
|
||||||
|
let restoreTimer: number | null = null
|
||||||
|
let restoreFrame = 0
|
||||||
|
let restorePumpUntil = 0
|
||||||
|
let lastSaved = saved
|
||||||
|
|
||||||
|
const stopRestore = () => {
|
||||||
|
if (restoreFrame) {
|
||||||
|
window.cancelAnimationFrame(restoreFrame)
|
||||||
|
restoreFrame = 0
|
||||||
|
}
|
||||||
|
if (restoreTimer != null) {
|
||||||
|
window.clearTimeout(restoreTimer)
|
||||||
|
restoreTimer = null
|
||||||
|
}
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishRestore = () => {
|
||||||
|
if (!restoring) return
|
||||||
|
restoring = false
|
||||||
|
stopRestore()
|
||||||
|
lastSaved = Math.round(el.scrollTop)
|
||||||
|
writeScrollPosition(key, lastSaved)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryRestore = () => {
|
||||||
|
if (!restoring) return
|
||||||
|
const max = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||||
|
const target = Math.min(saved, max)
|
||||||
|
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
|
||||||
|
el.scrollTop = target
|
||||||
|
}
|
||||||
|
if (target >= saved - 1) {
|
||||||
|
finishRestore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pumpRestore = () => {
|
||||||
|
if (!restoring) return
|
||||||
|
tryRestore()
|
||||||
|
if (restoring && performance.now() < restorePumpUntil) {
|
||||||
|
restoreFrame = window.requestAnimationFrame(pumpRestore)
|
||||||
|
} else {
|
||||||
|
restoreFrame = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNow = () => {
|
||||||
|
if (restoring) return
|
||||||
|
const current = Math.round(el.scrollTop)
|
||||||
|
if (current === lastSaved) return
|
||||||
|
lastSaved = current
|
||||||
|
writeScrollPosition(key, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRestore = () => {
|
||||||
|
if (!restoring) return
|
||||||
|
restoring = false
|
||||||
|
stopRestore()
|
||||||
|
lastSaved = Math.round(el.scrollTop)
|
||||||
|
writeScrollPosition(key, lastSaved)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved <= 0) {
|
||||||
|
el.scrollTop = 0
|
||||||
|
} else {
|
||||||
|
tryRestore()
|
||||||
|
if (restoring) {
|
||||||
|
const content = el.firstElementChild
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) {
|
||||||
|
observer = new ResizeObserver(() => tryRestore())
|
||||||
|
observer.observe(content)
|
||||||
|
}
|
||||||
|
restorePumpUntil = performance.now() + 1000
|
||||||
|
restoreFrame = window.requestAnimationFrame(pumpRestore)
|
||||||
|
restoreTimer = window.setTimeout(finishRestore, 10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.target === el) {
|
||||||
|
cancelRestore()
|
||||||
|
}
|
||||||
|
saveNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === 'PageDown' ||
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'Home' ||
|
||||||
|
event.key === 'End' ||
|
||||||
|
event.key === 'ArrowDown' ||
|
||||||
|
event.key === 'ArrowUp' ||
|
||||||
|
event.key === ' '
|
||||||
|
) {
|
||||||
|
cancelRestore()
|
||||||
|
}
|
||||||
|
saveNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('scroll', saveNow, { passive: true })
|
||||||
|
el.addEventListener('pointerdown', onPointerDown, true)
|
||||||
|
window.addEventListener('wheel', cancelRestore, { passive: true, capture: true })
|
||||||
|
window.addEventListener('touchstart', cancelRestore, { passive: true, capture: true })
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', saveNow)
|
||||||
|
el.removeEventListener('pointerdown', onPointerDown, true)
|
||||||
|
window.removeEventListener('wheel', cancelRestore, true)
|
||||||
|
window.removeEventListener('touchstart', cancelRestore, true)
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
stopRestore()
|
||||||
|
}
|
||||||
|
}, [pathname, userKey])
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
|
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||||
import {
|
import {
|
||||||
normalizeSubtitleChineseMode,
|
normalizeSubtitleChineseMode,
|
||||||
@@ -109,8 +110,13 @@ export function PlayerPage() {
|
|||||||
const [playerVolume, setPlayerVolume] = useState(() =>
|
const [playerVolume, setPlayerVolume] = useState(() =>
|
||||||
normalizePlayerVolume(authUser?.player_volume),
|
normalizePlayerVolume(authUser?.player_volume),
|
||||||
)
|
)
|
||||||
|
const [playerPlaybackRate, setPlayerPlaybackRate] = useState(() =>
|
||||||
|
normalizePlaybackRate(authUser?.player_playback_rate),
|
||||||
|
)
|
||||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||||
const playerVolumeTouchedRef = useRef(false)
|
const playerVolumeTouchedRef = useRef(false)
|
||||||
|
const playerPlaybackRateTouchedRef = useRef(false)
|
||||||
|
const playerPlaybackRateSaveSeqRef = useRef(0)
|
||||||
const subtitlePreferenceTouchedRef = useRef(false)
|
const subtitlePreferenceTouchedRef = useRef(false)
|
||||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||||
@@ -245,6 +251,10 @@ export function PlayerPage() {
|
|||||||
if (!playerVolumeTouchedRef.current) {
|
if (!playerVolumeTouchedRef.current) {
|
||||||
setPlayerVolume(volume)
|
setPlayerVolume(volume)
|
||||||
}
|
}
|
||||||
|
const playbackRate = normalizePlaybackRate(cfg.playback_rate)
|
||||||
|
if (!playerPlaybackRateTouchedRef.current) {
|
||||||
|
setPlayerPlaybackRate(playbackRate)
|
||||||
|
}
|
||||||
setDanmakuEnabled(cfg.enabled)
|
setDanmakuEnabled(cfg.enabled)
|
||||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||||
@@ -255,6 +265,10 @@ export function PlayerPage() {
|
|||||||
const current = useAuthStore.getState().user
|
const current = useAuthStore.getState().user
|
||||||
if (current) setAuthUser({ ...current, player_volume: volume })
|
if (current) setAuthUser({ ...current, player_volume: volume })
|
||||||
}
|
}
|
||||||
|
if (!playerPlaybackRateTouchedRef.current) {
|
||||||
|
const current = useAuthStore.getState().user
|
||||||
|
if (current) setAuthUser({ ...current, player_playback_rate: playbackRate })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// 配置读取失败时保持本地默认值,不影响播放。
|
// 配置读取失败时保持本地默认值,不影响播放。
|
||||||
@@ -289,6 +303,34 @@ export function PlayerPage() {
|
|||||||
[setAuthUser],
|
[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(
|
const saveDanmakuAdvanced = useCallback(
|
||||||
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
||||||
const patch: DanmakuSettingsPatch = {
|
const patch: DanmakuSettingsPatch = {
|
||||||
@@ -1424,6 +1466,9 @@ export function PlayerPage() {
|
|||||||
playerVolume={playerVolume}
|
playerVolume={playerVolume}
|
||||||
onPlayerVolumeChange={changePlayerVolume}
|
onPlayerVolumeChange={changePlayerVolume}
|
||||||
onPlayerVolumeCommit={commitPlayerVolume}
|
onPlayerVolumeCommit={commitPlayerVolume}
|
||||||
|
playerPlaybackRate={playerPlaybackRate}
|
||||||
|
onPlayerPlaybackRateChange={changePlayerPlaybackRate}
|
||||||
|
onPlayerPlaybackRateCommit={commitPlayerPlaybackRate}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
danmakuOpacity={danmakuOpacity}
|
danmakuOpacity={danmakuOpacity}
|
||||||
danmakuFontSize={danmakuFontSize}
|
danmakuFontSize={danmakuFontSize}
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ type PlayerVideoStageProps = {
|
|||||||
playerVolume: number
|
playerVolume: number
|
||||||
onPlayerVolumeChange: (volume: number) => void
|
onPlayerVolumeChange: (volume: number) => void
|
||||||
onPlayerVolumeCommit: (volume: number) => void
|
onPlayerVolumeCommit: (volume: number) => void
|
||||||
|
playerPlaybackRate: number
|
||||||
|
onPlayerPlaybackRateChange: (rate: number) => void
|
||||||
|
onPlayerPlaybackRateCommit: (rate: number) => void
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
danmakuOpacity: number
|
danmakuOpacity: number
|
||||||
danmakuFontSize: number
|
danmakuFontSize: number
|
||||||
@@ -205,6 +208,9 @@ export function PlayerVideoStage({
|
|||||||
playerVolume,
|
playerVolume,
|
||||||
onPlayerVolumeChange,
|
onPlayerVolumeChange,
|
||||||
onPlayerVolumeCommit,
|
onPlayerVolumeCommit,
|
||||||
|
playerPlaybackRate,
|
||||||
|
onPlayerPlaybackRateChange,
|
||||||
|
onPlayerPlaybackRateCommit,
|
||||||
danmakuEnabled,
|
danmakuEnabled,
|
||||||
danmakuOpacity,
|
danmakuOpacity,
|
||||||
danmakuFontSize,
|
danmakuFontSize,
|
||||||
@@ -653,6 +659,9 @@ export function PlayerVideoStage({
|
|||||||
volume={playerVolume}
|
volume={playerVolume}
|
||||||
onVolumeChange={onPlayerVolumeChange}
|
onVolumeChange={onPlayerVolumeChange}
|
||||||
onVolumeCommit={onPlayerVolumeCommit}
|
onVolumeCommit={onPlayerVolumeCommit}
|
||||||
|
playbackRate={playerPlaybackRate}
|
||||||
|
onPlaybackRateChange={onPlayerPlaybackRateChange}
|
||||||
|
onPlaybackRateCommit={onPlayerPlaybackRateCommit}
|
||||||
uiVisible={controlsVisible}
|
uiVisible={controlsVisible}
|
||||||
onUiVisibleChange={setControlsVisible}
|
onUiVisibleChange={setControlsVisible}
|
||||||
subs={subs}
|
subs={subs}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface User {
|
|||||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||||
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
/** 本地缓存的播放器音量,服务端配置接口会刷新。 */
|
||||||
player_volume?: number
|
player_volume?: number
|
||||||
|
/** 本地缓存的播放倍速,服务端配置接口会刷新。 */
|
||||||
|
player_playback_rate?: number
|
||||||
force_password_reset: boolean
|
force_password_reset: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
allowed_library_ids?: string[]
|
allowed_library_ids?: string[]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
formatPlaybackRate,
|
||||||
|
normalizePlaybackRate,
|
||||||
|
stepPlaybackRate,
|
||||||
|
} from './playbackRate.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`playbackRate: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
check('invalid value falls back to 1x', normalizePlaybackRate(undefined) === 1)
|
||||||
|
check('value snaps to nearest selectable rate', normalizePlaybackRate(1.2) === 1.25)
|
||||||
|
check('value below range clamps to the minimum', normalizePlaybackRate(0.1) === 0.5)
|
||||||
|
check('value above range clamps to the maximum', normalizePlaybackRate(9) === 3)
|
||||||
|
check('up steps to the next rate', stepPlaybackRate(1, 1) === 1.25)
|
||||||
|
check('down steps to the previous rate', stepPlaybackRate(1, -1) === 0.75)
|
||||||
|
check('up stops at the maximum', stepPlaybackRate(3, 1) === 3)
|
||||||
|
check('down stops at the minimum', stepPlaybackRate(0.5, -1) === 0.5)
|
||||||
|
check('integer formatting', formatPlaybackRate(1) === '1x')
|
||||||
|
check('decimal formatting', formatPlaybackRate(1.25) === '1.25x')
|
||||||
|
|
||||||
|
console.log('playbackRate.test.ts ok')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3] as const
|
||||||
|
|
||||||
|
export const MIN_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[0]
|
||||||
|
export const MAX_PLAYBACK_RATE = PLAYBACK_RATE_OPTIONS[PLAYBACK_RATE_OPTIONS.length - 1]
|
||||||
|
|
||||||
|
export function normalizePlaybackRate(value: unknown): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) return 1
|
||||||
|
let closest: number = PLAYBACK_RATE_OPTIONS[0]
|
||||||
|
let closestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (const option of PLAYBACK_RATE_OPTIONS) {
|
||||||
|
const distance = Math.abs(option - parsed)
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closest = option
|
||||||
|
closestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepPlaybackRate(current: unknown, direction: 1 | -1): number {
|
||||||
|
const normalized = normalizePlaybackRate(current)
|
||||||
|
const index = PLAYBACK_RATE_OPTIONS.indexOf(normalized as (typeof PLAYBACK_RATE_OPTIONS)[number])
|
||||||
|
const next = Math.min(PLAYBACK_RATE_OPTIONS.length - 1, Math.max(0, index + direction))
|
||||||
|
return PLAYBACK_RATE_OPTIONS[next]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPlaybackRate(rate: unknown): string {
|
||||||
|
const normalized = normalizePlaybackRate(rate)
|
||||||
|
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : String(normalized)}x`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user