mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
优化
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
|
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
|
||||||
@@ -65,6 +66,11 @@ type EmbyService struct {
|
|||||||
peopleMu sync.RWMutex
|
peopleMu sync.RWMutex
|
||||||
peopleCache map[string]embyPeopleCacheEntry
|
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
|
tmdb *TMDbProvider
|
||||||
adult *AdultProvider
|
adult *AdultProvider
|
||||||
personImageMu sync.RWMutex
|
personImageMu sync.RWMutex
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"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
|
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) {
|
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 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||||
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
|
||||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
return items, nil
|
||||||
if mount == nil || acct == nil {
|
}
|
||||||
return nil, 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) {
|
value, err := e.loadLatestItems(loadCtx, userID, parentID, limit)
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
if e.cache != nil {
|
||||||
return nil, err
|
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
|
var cached embyLatestCacheValue
|
||||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||||
e.rememberArtworkRefs(cached.Artwork)
|
return nil, false
|
||||||
return cached.Items, nil
|
}
|
||||||
|
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.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
if parentID != "" {
|
if parentID != "" {
|
||||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||||
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||||
if err == nil && e.cache != nil {
|
if err != nil {
|
||||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
return embyLatestCacheValue{}, err
|
||||||
}
|
}
|
||||||
return out, err
|
return embyLatestCacheValue{Items: out, Artwork: artwork}, nil
|
||||||
}
|
}
|
||||||
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
|
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
|
var rows []model.Media
|
||||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
|
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return embyLatestCacheValue{}, err
|
||||||
}
|
}
|
||||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||||
if len(rows) > limit {
|
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)
|
out, err := e.payloadsForMedia(ctx, rows, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return embyLatestCacheValue{}, err
|
||||||
}
|
}
|
||||||
if e.cache != nil {
|
return embyLatestCacheValue{Items: out}, nil
|
||||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
|
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))
|
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
|
||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
var rows []model.Media
|
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
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||||
@@ -209,6 +251,24 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
|||||||
return items, e.artworkRefsForSeriesGroups(groups), nil
|
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 列出有未完成播放进度的媒体。
|
// ResumeItems 列出有未完成播放进度的媒体。
|
||||||
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||||
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
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
|
needed := p.StartIndex + p.Limit
|
||||||
items := make([]map[string]any, 0, p.Limit)
|
slots := make([]resumeSlot, 0, len(hist))
|
||||||
localTotal, remoteTotal := 0, 0
|
localTotal, remoteTotal := 0, 0
|
||||||
for _, h := range hist {
|
for _, h := range hist {
|
||||||
if m, ok := byID[h.MediaID]; ok {
|
if m, ok := byID[h.MediaID]; ok {
|
||||||
@@ -401,36 +461,77 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
localTotal++
|
localTotal++
|
||||||
if produced := len(items); produced < needed {
|
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
|
||||||
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false))
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
remoteTotal++
|
remoteTotal++
|
||||||
if len(items) >= needed {
|
slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}})
|
||||||
continue
|
}
|
||||||
}
|
for {
|
||||||
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
|
filled := 0
|
||||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
pending := make([]*resumeRemoteFetch, 0)
|
||||||
if err != nil || mount == nil || acct == nil {
|
for i := range slots {
|
||||||
continue
|
if slots[i].item != nil {
|
||||||
}
|
filled++
|
||||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
if filled >= needed {
|
||||||
if err != nil || item == nil {
|
break
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
if p.ParentID != "" {
|
|
||||||
parentID, _ := item["ParentId"].(string)
|
|
||||||
seriesID, _ := item["SeriesId"].(string)
|
|
||||||
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
|
||||||
continue
|
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)
|
if len(pending) == 0 {
|
||||||
items = append(items, item)
|
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)
|
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
|
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 {
|
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
|
||||||
itemType := "Movie"
|
itemType := "Movie"
|
||||||
name := m.Title
|
name := m.Title
|
||||||
|
|||||||
@@ -805,6 +805,15 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
|
|||||||
|
|
||||||
// RemoteItem 拉取远程单条目详情(含响应的重写)。
|
// RemoteItem 拉取远程单条目详情(含响应的重写)。
|
||||||
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
|
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)
|
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -817,6 +826,9 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
|
|||||||
}
|
}
|
||||||
r.rememberRemotePeople(mount, out)
|
r.rememberRemotePeople(mount, out)
|
||||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||||
|
if cacheKey != "" && len(out) > 0 {
|
||||||
|
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
|
||||||
svc := newTestEmbyService(t)
|
svc := newTestEmbyService(t)
|
||||||
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
|
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
|
||||||
|
|||||||
@@ -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 读取图片,按选项缩放后写出,并把结果缓存
|
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||||
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
|
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
|
||||||
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
|
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
|
||||||
@@ -247,7 +275,7 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
release, ok := p.acquireResizeSlot(r.Context())
|
release, ok := p.acquireResizeSlotFor(r.Context(), stat.Size())
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -280,9 +308,13 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
|
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
|
||||||
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
|
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
|
||||||
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍排队缩放。
|
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
|
||||||
const compactImageSkipBytes = 200 * 1024
|
const compactImageSkipBytes = 200 * 1024
|
||||||
|
|
||||||
|
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
|
||||||
|
// 以免把未压缩的剧照直接发给客户端。
|
||||||
|
const resizeQueueBypassBytes = 1536 * 1024
|
||||||
|
|
||||||
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
||||||
// 跳过解码。返回 false 表示仍应走缩放路径。
|
// 跳过解码。返回 false 表示仍应走缩放路径。
|
||||||
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
|
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
|
||||||
@@ -299,3 +301,36 @@ func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
|
|||||||
t.Fatal("expected the original jpeg, not a decoded thumbnail")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Captions,
|
Captions,
|
||||||
CaptionsOff,
|
CaptionsOff,
|
||||||
|
FastForward,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
Maximize,
|
Maximize,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
Pause,
|
Pause,
|
||||||
PictureInPicture,
|
PictureInPicture,
|
||||||
Play,
|
Play,
|
||||||
|
Rewind,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
Volume2,
|
Volume2,
|
||||||
@@ -35,6 +38,24 @@ function formatTime(s: number): string {
|
|||||||
return `${m}:${String(sec).padStart(2, '0')}`
|
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 = {
|
type PlayerControlsProps = {
|
||||||
videoRef: React.RefObject<HTMLVideoElement>
|
videoRef: React.RefObject<HTMLVideoElement>
|
||||||
uiVisible: boolean
|
uiVisible: boolean
|
||||||
@@ -123,6 +144,14 @@ export function PlayerControls({
|
|||||||
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)
|
||||||
|
const applySeekRef = useRef<(absolute: number) => void>(() => undefined)
|
||||||
|
const durationRef = useRef(0)
|
||||||
|
const seekBurstRef = useRef<{ base: number; delta: number } | null>(null)
|
||||||
|
const seekApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const seekHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lastSeekAtRef = useRef(0)
|
||||||
|
const [seekHint, setSeekHint] = useState<SeekHint | null>(null)
|
||||||
|
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
controlsHoveredRef.current = controlsHovered
|
controlsHoveredRef.current = controlsHovered
|
||||||
@@ -144,6 +173,14 @@ export function PlayerControls({
|
|||||||
playlistOpenRef.current = playlistOpen
|
playlistOpenRef.current = playlistOpen
|
||||||
}, [playlistOpen])
|
}, [playlistOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
durationRef.current = duration
|
||||||
|
}, [duration])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStageEl(container())
|
||||||
|
}, [container])
|
||||||
|
|
||||||
// 点击控制栏外部时关闭字幕菜单
|
// 点击控制栏外部时关闭字幕菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subtitleMenuOpen) return
|
if (!subtitleMenuOpen) return
|
||||||
@@ -311,6 +348,7 @@ export function PlayerControls({
|
|||||||
el.currentTime = local
|
el.currentTime = local
|
||||||
setCurrentTime(streamOffset + local)
|
setCurrentTime(streamOffset + local)
|
||||||
}
|
}
|
||||||
|
applySeekRef.current = applyAbsoluteSeek
|
||||||
|
|
||||||
const handleSeekChange = (v: number) => {
|
const handleSeekChange = (v: number) => {
|
||||||
setScrubValue(v)
|
setScrubValue(v)
|
||||||
@@ -321,6 +359,11 @@ export function PlayerControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSeekStart = () => {
|
const handleSeekStart = () => {
|
||||||
|
if (seekApplyTimerRef.current) {
|
||||||
|
clearTimeout(seekApplyTimerRef.current)
|
||||||
|
seekApplyTimerRef.current = null
|
||||||
|
}
|
||||||
|
seekBurstRef.current = null
|
||||||
setIsScrubbing(true)
|
setIsScrubbing(true)
|
||||||
onUiVisibleChange(true)
|
onUiVisibleChange(true)
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
@@ -332,6 +375,102 @@ export function PlayerControls({
|
|||||||
setScrubValue(null)
|
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 changeVolume = (v: number) => {
|
||||||
const el = video()
|
const el = video()
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -380,7 +519,39 @@ export function PlayerControls({
|
|||||||
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
|
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
|
||||||
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
|
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
|
||||||
|
|
||||||
|
const seekOverlay = seekHint && stageEl
|
||||||
|
? createPortal(
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30">
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 flex w-36 -translate-y-1/2 flex-col items-center justify-center rounded-full bg-black/55 px-3 py-5 text-white shadow-lg backdrop-blur-sm ${
|
||||||
|
seekHint.dir === 'back' ? 'left-[8%] sm:left-[12%]' : 'right-[8%] sm:right-[12%]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{seekHint.dir === 'back' ? <Rewind size={28} /> : <FastForward size={28} />}
|
||||||
|
<span className="mt-1 text-center text-sm font-medium leading-tight">
|
||||||
|
{seekHint.seconds > 0
|
||||||
|
? seekHint.dir === 'back'
|
||||||
|
? '回退'
|
||||||
|
: '快进'
|
||||||
|
: seekHint.dir === 'back'
|
||||||
|
? '已到开头'
|
||||||
|
: '已到结尾'}
|
||||||
|
{seekHint.seconds > 0 && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span className="tabular-nums">{seekHint.seconds} 秒</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
stageEl,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{seekOverlay}
|
||||||
<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'
|
||||||
@@ -645,5 +816,6 @@ export function PlayerControls({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -783,7 +783,8 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [nextEpisode, nextEpisodeTitle, playEpisode])
|
}, [nextEpisode, nextEpisodeTitle, playEpisode])
|
||||||
|
|
||||||
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集
|
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集。
|
||||||
|
// 左右方向键的回退/快进在播放器控制栏里处理。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
|
|||||||
Reference in New Issue
Block a user