From be9d3a6d9e7fa523c21672bfb1aa17dc38579ab4 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:00:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/emby_compat.go | 6 + internal/service/emby_items_detail.go | 250 ++++++++++++++---- internal/service/emby_remote.go | 12 + .../service/emby_series_hierarchy_test.go | 6 + internal/service/image_resize.go | 36 ++- internal/service/image_resize_test.go | 35 +++ web/src/components/PlayerControls.tsx | 172 ++++++++++++ web/src/pages/PlayerPage.tsx | 3 +- 8 files changed, 468 insertions(+), 52 deletions(-) diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 61dc956..a81be5b 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -21,6 +21,7 @@ import ( "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" "go.uber.org/zap" + "golang.org/x/sync/singleflight" ) // 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到 @@ -65,6 +66,11 @@ type EmbyService struct { peopleMu sync.RWMutex peopleCache map[string]embyPeopleCacheEntry + // latestFlight collapses the homepage stampede: clients request Latest + // for every library at once, and a shared expiry used to rebuild each + // library in parallel. + latestFlight singleflight.Group + tmdb *TMDbProvider adult *AdultProvider personImageMu sync.RWMutex diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 5567e2d..1293fb3 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "go.uber.org/zap" @@ -120,44 +121,87 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil } -// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。 +// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)走远程并缓存。 func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) { + if e == nil { + return nil, nil + } if limit <= 0 || limit > 100 { limit = 20 } - if e.remote != nil && IsEmbyRemoteID(parentID) { - mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID) - mount, acct, _ := e.remote.ResolveMount(ctx, mountID) - if mount == nil || acct == nil { - return nil, nil + cacheKey := e.embyLatestCacheKey(userID, parentID, limit) + if items, ok := e.cachedLatestItems(ctx, cacheKey); ok { + return items, nil + } + // 一个客户端断开不应取消正在为其他客户端填充的共享重建。 + loadCtx := context.WithoutCancel(ctx) + v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) { + if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok { + return embyLatestCacheValue{Items: items}, nil } - if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) { - return nil, nil - } - out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit) + value, err := e.loadLatestItems(loadCtx, userID, parentID, limit) if err != nil { return nil, err } - if err := e.mergeRemoteUserData(ctx, userID, out); err != nil { - return nil, err + if e.cache != nil { + e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second) } - return out, nil + e.rememberArtworkRefs(value.Artwork) + return value, nil + }) + if err != nil { + return nil, err + } + cached, _ := v.(embyLatestCacheValue) + if cached.Items == nil { + return []map[string]any{}, nil + } + return cached.Items, nil +} + +func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) { + if e == nil || e.cache == nil { + return nil, false } - cacheKey := e.embyLatestCacheKey(userID, parentID, limit) var cached embyLatestCacheValue - if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) { - e.rememberArtworkRefs(cached.Artwork) - return cached.Items, nil + if !e.cache.GetJSON(ctx, cacheKey, &cached) { + return nil, false + } + e.rememberArtworkRefs(cached.Artwork) + if cached.Items == nil { + return []map[string]any{}, true + } + return cached.Items, true +} + +func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) { + if e.remote != nil && IsEmbyRemoteID(parentID) { + mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID) + mount, acct, _ := e.remote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) { + return embyLatestCacheValue{Items: []map[string]any{}}, nil + } + out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit) + if err != nil { + return embyLatestCacheValue{}, err + } + if out == nil { + out = []map[string]any{} + } + if err := e.mergeRemoteUserData(ctx, userID, out); err != nil { + return embyLatestCacheValue{}, err + } + return embyLatestCacheValue{Items: out}, nil } q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") q = e.applyUserMediaVisibility(ctx, q, userID) if parentID != "" { if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic { out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit) - if err == nil && e.cache != nil { - e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second) + if err != nil { + return embyLatestCacheValue{}, err } - return out, err + return embyLatestCacheValue{Items: out, Artwork: artwork}, nil } q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID)) } @@ -170,7 +214,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, } var rows []model.Media if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil { - return nil, err + return embyLatestCacheValue{}, err } rows = e.collapseMediaVersionRows(ctx, rows) if len(rows) > limit { @@ -178,12 +222,9 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, } out, err := e.payloadsForMedia(ctx, rows, userID) if err != nil { - return nil, err + return embyLatestCacheValue{}, err } - if e.cache != nil { - e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second) - } - return out, nil + return embyLatestCacheValue{Items: out}, nil } func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) { @@ -194,7 +235,8 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID)) q = e.applyUserMediaVisibility(ctx, q, userID) var rows []model.Media - if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { + // 只要最近几部剧的卡片,不要把整库 5 万行拉进内存再丢掉。 + if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embyLatestSeriesRowLimit(limit)).Find(&rows).Error; err != nil { return nil, nil, err } groups := e.seriesGroupsFromMedia(ctx, rows) @@ -209,6 +251,24 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l return items, e.artworkRefsForSeriesGroups(groups), nil } +// embyLatestSeriesRowLimit 是 Latest 剧集卡片的候选窗口。按「每部剧最近约 40 +// 集」估,刚好覆盖现有 25 部 × 40 集的分组测试,又不会为了 16 张海报去扫整库。 +// ponytail: 一部长剧如果占满窗口,更老的剧不会出现在 Latest 行;点进媒体库 +// 仍走完整分组。上限 2000 行。 +func embyLatestSeriesRowLimit(limit int) int { + if limit < 1 { + limit = 20 + } + n := limit * 40 + if n < 200 { + n = 200 + } + if n > 2000 { + n = 2000 + } + return n +} + // ResumeItems 列出有未完成播放进度的媒体。 func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) { return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit}) @@ -393,7 +453,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st // 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义 // 足够准确。 needed := p.StartIndex + p.Limit - items := make([]map[string]any, 0, p.Limit) + slots := make([]resumeSlot, 0, len(hist)) localTotal, remoteTotal := 0, 0 for _, h := range hist { if m, ok := byID[h.MediaID]; ok { @@ -401,36 +461,77 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st continue } localTotal++ - if produced := len(items); produced < needed { - items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false)) - } + slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)}) continue } if e.remote == nil || !IsEmbyRemoteID(h.MediaID) { continue } remoteTotal++ - if len(items) >= needed { - continue - } - mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID) - mount, acct, err := e.remote.ResolveMount(ctx, mountID) - if err != nil || mount == nil || acct == nil { - continue - } - item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID) - if err != nil || item == nil { - continue - } - if p.ParentID != "" { - parentID, _ := item["ParentId"].(string) - seriesID, _ := item["SeriesId"].(string) - if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID { + slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}}) + } + for { + filled := 0 + pending := make([]*resumeRemoteFetch, 0) + for i := range slots { + if slots[i].item != nil { + filled++ + if filled >= needed { + break + } continue } + f := slots[i].fetch + if f == nil || f.done { + continue + } + if f.mount == nil { + mountID, remoteID, _ := DecodeEmbyRemoteID(f.hist.MediaID) + mount, acct, err := e.remote.ResolveMount(ctx, mountID) + if err != nil || mount == nil || acct == nil { + f.done = true + continue + } + f.mount, f.acct, f.remoteID = mount, acct, remoteID + } + pending = append(pending, f) + if filled+len(pending) >= needed { + break + } } - item["UserData"] = mergedRemoteUserData(item["UserData"], &h) - items = append(items, item) + if len(pending) == 0 { + break + } + e.fillResumeRemotes(ctx, pending) + for i := range slots { + f := slots[i].fetch + if f == nil || !pendingContains(pending, f) { + continue + } + f.done = true + item := f.item + f.item = nil + if item == nil { + continue + } + if p.ParentID != "" { + parentID, _ := item["ParentId"].(string) + seriesID, _ := item["SeriesId"].(string) + mountID, _, _ := DecodeEmbyRemoteID(f.hist.MediaID) + if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID { + continue + } + } + item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist) + slots[i].item = item + } + } + items := make([]map[string]any, 0, p.Limit) + for i := range slots { + if slots[i].item == nil { + continue + } + items = append(items, slots[i].item) } total := int64(localTotal + remoteTotal) @@ -441,6 +542,57 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil } +type resumeSlot struct { + item map[string]any + fetch *resumeRemoteFetch +} + +type resumeRemoteFetch struct { + hist model.PlaybackHistory + mount *model.EmbyMount + acct *model.StrmAccount + remoteID string + item map[string]any + done bool +} + +func pendingContains(rows []*resumeRemoteFetch, target *resumeRemoteFetch) bool { + for _, row := range rows { + if row == target { + return true + } + } + return false +} + +// fillResumeRemotes 并行补齐继续观看里的远程条目。同一页以前是串行 GET, +// 远程一慢就把整个 Resume 拖到数秒。结果按原播放历史顺序回填。 +func (e *EmbyService) fillResumeRemotes(ctx context.Context, rows []*resumeRemoteFetch) { + if e == nil || e.remote == nil || len(rows) == 0 { + return + } + var wg sync.WaitGroup + sem := make(chan struct{}, 4) + for i := range rows { + wg.Add(1) + go func(i int) { + defer wg.Done() + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + defer func() { <-sem }() + item, err := e.remote.RemoteItem(ctx, rows[i].mount, rows[i].acct, rows[i].remoteID) + if err != nil || item == nil { + return + } + rows[i].item = item + }(i) + } + wg.Wait() +} + func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any { itemType := "Movie" name := m.Title diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index d4a14bf..0ba6d2f 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -805,6 +805,15 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model. // RemoteItem 拉取远程单条目详情(含响应的重写)。 func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) { + cacheKey := "" + if r != nil && r.cache != nil && mount != nil && remoteID != "" { + cacheKey = r.remoteCacheKey("item", mount.ID, remoteID) + var cached map[string]any + if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 { + r.rememberRemotePeople(mount, cached) + return cached, nil + } + } cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err @@ -817,6 +826,9 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou } r.rememberRemotePeople(mount, out) RewriteEmbyRemoteIDs(out, mount.ID) + if cacheKey != "" && len(out) > 0 { + r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL()) + } return out, nil } diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index 801f668..c1eaed0 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -156,6 +156,12 @@ func TestEmbySeriesGroupingPaginatesAfterFullLibraryGrouping(t *testing.T) { } } +func TestEmbyLatestSeriesRowLimitCoversGroupedFixture(t *testing.T) { + if got := embyLatestSeriesRowLimit(25); got < 25*40 { + t.Fatalf("latest window %d is smaller than the 25x40 fixture", got) + } +} + func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) { svc := newTestEmbyService(t) lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true} diff --git a/internal/service/image_resize.go b/internal/service/image_resize.go index e620ba4..8941aeb 100644 --- a/internal/service/image_resize.go +++ b/internal/service/image_resize.go @@ -226,6 +226,34 @@ func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) { } } +// tryAcquireResizeSlot 不等待。槽位忙时返回 false,调用方应改出原图。 +func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) { + if p == nil { + return func() {}, true + } + p.resizeSemMu.Lock() + if p.resizeSem == nil { + p.resizeSem = make(chan struct{}, imageResizeConcurrency()) + } + sem := p.resizeSem + p.resizeSemMu.Unlock() + select { + case sem <- struct{}{}: + return func() { <-sem }, true + default: + return nil, false + } +} + +// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放, +// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。 +func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) { + if size > 0 && size <= resizeQueueBypassBytes { + return p.tryAcquireResizeSlot() + } + return p.acquireResizeSlot(ctx) +} + // serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存 // 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。 // 返回 false 表示缩放不可用,调用方应回退到原图直出。 @@ -247,7 +275,7 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request return true } - release, ok := p.acquireResizeSlot(r.Context()) + release, ok := p.acquireResizeSlotFor(r.Context(), stat.Size()) if !ok { return false } @@ -280,9 +308,13 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request // compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节 // 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。 -// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍排队缩放。 +// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。 const compactImageSkipBytes = 200 * 1024 +// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队, +// 以免把未压缩的剧照直接发给客户端。 +const resizeQueueBypassBytes = 1536 * 1024 + // serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时 // 跳过解码。返回 false 表示仍应走缩放路径。 func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool { diff --git a/internal/service/image_resize_test.go b/internal/service/image_resize_test.go index a570caf..c495dd0 100644 --- a/internal/service/image_resize_test.go +++ b/internal/service/image_resize_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "context" "image" "image/color" "image/jpeg" @@ -9,6 +10,7 @@ import ( "net/http/httptest" "os" "testing" + "time" ) // encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。 @@ -299,3 +301,36 @@ func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) { t.Fatal("expected the original jpeg, not a decoded thumbnail") } } + +func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) { + dir := t.TempDir() + src := dir + string(os.PathSeparator) + "backdrop.jpg" + data := make([]byte, compactImageSkipBytes+1024) + data[0], data[1], data[2] = 0xff, 0xd8, 0xff + if err := os.WriteFile(src, data, 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"} + var releases []func() + for i := 0; i < imageResizeConcurrency(); i++ { + release, ok := proxy.acquireResizeSlot(context.Background()) + if !ok { + t.Fatal("expected a resize slot") + } + releases = append(releases, release) + } + defer func() { + for _, release := range releases { + release() + } + }() + + start := time.Now() + handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480}) + if time.Since(start) > 200*time.Millisecond { + t.Fatal("resize slot was busy but the request still waited in the queue") + } + if handled { + t.Fatal("expected a busy resize slot to fall back so the caller can serve the original") + } +} diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index ca63ac2..655f28f 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Captions, CaptionsOff, + FastForward, ListVideo, Maximize, MessageSquareText, @@ -9,6 +11,7 @@ import { Pause, PictureInPicture, Play, + Rewind, SkipBack, SkipForward, Volume2, @@ -35,6 +38,24 @@ function formatTime(s: number): string { return `${m}:${String(sec).padStart(2, '0')}` } +const SEEK_STEP_SEC = 10 +const SEEK_REPEAT_MS = 160 +const SEEK_APPLY_MS = 220 +const SEEK_HINT_MS = 700 + +type SeekHint = { + dir: 'back' | 'forward' + seconds: number +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return Boolean( + target.closest('input, textarea, select, [contenteditable="true"]') || + target.isContentEditable, + ) +} + type PlayerControlsProps = { videoRef: React.RefObject uiVisible: boolean @@ -123,6 +144,14 @@ export function PlayerControls({ const danmakuOpenRef = useRef(false) const playlistOpenRef = useRef(false) const pendingSeekRef = useRef(null) + const applySeekRef = useRef<(absolute: number) => void>(() => undefined) + const durationRef = useRef(0) + const seekBurstRef = useRef<{ base: number; delta: number } | null>(null) + const seekApplyTimerRef = useRef | null>(null) + const seekHintTimerRef = useRef | null>(null) + const lastSeekAtRef = useRef(0) + const [seekHint, setSeekHint] = useState(null) + const [stageEl, setStageEl] = useState(null) useEffect(() => { controlsHoveredRef.current = controlsHovered @@ -144,6 +173,14 @@ export function PlayerControls({ playlistOpenRef.current = playlistOpen }, [playlistOpen]) + useEffect(() => { + durationRef.current = duration + }, [duration]) + + useEffect(() => { + setStageEl(container()) + }, [container]) + // 点击控制栏外部时关闭字幕菜单 useEffect(() => { if (!subtitleMenuOpen) return @@ -311,6 +348,7 @@ export function PlayerControls({ el.currentTime = local setCurrentTime(streamOffset + local) } + applySeekRef.current = applyAbsoluteSeek const handleSeekChange = (v: number) => { setScrubValue(v) @@ -321,6 +359,11 @@ export function PlayerControls({ } const handleSeekStart = () => { + if (seekApplyTimerRef.current) { + clearTimeout(seekApplyTimerRef.current) + seekApplyTimerRef.current = null + } + seekBurstRef.current = null setIsScrubbing(true) onUiVisibleChange(true) if (hideTimerRef.current) clearTimeout(hideTimerRef.current) @@ -332,6 +375,102 @@ export function PlayerControls({ setScrubValue(null) } + const revealControls = useCallback(() => { + onUiVisibleChange(true) + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + const el = video() + if ( + !el || + el.paused || + controlsHoveredRef.current || + isScrubbingRef.current || + subtitleMenuOpenRef.current || + danmakuOpenRef.current || + playlistOpenRef.current + ) { + return + } + hideTimerRef.current = setTimeout(() => { + if ( + !controlsHoveredRef.current && + !isScrubbingRef.current && + !subtitleMenuOpenRef.current && + !danmakuOpenRef.current && + !playlistOpenRef.current + ) { + onUiVisibleChange(false) + } + }, 3000) + }, [onUiVisibleChange, video]) + + const queueRelativeSeek = useCallback((delta: number) => { + const el = video() + if (!el) return + if (!seekBurstRef.current) { + const base = pendingSeekRef.current ?? streamOffset + (el.currentTime || 0) + seekBurstRef.current = { base, delta: 0 } + } + seekBurstRef.current.delta += delta + const max = durationRef.current > 0 ? durationRef.current : Number.POSITIVE_INFINITY + const target = Math.min(max, Math.max(0, seekBurstRef.current.base + seekBurstRef.current.delta)) + const applied = target - seekBurstRef.current.base + seekBurstRef.current.delta = applied + if (applied === 0) { + if (!seekApplyTimerRef.current) { + seekBurstRef.current = null + setSeekHint({ + dir: delta < 0 ? 'back' : 'forward', + seconds: 0, + }) + if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current) + seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS) + } + return + } + + pendingSeekRef.current = target + setCurrentTime(target) + setSeekHint({ + dir: applied < 0 ? 'back' : 'forward', + seconds: Math.abs(Math.round(applied)), + }) + revealControls() + + if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current) + seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS) + if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current) + seekApplyTimerRef.current = setTimeout(() => { + const burst = seekBurstRef.current + seekBurstRef.current = null + seekApplyTimerRef.current = null + if (!burst) return + applySeekRef.current(burst.base + burst.delta) + }, SEEK_APPLY_MS) + }, [revealControls, streamOffset, video]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return + if (isEditableTarget(e.target)) return + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return + e.preventDefault() + const now = Date.now() + if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return + lastSeekAtRef.current = now + e.preventDefault() + queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [queueRelativeSeek]) + + useEffect(() => { + return () => { + if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current) + if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current) + } + }, []) + const changeVolume = (v: number) => { const el = video() if (!el) return @@ -380,7 +519,39 @@ export function PlayerControls({ const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt' const usesOriginalASS = selectedSubtitle?.delivery === 'ass' + const seekOverlay = seekHint && stageEl + ? createPortal( +
+
+ {seekHint.dir === 'back' ? : } + + {seekHint.seconds > 0 + ? seekHint.dir === 'back' + ? '回退' + : '快进' + : seekHint.dir === 'back' + ? '已到开头' + : '已到结尾'} + {seekHint.seconds > 0 && ( + <> +
+ {seekHint.seconds} 秒 + + )} +
+
+
, + stageEl, + ) + : null + return ( + <> + {seekOverlay}
+ ) } \ No newline at end of file diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index e450743..f6f63ca 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -783,7 +783,8 @@ export function PlayerPage() { } }, [nextEpisode, nextEpisodeTitle, playEpisode]) - // ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集 + // ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集。 + // 左右方向键的回退/快进在播放器控制栏里处理。 useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null