bug处理+emby客户端兼容

This commit is contained in:
truewhile
2026-09-06 22:10:23 +08:00
parent 394d1a3cda
commit f1b8010496
9 changed files with 668 additions and 55 deletions
+5 -2
View File
@@ -70,8 +70,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
path := c.Request.URL.Path
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
if handler.TryHandleEmbyNormalizedRoute(c, r) {
return
}
path := c.Request.URL.Path
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
return
+30 -1
View File
@@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
c.Query("X-Emby-Client"),
c.Query("X-MediaBrowser-Client"),
auth["Client"],
auth["client"],
),
}
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
@@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string {
return "Emby"
case strings.Contains(lower, "jellyfin"):
return "Jellyfin"
case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
return "CapyPlayer"
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
return "SenPlayer"
case strings.Contains(lower, "yamby"):
return "Yamby"
case strings.Contains(lower, "vidhub"):
return "VidHub"
case strings.Contains(lower, "fileball"):
return "Fileball"
case strings.Contains(lower, "hamhub"):
return "HamHub"
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
return "AfuseKt"
case strings.Contains(lower, "cony"):
return "Cony"
case strings.Contains(lower, "kodi"):
return "Kodi"
case strings.Contains(lower, "mrmc"):
return "MrMC"
case strings.Contains(lower, "forward"):
return "Forward"
case strings.Contains(lower, "alpha"):
return "Alpha"
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
return "DanDanPlay"
case strings.Contains(lower, "potplayer"):
return "PotPlayer"
case strings.Contains(lower, "vlc"):
return "VLC"
case strings.Contains(lower, "hills"):
return "Hills"
default:
@@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string {
return "Mac"
case strings.Contains(lower, "linux"):
return "Linux PC"
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"):
return "Apple TV"
default:
return ""
@@ -0,0 +1,318 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestNormalizeEmbyPath(t *testing.T) {
tests := []struct {
input string
wantPath string
changed bool
}{
{
input: "/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/emby/items/123/playbackInfo",
wantPath: "/emby/items/123/playbackinfo",
changed: true,
},
{
input: "//emby//System//Info//Public",
wantPath: "/emby/system/info/public",
changed: true,
},
{
input: "/Items/msgo-series-1/PlaybackInfo",
wantPath: "/items/msgo-series-1/playbackinfo",
changed: true,
},
{
input: "/Videos/m-123/Master.m3u8",
wantPath: "/videos/m-123/master.m3u8",
changed: true,
},
{
input: "/api/unknown/other",
wantPath: "/api/unknown/other",
changed: false,
},
}
for _, tt := range tests {
gotPath, changed := NormalizeEmbyPath(tt.input)
if gotPath != tt.wantPath || changed != tt.changed {
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
}
}
}
func TestEmbyDuplicatePrefixHandling(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "ServerName") {
t.Fatalf("expected server info body, got: %s", w.Body.String())
}
}
func TestEmbyMixedCaseHandling(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 混合大小写驼峰: /emby/system/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
}
}
func TestEmbyClientIdentification(t *testing.T) {
tests := []struct {
name string
ua string
query string
headerAuth string
wantClient string
}{
{
name: "CapyPlayer via UA",
ua: "CapyPlayer/1.2.0 (iOS)",
wantClient: "CapyPlayer",
},
{
name: "SenPlayer via UA",
ua: "SenPlayer/2.1",
wantClient: "SenPlayer",
},
{
name: "Fileball via UA",
ua: "Fileball/1.0.0",
wantClient: "Fileball",
},
{
name: "Kodi via UA",
ua: "Kodi/20.2",
wantClient: "Kodi",
},
{
name: "Client in query",
ua: "CustomApp/1.0",
query: "?X-Emby-Client=CapyPlayer",
wantClient: "CapyPlayer",
},
{
name: "Client in auth header",
ua: "Custom/1.0",
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
wantClient: "SenPlayer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
url := "/test"
if tt.query != "" {
url += tt.query
}
req := httptest.NewRequest(http.MethodGet, url, nil)
if tt.ua != "" {
req.Header.Set("User-Agent", tt.ua)
}
if tt.headerAuth != "" {
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
}
c.Request = req
info := embyClientInfoFromRequest(c)
if info.Client != tt.wantClient {
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
}
})
}
}
func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, svc)
token := signedTestToken(t, secret)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "m-adaptive-1"},
Title: "测试媒体",
Path: "D:\\media\\test.mkv",
LibraryID: "lib-1",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
// 1. 从 /emby 前缀请求 PlaybackInfo
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req1.Header.Set("X-Emby-Token", token)
w1 := httptest.NewRecorder()
router.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
}
var res1 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
}
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req2.Header.Set("X-Emby-Token", token)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
}
var res2 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
}
// 3. 从根路径 /Items 请求 PlaybackInfo
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
req3.Header.Set("X-Emby-Token", token)
w3 := httptest.NewRecorder()
router.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
}
}
func TestEmbyImageClearNoStore(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("placeholder image should return 200, got %d", w.Code)
}
cacheControl := w.Header().Get("Cache-Control")
if strings.Contains(cacheControl, "no-store") {
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
}
if !strings.Contains(cacheControl, "public") {
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
}
}
+2 -1
View File
@@ -51,13 +51,14 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
}
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
c.Writer.Header().Del("Cache-Control")
c.Writer.Header().Del("Pragma")
c.Writer.Header().Del("Expires")
}
func embyServePlaceholderImage(c *gin.Context) {
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=3600")
c.Header("Cache-Control", "public, max-age=86400")
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
+190
View File
@@ -0,0 +1,190 @@
package handler
import (
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
var (
multipleSlashesRE = regexp.MustCompile(`/{2,}`)
)
// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写),
// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时,
// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。
var embyStaticSegments = map[string]struct{}{
"system": {},
"info": {},
"public": {},
"endpoint": {},
"configuration": {},
"ping": {},
"users": {},
"useritems": {},
"me": {},
"authenticatebyname": {},
"items": {},
"counts": {},
"latest": {},
"resume": {},
"playbackinfo": {},
"shows": {},
"seasons": {},
"episodes": {},
"nextup": {},
"upcoming": {},
"similar": {},
"thumbnailset": {},
"thememedia": {},
"specialfeatures": {},
"intros": {},
"videos": {},
"stream": {},
"subtitles": {},
"master.m3u8": {},
"main.m3u8": {},
"sessions": {},
"playing": {},
"progress": {},
"stopped": {},
"capabilities": {},
"full": {},
"logout": {},
"views": {},
"library": {},
"mediafolders": {},
"virtualfolders": {},
"selectablemediafolders": {},
"branding": {},
"css": {},
"localization": {},
"options": {},
"cultures": {},
"customcssjs": {},
"scripts": {},
"displaypreferences": {},
"quickconnect": {},
"enabled": {},
"startup": {},
"complete": {},
"favoriteitems": {},
"playeditems": {},
"images": {},
"primary": {},
"backdrop": {},
"banner": {},
"thumb": {},
"logo": {},
"serverdomains": {},
"ext": {},
"danmu": {},
"raw": {},
"mediasegments": {},
"artists": {},
"persons": {},
"genres": {},
"embywebsocket": {},
}
// NormalizeEmbyPath 规范化 Emby 请求路径:
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。
func NormalizeEmbyPath(p string) (string, bool) {
if p == "" {
return "", false
}
original := p
// 1. 折叠多余斜杠
p = multipleSlashesRE.ReplaceAllString(p, "/")
// 2. 折叠重复的 /emby 前缀
for {
lower := strings.ToLower(p)
if strings.HasPrefix(lower, "/emby/emby/") {
p = "/emby/" + p[len("/emby/emby/"):]
continue
}
if lower == "/emby/emby" {
p = "/emby"
break
}
break
}
// 3. 分析是否具有 Emby 路由特征
hasEmbyPrefix := false
workPath := p
if strings.HasPrefix(strings.ToLower(workPath), "/emby/") {
hasEmbyPrefix = true
workPath = workPath[len("/emby"):]
} else if strings.EqualFold(workPath, "/emby") {
return "/emby", original != "/emby"
}
segments := strings.Split(strings.Trim(workPath, "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return p, p != original
}
// 检查第一段是否为 Emby 根路由关键字
firstLower := strings.ToLower(segments[0])
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
// 不是 Emby 相关路径,保持原样
return original, false
}
// 4. 将已知静态段转为小写,动态段保留原样
for i, seg := range segments {
segLower := strings.ToLower(seg)
if _, isStatic := embyStaticSegments[segLower]; isStatic {
if seg != segLower {
segments[i] = segLower
}
}
}
var builder strings.Builder
if hasEmbyPrefix {
builder.WriteString("/emby")
}
for _, seg := range segments {
builder.WriteString("/")
builder.WriteString(seg)
}
if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") {
builder.WriteString("/")
}
normalized := builder.String()
return normalized, normalized != original
}
const embyNormalizedCtxKey = "emby_normalized_path"
// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。
// 若成功分发并处理,返回 true;否则返回 false。
func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool {
if c == nil || r == nil {
return false
}
if c.GetBool(embyNormalizedCtxKey) {
return false
}
normalized, changed := NormalizeEmbyPath(c.Request.URL.Path)
if !changed {
return false
}
c.Set(embyNormalizedCtxKey, true)
c.Request.URL.Path = normalized
// 重置 context 状态并由 engine 重新查找路由树
c.Params = nil
c.Writer.Header().Del("Content-Type")
r.HandleContext(c)
return true
}
+8
View File
@@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
}
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
r.NoRoute(func(c *gin.Context) {
if TryHandleEmbyNormalizedRoute(c, r) {
return
}
c.Status(404)
})
}
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
+11 -3
View File
@@ -11,6 +11,10 @@ import (
// SystemInfo returns the full Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any {
port := 8096
if e != nil && e.cfg != nil {
port = e.cfg.App.Port
}
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
@@ -27,10 +31,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
"SupportsLibraryMonitor": true,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"HttpServerPortNumber": e.cfg.App.Port,
"HttpServerPortNumber": port,
"HttpsPortNumber": 0,
"PublishedServerUrl": "",
"WebSocketPortNumber": e.cfg.App.Port,
"WebSocketPortNumber": port,
"CompletedInstallations": []any{},
"CanSelfRestart": false,
"CanLaunchWebBrowser": false,
@@ -40,6 +44,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
func (e *EmbyService) SystemInfoPublic() map[string]any {
port := 8096
if e != nil && e.cfg != nil {
port = e.cfg.App.Port
}
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
@@ -50,7 +58,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
"OperatingSystem": "Windows",
"LocalAddress": "",
"WanAddress": "",
"HttpServerPortNumber": e.cfg.App.Port,
"HttpServerPortNumber": port,
"HttpsPortNumber": 0,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
+1 -1
View File
@@ -137,7 +137,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
}
return (
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
+103 -47
View File
@@ -1,37 +1,24 @@
import { forwardRef, useEffect, useState, type CSSProperties, type ReactNode } from 'react'
import { VirtuosoGrid } from 'react-virtuoso'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { Virtuoso } from 'react-virtuoso'
import clsx from 'clsx'
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
export const MEDIA_GRID_CLASS =
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
// 首帧滚动容器尚未就绪时的占位渲染数量(约一屏)。
const FIRST_PAINT_COUNT = 28
function getFallbackColumns(width: number): number {
if (width >= 1536) return 8
if (width >= 1280) return 7
if (width >= 1024) return 6
if (width >= 768) return 5
if (width >= 640) return 4
return 3
}
const GridList = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridList({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest} className={MEDIA_GRID_CLASS}>
{children}
</div>
)
},
)
const GridItem = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridItem({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest}>
{children}
</div>
)
},
)
// VirtualMediaGrid 大库性能优化:无论库里有 1 千还是 5 万张卡片,
// 实际挂载的只有视口内及邻近一屏的节点。滚动容器是 Layout 的
// <main id="app-main-scroll">(非 window 滚动),通过 customScrollParent 接入;
// 元素尚未挂载时先渲染一屏卡片,挂载后切换为虚拟列表。
// VirtualMediaGrid 大库性能优化:
// 采用按行虚拟滚动(Row-based Virtualization)。相比 VirtuosoGrid 强制要求所有网格项
// 绝对等高且易受 CSS Grid 亚像素尺寸扰动引发死循环闪烁,按行使用基础 Virtuoso 组件
// 天然支持每行真实高度,并且每一行内部保持原生的响应式 CSS Grid 布局。
export function VirtualMediaGrid({
totalCount,
renderItem,
@@ -39,29 +26,98 @@ export function VirtualMediaGrid({
totalCount: number
renderItem: (index: number) => ReactNode
}) {
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const probeRef = useRef<HTMLDivElement>(null)
const [columns, setColumns] = useState(() => {
if (typeof window !== 'undefined') {
return getFallbackColumns(window.innerWidth)
}
return 4
})
useEffect(() => {
setScrollParent(document.getElementById('app-main-scroll'))
const updateColumns = useCallback(() => {
if (probeRef.current) {
const computed = window.getComputedStyle(probeRef.current).gridTemplateColumns
if (computed && computed !== 'none') {
const count = computed.trim().split(/\s+/).filter(Boolean).length
if (count > 0) {
setColumns((prev) => (prev !== count ? count : prev))
return
}
}
}
const width = containerRef.current?.clientWidth || (typeof window !== 'undefined' ? window.innerWidth : 0)
if (width > 0) {
const fallback = getFallbackColumns(width)
setColumns((prev) => (prev !== fallback ? fallback : prev))
}
}, [])
if (!scrollParent) {
return (
<div className={MEDIA_GRID_CLASS}>
{Array.from({ length: Math.min(totalCount, FIRST_PAINT_COUNT) }, (_, index) => (
<div key={index}>{renderItem(index)}</div>
))}
</div>
)
}
useLayoutEffect(() => {
updateColumns()
}, [updateColumns])
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateColumns)
return () => window.removeEventListener('resize', updateColumns)
}
const observer = new ResizeObserver(() => {
updateColumns()
})
observer.observe(el)
return () => observer.disconnect()
}, [updateColumns])
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(() => {
return typeof document !== 'undefined' ? document.getElementById('app-main-scroll') : null
})
useEffect(() => {
if (!scrollParent) {
setScrollParent(document.getElementById('app-main-scroll'))
}
}, [scrollParent])
const rowCount = Math.ceil(totalCount / columns)
return (
<VirtuosoGrid
customScrollParent={scrollParent}
totalCount={totalCount}
overscan={800}
components={{ List: GridList, Item: GridItem }}
itemContent={renderItem}
/>
<div ref={containerRef} className="relative w-full">
{/* 隐藏探针节点:跟随 Tailwind MEDIA_GRID_CLASS 响应式断点自动计算当前列数 */}
<div
ref={probeRef}
className={clsx(MEDIA_GRID_CLASS, 'pointer-events-none invisible absolute h-0 w-full overflow-hidden')}
aria-hidden="true"
/>
{!scrollParent ? (
<div className={MEDIA_GRID_CLASS}>
{Array.from({ length: Math.min(totalCount, columns * 4) }, (_, index) => (
<div key={index}>{renderItem(index)}</div>
))}
</div>
) : (
<Virtuoso
customScrollParent={scrollParent}
totalCount={rowCount}
overscan={800}
itemContent={(rowIndex) => {
const start = rowIndex * columns
return (
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
{Array.from({ length: columns }, (_, colIndex) => {
const itemIndex = start + colIndex
if (itemIndex >= totalCount) {
return <div key={colIndex} aria-hidden="true" />
}
return <div key={itemIndex}>{renderItem(itemIndex)}</div>
})}
</div>
)
}}
/>
)}
</div>
)
}