-- layout/home1.lua
-- ================================================================
-- 首页布局（v6 秒显 · 16:9 卡片 · 零加载文字）
-- 关键改动：
--   1. 卡片高度 = 屏宽 - 左右边距，按 16:9 精确计算
--   2. 移除所有"加载中…"文字，卡片初始即黑，首帧到达直接覆盖
--   3. 三层缓存（内存 → 磁盘 → 后台提取），二次启动零延迟
--   4. 视频用 MediaPlayer 播放，首帧渲染后无缝顶掉截图
-- ================================================================

local buildBanner
local buildNotice
local buildStats
local buildVideoCard
local buildHeader
local renderList
local showLoading
local showLoadingInto
local showSearchDialog
local performSearch
local renderSearchResults
local showDetailDialog

-- ==================== 类绑定 ====================
local _MP_CLASS = nil
local _SURFACE_CLASS = nil
local _TEXTUREVIEW_CLASS = nil
pcall(function() _MP_CLASS = luajava.bindClass("android.media.MediaPlayer") end)
pcall(function() _SURFACE_CLASS = luajava.bindClass("android.view.Surface") end)
pcall(function() _TEXTUREVIEW_CLASS = luajava.bindClass("android.view.TextureView") end)

local MP_INFO_VIDEO_RENDERING_START = 3
local MP_ERROR_IO          = -1004
local MP_ERROR_MALFORMED   = -1007
local MP_ERROR_UNSUPPORTED = -1010
local MP_ERROR_TIMED_OUT   = -110

-- ==================== 视频路径解析 ====================
local function resolveVideoPath(activity, state)
    local function tryFile(path)
        if not path or path == "" then return false, 0 end
        local f = nil
        local ok = pcall(function()
            f = luajava.newInstance("java.io.File", tostring(path))
        end)
        if not ok or not f then return false, 0 end
        local exists, len = false, 0
        pcall(function() exists = f.exists() end)
        pcall(function() len = tonumber(f.length()) or 0 end)
        return exists == true and len > 0, len
    end

    if state and state.customVideoPath and state.customVideoPath ~= "" then
        local ok = tryFile(state.customVideoPath)
        if ok then return state.customVideoPath, "custom" end
    end

    local candidates = {}
    local function add(p)
        if p and p ~= "" then candidates[#candidates + 1] = p end
    end

    local luaDir = nil
    pcall(function() luaDir = activity.getLuaDir() end)
    if type(luaDir) == "string" and luaDir ~= "" then
        add(luaDir .. "/res/mp4/theme1.mp4")
        add(luaDir .. "/mp4/theme1.mp4")
        add(luaDir .. "/res/theme1.mp4")
        add(luaDir .. "/theme1.mp4")
    end

    -- ★ 优化：使用 ctx 传入的 LUA_DIR
    local ctxLuaDir = nil
    pcall(function() ctxLuaDir = _G.LUA_DIR end)
    if type(ctxLuaDir) == "string" and ctxLuaDir ~= "" then
        add(ctxLuaDir .. "/res/mp4/theme1.mp4")
        add(ctxLuaDir .. "/mp4/theme1.mp4")
    end

    add("/storage/emulated/0/LuaAppX_Pro/project/My Application1/res/mp4/theme1.mp4")
    add("/sdcard/LuaAppX_Pro/project/My Application1/res/mp4/theme1.mp4")
    add("/storage/emulated/0/LuaAppX_Pro/project/辉少工具箱/res/mp4/theme1.mp4")
    add("/sdcard/LuaAppX_Pro/project/辉少工具箱/res/mp4/theme1.mp4")
    add("/sdcard/res/mp4/theme1.mp4")
    add("/storage/emulated/0/res/mp4/theme1.mp4")

    for _, p in ipairs(candidates) do
        local ok = tryFile(p)
        if ok then return p, "found" end
    end

    return nil, "not_found", luaDir or "(getLuaDir 返回空)"
end

-- ==================== 通用 inflate ====================
local function inflate(activity, tbl)
    if type(tbl) ~= "table" then return nil end
    local cls = tbl[1]
    if not cls then return nil end
    local ok, v = pcall(function()
        if type(cls) == "string" then return luajava.newInstance(cls, activity)
        else return cls(activity) end
    end)
    if not ok or not v then return nil end
    local density = 1
    pcall(function()
        density = activity.getResources().getDisplayMetrics().density
    end)
    local function d(v2)
        return math.floor((tonumber(v2) or 0) * density + 0.5)
    end
    local children = {}
    for k, val in pairs(tbl) do
        if type(k) == "number" and k > 1 then
            if type(val) == "table" then children[#children + 1] = val end
        elseif type(k) == "string" then
            pcall(function()
                if k == "orientation" then
                    if val == "vertical" then v.setOrientation(1)
                    elseif val == "horizontal" then v.setOrientation(0) end
                elseif k == "gravity" then
                    local g = 0
                    local s = tostring(val)
                    if s:find("center_vertical") then g = g + 0x10
                    elseif s:find("center_horizontal") then g = g + 0x01
                    elseif s:find("center") then g = 0x11 end
                    if s:find("left")  then g = g + 0x03 end
                    if s:find("right") then g = g + 0x05 end
                    if s:find("top")   then g = g + 0x30 end
                    if s:find("bottom")then g = g + 0x50 end
                    if g == 0 then g = 0x11 end
                    v.setGravity(g)
                elseif k == "layout_width" then
                    local lp = v.getLayoutParams() or LinearLayout.LayoutParams(-2, -2)
                    if val == "fill" or val == "match_parent" then lp.width = -1
                    elseif val == "wrap" or val == "wrap_content" then lp.width = -2
                    elseif type(val) == "number" then lp.width = d(val)
                    elseif type(val) == "string" then
                        local n = val:match("^(%d+)dp$")
                        if n then lp.width = d(n) end
                    end
                    v.setLayoutParams(lp)
                elseif k == "layout_height" then
                    local lp = v.getLayoutParams() or LinearLayout.LayoutParams(-2, -2)
                    if val == "fill" or val == "match_parent" then lp.height = -1
                    elseif val == "wrap" or val == "wrap_content" then lp.height = -2
                    elseif type(val) == "number" then lp.height = d(val)
                    elseif type(val) == "string" then
                        local n = val:match("^(%d+)dp$")
                        if n then lp.height = d(n) end
                    end
                    v.setLayoutParams(lp)
                elseif k == "layout_weight" then
                    local lp = v.getLayoutParams() or LinearLayout.LayoutParams(-2, -2)
                    lp.weight = tonumber(val) or 0
                    v.setLayoutParams(lp)
                elseif k == "layout_margin" then
                    local lp = v.getLayoutParams() or LinearLayout.LayoutParams(-2, -2)
                    local m = d(val); lp:setMargins(m, m, m, m); v.setLayoutParams(lp)
                elseif k == "padding" then
                    local m = d(val); v.setPadding(m, m, m, m)
                elseif k == "text" then v.setText(tostring(val))
                elseif k == "textSize" then
                    local n = tostring(val):match("([%d%.]+)")
                    if n then v.setTextSize(tonumber(n)) end
                elseif k == "textColor" then
                    if type(val) == "string" and val:match("^#") then
                        local hex = tonumber(val:gsub("^#", ""), 16)
                        if hex then v.setTextColor(hex) end
                    else v.setTextColor(val) end
                elseif k == "textStyle" then
                    if val == "bold" then v.setTypeface(Typeface.DEFAULT_BOLD) end
                elseif k == "background" then
                    if type(val) == "string" and val:match("^#") then
                        local hex = tonumber(val:gsub("^#", ""), 16)
                        if hex then v.setBackgroundColor(hex) end
                    else v.setBackground(val) end
                elseif k == "singleLine" then
                    if val then v.setSingleLine(true) end
                elseif k == "maxLines" then v.setMaxLines(tonumber(val) or 1)
                elseif k == "ellipsize" then
                    local s = tostring(val)
                    if s == "end" then v.setEllipsize(TextUtils.TruncateAt.END)
                    elseif s == "middle" then v.setEllipsize(TextUtils.TruncateAt.MIDDLE) end
                elseif k == "scaleType" then
                    local s = tostring(val)
                    if s == "fitCenter" then v.setScaleType(ImageView.ScaleType.FIT_CENTER)
                    elseif s == "centerCrop" then v.setScaleType(ImageView.ScaleType.CENTER_CROP) end
                end
            end)
        end
    end
    for _, childTbl in ipairs(children) do
        local cv = inflate(activity, childTbl)
        if cv and v.addView then pcall(function() v.addView(cv) end) end
    end
    return v
end

-- ==================== buildHeader ====================
buildHeader = function(ctx)
    local activity = ctx.activity
    local dp = ctx.dp
    local C = ctx.getColors()
    local state = ctx.state

    if not state.cachedGreeting then state.cachedGreeting = ctx.getGreeting() end
    if not state.cachedSubtitle then state.cachedSubtitle = ctx.getRandomSubtitle() end

    local wrap = LinearLayout(activity)
    wrap.setOrientation(LinearLayout.VERTICAL)
    wrap.setLayoutParams(LinearLayout.LayoutParams(-1, -2))

    local hiTv = TextView(activity)
    hiTv.setText(state.cachedGreeting)
    hiTv.setTextColor(C.TEXT)
    hiTv.setTextSize(26)
    ctx.setTextBold(hiTv, true)
    wrap.addView(hiTv)

    local subTv = TextView(activity)
    subTv.setText(state.cachedSubtitle)
    subTv.setTextColor(C.TEXT_SUB)
    subTv.setTextSize(12)
    local subLp = LinearLayout.LayoutParams(-1, -2)
    subLp.setMargins(0, dp(6), 0, dp(14))
    subTv.setLayoutParams(subLp)
    wrap.addView(subTv)

    local search = LinearLayout(activity)
    search.setOrientation(LinearLayout.HORIZONTAL)
    search.setGravity(Gravity.CENTER_VERTICAL)
    search.setBackground(ctx.cachedRoundedBg(C.CARD, 22))
    search.setPadding(dp(16), dp(12), dp(16), dp(12))
    local searchLp = LinearLayout.LayoutParams(-1, -2)
    searchLp.setMargins(0, 0, 0, dp(4))
    search.setLayoutParams(searchLp)

    local ssBmp = nil
    pcall(function() ssBmp = ctx.loadLocalBitmap("ss.png") end)
    if ssBmp then
        local iconIv = ImageView(activity)
        local ssz = dp(16)
        local siLp = LinearLayout.LayoutParams(ssz, ssz)
        siLp.setMargins(0, 0, dp(10), 0)
        siLp.gravity = Gravity.CENTER_VERTICAL
        iconIv.setLayoutParams(siLp)
        iconIv.setScaleType(ImageView.ScaleType.FIT_CENTER)
        pcall(function()
            if not ssBmp.isRecycled() then iconIv.setImageBitmap(ssBmp) end
        end)
        search.addView(iconIv)
    else
        local iconTv = TextView(activity)
        iconTv.setText("🔍")
        iconTv.setTextSize(14)
        local siLp = LinearLayout.LayoutParams(-2, -2)
        siLp.setMargins(0, 0, dp(10), 0)
        iconTv.setLayoutParams(siLp)
        search.addView(iconTv)
    end

    local hint = TextView(activity)
    hint.setText("搜索你感兴趣的")
    hint.setTextColor(C.TEXT_SUB)
    hint.setTextSize(13)
    search.addView(hint)

    ctx.setOnClick(search, function() showSearchDialog(ctx) end)
    wrap.addView(search)
    return wrap
end

-- ================================================================
-- buildVideoCard —— 16:9 秒显卡片
-- ================================================================
buildVideoCard = function(ctx)
    if not _MP_CLASS or not _SURFACE_CLASS or not _TEXTUREVIEW_CLASS then
        return nil
    end

    local LOG_TAG = "HomeVideo"
    local function log(msg)
        pcall(function()
            luajava.bindClass("android.util.Log").i(LOG_TAG, tostring(msg))
        end)
    end

    -- ============ 全局帧缓存 ============
    if not _G.__homeVideoFrameMap then _G.__homeVideoFrameMap = {} end

    local function simpleHash(s)
        local h = 5381
        for i = 1, #s do h = (h * 33 + s:byte(i)) % 2147483647 end
        return tostring(h)
    end

    local function getFrameFromMemory(cacheKey)
        return _G.__homeVideoFrameMap[cacheKey]
    end

    local function setFrameToMemory(cacheKey, bmp)
        for k, v in pairs(_G.__homeVideoFrameMap) do
            if k ~= cacheKey then
                pcall(function()
                    if v and not v.isRecycled() then v.recycle() end
                end)
                _G.__homeVideoFrameMap[k] = nil
            end
        end
        _G.__homeVideoFrameMap[cacheKey] = bmp
    end

    local function getCacheFilePath(activity, cacheKey)
        local p = nil
        pcall(function()
            local dir = activity.getCacheDir().getAbsolutePath()
            p = tostring(dir) .. "/home_video_" .. simpleHash(cacheKey) .. ".jpg"
        end)
        return p
    end

    local function loadFrameFromDisk(activity, cacheKey)
        local bmp = nil
        pcall(function()
            local p = getCacheFilePath(activity, cacheKey)
            if p then
                local f = luajava.newInstance("java.io.File", p)
                if f.exists() and tonumber(f.length()) > 0 then
                    bmp = android.graphics.BitmapFactory.decodeFile(p)
                end
            end
        end)
        return bmp
    end

    local function saveFrameToDisk(activity, cacheKey, bmp)
        pcall(function()
            local p = getCacheFilePath(activity, cacheKey)
            if p and bmp and not bmp.isRecycled() then
                local fos = luajava.newInstance("java.io.FileOutputStream", p)
                bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, fos)
                fos.flush()
                fos.close()
            end
        end)
    end

    local function extractFrame(videoPath, targetW)
        local bmp = nil
        pcall(function()
            local retriever = luajava.newInstance("android.media.MediaMetadataRetriever")
            retriever.setDataSource(videoPath)
            bmp = retriever.getFrameAtTime(0, 2) -- OPTION_CLOSEST_SYNC
            pcall(function() retriever.release() end)
        end)
        if bmp and targetW and targetW > 0 then
            local w = bmp.getWidth()
            if w > targetW then
                local ratio = targetW / w
                local newW = math.floor(targetW)
                local newH = math.floor(bmp.getHeight() * ratio)
                if newH < 1 then newH = 1 end
                local scaled = nil
                pcall(function()
                    scaled = android.graphics.Bitmap.createScaledBitmap(
                        bmp, newW, newH, true)
                end)
                if scaled then
                    pcall(function() bmp.recycle() end)
                    bmp = scaled
                end
            end
        end
        return bmp
    end

    -- ============ UI 构建 ============
    local okAll, result = pcall(function()
        local activity = ctx.activity
        local dp = ctx.dp
        local state = ctx.state

        local wrapper = LinearLayout(activity)
        wrapper.setOrientation(LinearLayout.VERTICAL)
        wrapper.setGravity(Gravity.CENTER)
        local wLp = LinearLayout.LayoutParams(-1, -2)
        wLp.setMargins(0, dp(12), 0, dp(8))
        wrapper.setLayoutParams(wLp)

        -- ---- 计算 16:9 高度 ----
        local metrics = activity.getResources().getDisplayMetrics()
        local screenW = metrics.widthPixels
        local sideMargin = dp(36)  -- 左右各 18dp
        local cardW = screenW - sideMargin
        if cardW < 1 then cardW = screenW end
        -- 根据视频实际宽高比计算
        local cardH = math.floor(cardW * 9 / 16 + 0.5)

        -- CardView
        local card = nil
        local okCard = pcall(function()
            card = luajava.newInstance("androidx.cardview.widget.CardView", activity)
        end)
        if not okCard or not card then card = FrameLayout(activity) end
        card.setLayoutParams(LinearLayout.LayoutParams(-1, cardH))
        pcall(function()
            card.setRadius(dp(20))
            card.setCardElevation(dp(8))
            card.setCardBackgroundColor(0xff000000)
            card.setClipToPadding(true)
            card.setPreventCornerOverlap(false)
        end)

        -- 底层：TextureView
        local textureView = nil
        local okTv = pcall(function() textureView = _TEXTUREVIEW_CLASS(activity) end)
        if not okTv or not textureView then
            log("✗ TextureView 创建失败")
            wrapper.addView(card)
            return wrapper
        end
        textureView.setLayoutParams(FrameLayout.LayoutParams(-1, -1))
        textureView.setOpaque(true)
        card.addView(textureView)

        -- 中层：第一帧 ImageView（初始隐藏，加载完立即显示）
        local frameIv = ImageView(activity)
        frameIv.setScaleType(ImageView.ScaleType.FIT_CENTER)
        frameIv.setBackgroundColor(0xff000000)
        frameIv.setLayoutParams(FrameLayout.LayoutParams(-1, -1))
        frameIv.setVisibility(View.GONE)
        card.addView(frameIv)

        -- 顶层：错误提示（默认隐藏）
        local diagTv = TextView(activity)
        diagTv.setTextColor(0xffff5555)
        diagTv.setTextSize(11)
        diagTv.setGravity(Gravity.CENTER)
        diagTv.setLineSpacing(dp(4), 1.0)
        local dLp = FrameLayout.LayoutParams(-1, -1)
        dLp.gravity = Gravity.CENTER
        diagTv.setLayoutParams(dLp)
        diagTv.setPadding(dp(16), dp(16), dp(16), dp(16))
        diagTv.setVisibility(View.GONE)
        card.addView(diagTv)

        wrapper.addView(card)
        pcall(function() _G.homeCardVideo = textureView end)

        local function showDiag(text)
            pcall(function()
                diagTv.setText(text)
                diagTv.setVisibility(View.VISIBLE)
            end)
        end

        -- ============ 路径解析 ============
        local videoPath, pathTag, diag = resolveVideoPath(activity, state)
        if not videoPath then
            log("✗ 找不到视频 (getLuaDir=" .. tostring(diag) .. ")")
            -- ★ 修复：找不到视频时，不显示错误，直接隐藏卡片
            wrapper.setVisibility(View.GONE)
            return wrapper
        end
        log("✓ 找到视频(" .. tostring(pathTag) .. "): " .. videoPath)

        local fileLen = 0
        pcall(function()
            local f = luajava.newInstance("java.io.File", videoPath)
            fileLen = tonumber(f.length()) or 0
        end)

        -- ============ 第一帧：三级缓存 ============
        local cacheKey = videoPath .. "#" .. tostring(fileLen)

        local memoryBmp = getFrameFromMemory(cacheKey)
        if memoryBmp and not memoryBmp.isRecycled() then
            pcall(function() frameIv.setImageBitmap(memoryBmp) end)
            frameIv.setVisibility(View.VISIBLE)
            log("✓ 首帧：内存命中")
        else
            local diskBmp = loadFrameFromDisk(activity, cacheKey)
            if diskBmp and not diskBmp.isRecycled() then
                setFrameToMemory(cacheKey, diskBmp)
                pcall(function() frameIv.setImageBitmap(diskBmp) end)
                frameIv.setVisibility(View.VISIBLE)
                log("✓ 首帧：磁盘命中")
            else
                -- 只有首次 App 启动会走到这里
                local targetW = cardW
                ctx.runAsync(function()
                    local bmp = extractFrame(videoPath, targetW)
                    if bmp then
                        setFrameToMemory(cacheKey, bmp)
                        saveFrameToDisk(activity, cacheKey, bmp)
                        ctx.runOnUiSafely(function()
                            pcall(function()
                                if not bmp.isRecycled() then
                                    frameIv.setImageBitmap(bmp)
                                end
                                frameIv.setVisibility(View.VISIBLE)
                            end)
                        end)
                        log("✓ 首帧：提取完成")
                    end
                end)
            end
        end

        -- ============ MediaPlayer ============
        local mediaPlayer = nil
        local okMp = pcall(function() mediaPlayer = _MP_CLASS() end)
        if not okMp or not mediaPlayer then
            log("✗ MediaPlayer 初始化失败")
            return wrapper
        end

        local prepared    = false
        local shouldPlay  = true
        local released    = false
        local currentSurface = nil

        local function safeRelease()
            if released then return end
            released = true
            pcall(function()
                if mediaPlayer and mediaPlayer.isPlaying() then mediaPlayer.stop() end
            end)
            pcall(function() if mediaPlayer then mediaPlayer.release() end end)
            mediaPlayer = nil
        end

        pcall(function()
            mediaPlayer.setOnPreparedListener(luajava.createProxy(
                "android.media.MediaPlayer$OnPreparedListener", {
                onPrepared = function(mp)
                    prepared = true
                    pcall(function()
                        mp.setLooping(true)
                        mp.setVolume(0, 0)
                        mp.setVideoScalingMode(1)
                    end)
                    if currentSurface and shouldPlay then
                        pcall(function()
                            if not mp.isPlaying() then mp.start() end
                        end)
                    end
                end
            }))
        end)

        pcall(function()
            mediaPlayer.setOnErrorListener(luajava.createProxy(
                "android.media.MediaPlayer$OnErrorListener", {
                onError = function(mp, what, extra)
                    local human = "未知错误"
                    if what == 1 then
                        if extra == MP_ERROR_IO then human = "IO 错误 / 无权限"
                        elseif extra == MP_ERROR_MALFORMED then human = "文件损坏或非标准 MP4"
                        elseif extra == MP_ERROR_UNSUPPORTED then human = "编码不支持（建议 H.264）"
                        elseif extra == MP_ERROR_TIMED_OUT then human = "读取超时"
                        else human = "MediaPlayer 错误 " .. tostring(extra) end
                    end
                    showDiag("⚠ 视频无法播放\n" .. human)
                    return true
                end
            }))
        end)

        pcall(function()
            mediaPlayer.setOnInfoListener(luajava.createProxy(
                "android.media.MediaPlayer$OnInfoListener", {
                onInfo = function(mp, what, extra)
                    if what == MP_INFO_VIDEO_RENDERING_START then
                        pcall(function() frameIv.setVisibility(View.GONE) end)
                        log("✓✓ 首帧渲染完成，切换至视频")
                    end
                    return false
                end
            }))
        end)

        pcall(function()
            mediaPlayer.setOnCompletionListener(luajava.createProxy(
                "android.media.MediaPlayer$OnCompletionListener", {
                onCompletion = function(mp)
                    pcall(function()
                        if shouldPlay and not released then
                            mp.seekTo(0); mp.start()
                        end
                    end)
                end
            }))
        end)

        local function bindSurface(st)
            if not st or not mediaPlayer or released then return end
            local ok = pcall(function()
                if not currentSurface then currentSurface = _SURFACE_CLASS(st) end
                mediaPlayer.setSurface(currentSurface)
            end)
            if not ok then return end
            if prepared and shouldPlay then
                pcall(function()
                    if not mediaPlayer.isPlaying() then
                        mediaPlayer.start()
                    else
                        mediaPlayer.setSurface(currentSurface)
                    end
                end)
            end
        end

        pcall(function()
            textureView.setSurfaceTextureListener(luajava.createProxy(
                "android.view.TextureView$SurfaceTextureListener", {
                onSurfaceTextureAvailable = function(st, w, h)
                    bindSurface(st)
                end,
                onSurfaceTextureSizeChanged = function(st, w, h) end,
                onSurfaceTextureDestroyed = function(st)
                    pcall(function()
                        if mediaPlayer and not released then mediaPlayer.setSurface(nil) end
                        if currentSurface then
                            pcall(function() currentSurface.release() end)
                            currentSurface = nil
                        end
                    end)
                    return true
                end,
                onSurfaceTextureUpdated = function(st) end,
            }))
        end)

        local okDs = pcall(function() mediaPlayer.setDataSource(videoPath) end)
        if not okDs then
            okDs = pcall(function() mediaPlayer.setDataSource("file://" .. videoPath) end)
            if not okDs then
                showDiag("⚠ 无法打开视频文件")
                safeRelease()
                return wrapper
            end
        end

        pcall(function()
            local st = textureView.getSurfaceTexture()
            if st then bindSurface(st) end
        end)

        pcall(function() mediaPlayer.prepareAsync() end)
        _G.__预热视频播放器 = mediaPlayer

        pcall(function()
            wrapper.addOnAttachStateChangeListener(luajava.createProxy(
                "android.view.View$OnAttachStateChangeListener", {
                onViewAttachedToWindow = function(v)
                    shouldPlay = true
                    pcall(function()
                        if mediaPlayer and prepared and not released
                            and not mediaPlayer.isPlaying() then
                            mediaPlayer.start()
                        end
                    end)
                end,
                onViewDetachedFromWindow = function(v)
                    shouldPlay = false
                    pcall(function()
                        if mediaPlayer and not released
                            and mediaPlayer.isPlaying() then
                            mediaPlayer.pause()
                        end
                    end)
                end
            }))
        end)

        ctx.slideUpFadeIn(wrapper, 320, 40)
        return wrapper
    end)

    if not okAll then
        log("✗ buildVideoCard 整体异常: " .. tostring(result))
        return nil
    end
    return result
end

-- ==================== buildBanner ====================
buildBanner = function(ctx)
    local activity = ctx.activity
    local dp = ctx.dp
    local C = ctx.getColors()
    local state = ctx.state

    local bannerData = state.bannerData
    if not bannerData or #bannerData == 0 then return nil end

    if state.currentBannerTask then
        state.currentBannerTask.running = false
        if state.currentBannerTask.handler then
            pcall(function()
                state.currentBannerTask.handler.removeCallbacksAndMessages(nil)
            end)
        end
        state.currentBannerTask = nil
    end

    local count = #bannerData
    local localBannerData = bannerData

    local frame = FrameLayout(activity)
    local frameLp = LinearLayout.LayoutParams(-1, dp(180))
    frameLp.setMargins(0, dp(6), 0, dp(14))
    frame.setLayoutParams(frameLp)
    frame.setMinimumHeight(dp(180))

    local pager = HorizontalScrollView(activity)
    pager.setHorizontalScrollBarEnabled(false)
    pager.setOverScrollMode(View.OVER_SCROLL_NEVER)
    pager.setLayoutParams(FrameLayout.LayoutParams(-1, -1))

    local strip = LinearLayout(activity)
    strip.setOrientation(LinearLayout.HORIZONTAL)
    pager.addView(strip, FrameLayout.LayoutParams(-2, -1))

    local fallbackPageW = math.max(dp(200), ctx.SCREEN_WIDTH_PX - dp(36))
    local pages = {}
    local pageImgs = {}

    for idx, b in ipairs(localBannerData) do
        local page = FrameLayout(activity)
        page.setLayoutParams(LinearLayout.LayoutParams(fallbackPageW, -1))
        local img = ImageView(activity)
        img.setScaleType(ImageView.ScaleType.CENTER_CROP)
        img.setLayoutParams(FrameLayout.LayoutParams(-1, -1))
        img.setBackground(ctx.cachedRoundedBg(C.ACCENT_SOFT_BG, 14))
        page.addView(img)
        pageImgs[idx] = img

        if b.title and b.title ~= "" then
            local titleBg = TextView(activity)
            titleBg.setText(b.title)
            titleBg.setTextColor(0xffffffff)
            titleBg.setTextSize(12)
            titleBg.setPadding(dp(12), dp(6), dp(12), dp(6))
            titleBg.setBackground(ctx.cachedRoundedBg(0x99000000, 10))
            local tLp = FrameLayout.LayoutParams(-2, -2)
            tLp.gravity = Gravity.BOTTOM
            tLp.leftMargin = dp(10)
            tLp.bottomMargin = dp(10)
            titleBg.setLayoutParams(tLp)
            page.addView(titleBg)
        end
        if b.url and b.url:find("^http") then
            ctx.setOnClick(page, function() ctx.openExternalUrl(b.url) end)
        end
        strip.addView(page)
        pages[#pages + 1] = page
    end
    frame.addView(pager)

    local indicator = LinearLayout(activity)
    indicator.setOrientation(LinearLayout.HORIZONTAL)
    local iLp = FrameLayout.LayoutParams(-2, -2)
    iLp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL
    iLp.bottomMargin = dp(8)
    indicator.setLayoutParams(iLp)

    local activeDotBg   = ctx.cachedRoundedBg(0xffffffff, 3)
    local inactiveDotBg = ctx.cachedRoundedBg(0x66ffffff, 3)
    local dots = {}
    for i = 1, count do
        local dot = View(activity)
        local dLp = LinearLayout.LayoutParams(dp(6), dp(6))
        dLp.setMargins(dp(3), 0, dp(3), 0)
        dot.setLayoutParams(dLp)
        dot.setBackground(inactiveDotBg)
        indicator.addView(dot)
        dots[i] = dot
    end
    frame.addView(indicator)

    local currentIdx = 1
    local function updateDots(idx)
        if idx == currentIdx then return end
        if dots[currentIdx] then dots[currentIdx].setBackground(inactiveDotBg) end
        if dots[idx] then dots[idx].setBackground(activeDotBg) end
        currentIdx = idx
    end
    if dots[1] then dots[1].setBackground(activeDotBg) end

    local function getPageW()
        local w = 0
        pcall(function() w = pager.getWidth() end)
        if w <= 0 then w = fallbackPageW end
        return w
    end

    local loadedFlags = {}
    local pendingUrls = {}
    local MAX_SLOT_RETRY = 20

    local function doDownload(idx, url, imgRef, retryCount)
        retryCount = retryCount or 0
        if not ctx.acquireImgSlot() then
            if retryCount >= MAX_SLOT_RETRY then
                loadedFlags[idx] = "failed"; pendingUrls[url] = nil; return
            end
            ctx.postDelayed(function()
                if not ctx.isActivityAlive() then
                    loadedFlags[idx] = "failed"; pendingUrls[url] = nil; return
                end
                if loadedFlags[idx] == "loading" then
                    doDownload(idx, url, imgRef, retryCount + 1)
                end
            end, 300)
            return
        end

        local scheduled = ctx.runAsync(function()
            local bmp = nil
            for attempt = 1, 2 do
                if not ctx.isActivityAlive() then break end
                local ok = pcall(function()
                    local conn = ctx.openConn(url, "GET", nil,
                        ctx.CONNECT_TIMEOUT_MS, ctx.READ_TIMEOUT_MS)
                    conn.setRequestProperty("Accept-Encoding", "identity")
                    local code = conn.getResponseCode()
                    if code ~= 200 then error("HTTP " .. code) end
                    local ins = conn.getInputStream()
                    local baos = ctx.newBAOS()
                    if not baos then error("BAOS 不可用") end
                    local ByteType = luajava.bindClass("java.lang.Byte").TYPE
                    local buf = luajava.newArray(ByteType, 8192)
                    while true do
                        local n = ins.read(buf, 0, 8192)
                        if n == -1 then break end
                        if n > 0 then baos.write(buf, 0, n) end
                    end
                    pcall(function() ins.close() end)
                    pcall(function() conn.disconnect() end)
                    local bytes = baos.toByteArray()
                    local byteLen = ctx.byteArrayLen(bytes)
                    if byteLen == 0 then error("空响应") end
                    local opts = android.graphics.BitmapFactory.Options()
                    opts.inJustDecodeBounds = true
                    android.graphics.BitmapFactory.decodeByteArray(bytes, 0, byteLen, opts)
                    if not opts.outWidth or opts.outWidth <= 0 then error("不是有效图片") end
                    local targetW = ctx.SCREEN_WIDTH_PX - dp(36)
                    if targetW <= 0 then targetW = dp(360) end
                    local sample = 1
                    if opts.outWidth > targetW then
                        sample = math.floor(opts.outWidth / targetW)
                        if sample < 1 then sample = 1 end
                    end
                    local opts2 = android.graphics.BitmapFactory.Options()
                    opts2.inSampleSize = sample
                    opts2.inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
                    bmp = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, byteLen, opts2)
                    if not bmp then error("decodeByteArray 返回 nil") end
                end)
                if ok and bmp then break end
                pcall(function() Thread.sleep(800) end)
            end
            ctx.releaseImgSlot()
            if bmp then
                ctx.cachePutBmp(url, bmp)
                ctx.runOnUiSafely(function()
                    pcall(function()
                        if imgRef and not bmp.isRecycled() then
                            imgRef.setImageBitmap(bmp)
                        end
                    end)
                end)
                loadedFlags[idx] = true
            else
                loadedFlags[idx] = "failed"
            end
            pendingUrls[url] = nil
        end)

        if not scheduled then
            ctx.releaseImgSlot()
            loadedFlags[idx] = "failed"
            pendingUrls[url] = nil
        end
    end

    local function loadBannerImage(idx)
        if idx < 1 or idx > count then return end
        if loadedFlags[idx] == true or loadedFlags[idx] == "loading" then return end
        local b = localBannerData[idx]
        if not b then return end
        local url = b.img
        local imgRef = pageImgs[idx]
        if not imgRef or pendingUrls[url] then return end

        local cachedBmp = ctx.cacheGetBmp(url)
        if cachedBmp then
            pcall(function()
                if not cachedBmp.isRecycled() then
                    imgRef.setImageBitmap(cachedBmp)
                end
            end)
            loadedFlags[idx] = true
            return
        end
        loadedFlags[idx] = "loading"
        pendingUrls[url] = true
        doDownload(idx, url, imgRef, 0)
    end

    loadBannerImage(1)
    if count >= 2 then loadBannerImage(2) end

    pcall(function()
        pager.setOnScrollChangeListener(luajava.createProxy(
            "android.view.View$OnScrollChangeListener", {
            onScrollChange = function(v, sx, sy, osx, osy)
                local pw = getPageW()
                if pw <= 0 then return end
                local idx = math.floor((sx + pw / 2) / pw) + 1
                if idx < 1 then idx = 1 end
                if idx > count then idx = count end
                updateDots(idx)
                loadBannerImage(idx)
                if idx > 1 then loadBannerImage(idx - 1) end
                if idx < count then loadBannerImage(idx + 1) end
            end
        }))
    end)

    ctx.postDelayed(function()
        pcall(function() pager.scrollTo(0, 0) end)
    end, 60)

    local autoTask = { running = true, handler = nil }
    state.currentBannerTask = autoTask
    pcall(function() autoTask.handler = Handler(Looper.getMainLooper()) end)
    if not autoTask.handler then
        pcall(function() autoTask.handler = Handler() end)
    end

    do
        local tasks = state.ACTIVE_BANNER_TASKS
        while #tasks >= 4 do
            local old = table.remove(tasks, 1)
            if old then
                old.running = false
                if old.handler then
                    pcall(function() old.handler.removeCallbacksAndMessages(nil) end)
                end
            end
        end
        tasks[#tasks + 1] = autoTask
    end

    local function scheduleNext()
        if not autoTask.running then return end
        pcall(function()
            autoTask.handler.postDelayed(ctx.makeRunnable(function()
                if not autoTask.running then return end
                if not ctx.isActivityAlive() then autoTask.running = false; return end
                if state.currentTab ~= ctx.TAB_HOME or state.searchQuery ~= nil then
                    scheduleNext(); return
                end
                if state.isScrolling then scheduleNext(); return end
                local next = currentIdx + 1
                if next > count then next = 1 end
                local pw = getPageW()
                if pw > 0 and pages[next] then
                    pcall(function() pager.smoothScrollTo((next - 1) * pw, 0) end)
                    updateDots(next)
                    loadBannerImage(next)
                end
                scheduleNext()
            end), ctx.BANNER_INTERVAL_MS)
        end)
    end
    scheduleNext()

    pcall(function()
        frame.addOnAttachStateChangeListener(luajava.createProxy(
            "android.view.View$OnAttachStateChangeListener", {
            onViewAttachedToWindow = function(v) end,
            onViewDetachedFromWindow = function(v)
                autoTask.running = false
                if autoTask.handler then
                    pcall(function()
                        autoTask.handler.removeCallbacksAndMessages(nil)
                    end)
                end
                if state.currentBannerTask == autoTask then
                    state.currentBannerTask = nil
                end
                local tasks = state.ACTIVE_BANNER_TASKS
                for i = #tasks, 1, -1 do
                    if tasks[i] == autoTask then table.remove(tasks, i) end
                end
            end
        }))
    end)

    ctx.slideUpFadeIn(frame, 320, 0)
    return frame
end

-- ==================== buildNotice ====================
buildNotice = function(ctx)
    local activity = ctx.activity
    local dp = ctx.dp
    local C = ctx.getColors()
    local state = ctx.state
    local noticeData = state.noticeData
    if not noticeData or not noticeData.text or noticeData.text == "" then return nil end

    local bar = LinearLayout(activity)
    bar.setOrientation(LinearLayout.HORIZONTAL)
    bar.setGravity(Gravity.CENTER_VERTICAL)
    bar.setBackground(ctx.cachedRoundedBg(C.CARD, 14))
    bar.setPadding(dp(14), dp(12), dp(14), dp(12))
    local barLp = LinearLayout.LayoutParams(-1, -2)
    barLp.setMargins(0, dp(14), 0, dp(18))
    bar.setLayoutParams(barLp)

    local tag = TextView(activity)
    tag.setText("公告")
    tag.setTextColor(0xffffffff)
    tag.setTextSize(11)
    tag.setPadding(dp(8), dp(3), dp(8), dp(3))
    tag.setBackground(ctx.cachedRoundedBg(C.ACCENT, 8))
    local tagLp = LinearLayout.LayoutParams(-2, -2)
    tagLp.setMargins(0, 0, dp(10), 0)
    tag.setLayoutParams(tagLp)
    bar.addView(tag)

    local txt = TextView(activity)
    txt.setText(noticeData.text)
    txt.setTextColor(C.TEXT)
    txt.setTextSize(13)
    txt.setMaxLines(1)
    txt.setSingleLine(true)
    txt.setEllipsize(TextUtils.TruncateAt.END)
    txt.setLayoutParams(LinearLayout.LayoutParams(0, -2, 1.0))
    bar.addView(txt)

    if noticeData.link and noticeData.link ~= "" then
        ctx.setOnClick(bar, function() ctx.openExternalUrl(noticeData.link) end)
    end
    ctx.slideUpFadeIn(bar, 280, 60)
    return bar
end

-- ==================== buildStats ====================
buildStats = function(ctx)
    local activity = ctx.activity
    local dp = ctx.dp
    local C = ctx.getColors()
    local state = ctx.state

    local filesCount = 0
    for _, catId in ipairs(ctx.HOME_LATEST_CATS) do
        local list = state.dataCache[catId]
        if list then filesCount = filesCount + #list end
    end
    local ziyuanCount = 0
    local zl = state.dataCache["ziyuan"]
    if zl then ziyuanCount = #zl end

    local row = LinearLayout(activity)
    row.setOrientation(LinearLayout.HORIZONTAL)
    local rowLp = LinearLayout.LayoutParams(-1, -2)
    rowLp.setMargins(0, 0, 0, dp(18))
    row.setLayoutParams(rowLp)

    local function makeStatCard(num, label, accentColor)
        local card = LinearLayout(activity)
        card.setOrientation(LinearLayout.HORIZONTAL)
        card.setGravity(Gravity.CENTER_VERTICAL)
        card.setBackground(ctx.cachedRoundedBgStroke(C.CARD, C.CARD_STROKE, 14, 1))
        card.setPadding(dp(16), dp(16), dp(16), dp(16))
        local lp = LinearLayout.LayoutParams(0, -2, 1.0)
        lp.setMargins(dp(5), 0, dp(5), 0)
        card.setLayoutParams(lp)

        local numTv = TextView(activity)
        numTv.setText(tostring(num))
        numTv.setTextColor(accentColor or C.ACCENT)
        numTv.setTextSize(16)
        ctx.setTextBold(numTv, true)
        pcall(function() numTv.setIncludeFontPadding(false) end)
        numTv.setLayoutParams(LinearLayout.LayoutParams(0, -2, 1.0))
        card.addView(numTv)

        local labelTv = TextView(activity)
        labelTv.setText(label)
        labelTv.setTextColor(C.TEXT_SUB)
        labelTv.setTextSize(12)
        pcall(function() labelTv.setIncludeFontPadding(false) end)
        local labelLp = LinearLayout.LayoutParams(0, -2, 1.0)
        labelLp.leftMargin = dp(8)
        labelTv.setLayoutParams(labelLp)
        card.addView(labelTv)
        return card
    end

    row.addView(makeStatCard(filesCount, "文件总数", C.ACCENT))
    row.addView(makeStatCard(ziyuanCount, "资源总数", 0xffaf52de))
    ctx.slideUpFadeIn(row, 280, 90)
    return row
end

-- ==================== renderList ====================
renderList = function(ctx, items)
    local contentLayout = ctx.contentLayout
    if not contentLayout then return end
    ctx.recycleAllCards()
    contentLayout.removeAllViews()

    local h = buildHeader(ctx)
    if h then contentLayout.addView(h) end

    local video = buildVideoCard(ctx)
    if video then contentLayout.addView(video) end

    local banner = buildBanner(ctx)
    if banner then contentLayout.addView(banner) end

    local notice = buildNotice(ctx)
    if notice then contentLayout.addView(notice) end

    local stats = buildStats(ctx)
    if stats then contentLayout.addView(stats) end

    local sec = ctx.makeSectionTitle("最近更新")
    contentLayout.addView(sec)
    ctx.fadeInView(sec, 240, 0)

    if not items or #items == 0 then return end
    local maxCount = #items
    if maxCount > ctx.HOME_MAX_ITEMS then maxCount = ctx.HOME_MAX_ITEMS end

    for i = 1, maxCount do
        local item = items[i]
        local rawName = (item.name or "未命名"):gsub("%.zip$", "")
        local prefix = item.pinned and "📌 " or ""
        local sub = tostring(item.time or "")
        if item.today then sub = "今日 · " .. sub end
        if item.__catId then sub = sub .. " · " .. ctx.findCatName(item.__catId) end
        local card = ctx.makeCard(prefix .. rawName, sub, function()
            showDetailDialog(ctx, item)
        end)
        contentLayout.addView(card)
        ctx.slideUpFadeIn(card, 240, math.min(150 + i * 30, 600))
    end
end

-- ==================== showLoading ====================
showLoadingInto = function(ctx, container, msg)
    if not container then return end
    local tv = TextView(ctx.activity)
    tv.setText(msg or "正在加载...")
    tv.setTextColor(ctx.getColors().TEXT_SUB)
    tv.setTextSize(14)
    tv.setGravity(Gravity.CENTER)
    tv.setPadding(0, ctx.dp(36), 0, ctx.dp(36))
    container.addView(tv)
    ctx.fadeInView(tv, 220, 0)
end

showLoading = function(ctx, msg)
    showLoadingInto(ctx, ctx.contentLayout, msg)
end

-- ==================== showSearchDialog ====================
showSearchDialog = function(ctx)
    local activity = ctx.activity
    local dp = ctx.dp
    local C = ctx.getColors()
    local state = ctx.state
    if not ctx.isActivityAlive() then return end

    local dialogLayout = LinearLayout(activity)
    dialogLayout.setOrientation(LinearLayout.VERTICAL)
    dialogLayout.setPadding(dp(20), dp(20), dp(20), dp(16))
    dialogLayout.setBackground(ctx.cachedRoundedBg(C.CARD, 18))

    local title = TextView(activity)
    title.setText("搜索")
    title.setTextColor(C.TEXT); title.setTextSize(16)
    ctx.setTextBold(title, true)
    dialogLayout.addView(title)

    local input = EditText(activity)
    input.setText(state.searchQuery or "")
    input.setTextColor(C.TEXT); input.setTextSize(14)
    input.setHint("输入关键词，支持文件名 / 作者 / 说明")
    input.setHintTextColor(C.TEXT_SUB)
    input.setPadding(dp(14), dp(12), dp(14), dp(12))
    input.setBackground(ctx.cachedRoundedBgStroke(C.BG, C.CARD_STROKE, 12, 1))
    input.setSingleLine(true)
    local inLp = LinearLayout.LayoutParams(-1, -2)
    inLp.setMargins(0, dp(14), 0, dp(10))
    input.setLayoutParams(inLp)
    dialogLayout.addView(input)

    local rangeHint = TextView(activity)
    rangeHint.setText("将在「功能 / 美化 / 配置 / 资源」全部分类中搜索")
    rangeHint.setTextColor(C.TEXT_SUB); rangeHint.setTextSize(11)
    local rhLp = LinearLayout.LayoutParams(-1, -2)
    rhLp.setMargins(dp(4), 0, 0, dp(12))
    rangeHint.setLayoutParams(rhLp)
    dialogLayout.addView(rangeHint)

    local btnLayout = LinearLayout(activity)
    btnLayout.setOrientation(LinearLayout.HORIZONTAL)

    local cancelBtn = TextView(activity)
    cancelBtn.setText("取消")
    cancelBtn.setTextColor(C.TEXT); cancelBtn.setTextSize(14)
    cancelBtn.setGravity(Gravity.CENTER)
    cancelBtn.setPadding(0, dp(12), 0, dp(12))
    cancelBtn.setBackground(ctx.cachedRoundedBgStroke(0x00000000, C.CARD_STROKE, 11, 1))
    local cbLp = LinearLayout.LayoutParams(0, -2, 1.0)
    cbLp.setMargins(0, 0, dp(6), 0)
    cancelBtn.setLayoutParams(cbLp)
    btnLayout.addView(cancelBtn)

    local goBtn = TextView(activity)
    goBtn.setText("搜索")
    goBtn.setTextColor(0xffffffff); goBtn.setTextSize(14)
    goBtn.setGravity(Gravity.CENTER)
    goBtn.setPadding(0, dp(12), 0, dp(12))
    goBtn.setBackground(ctx.cachedRoundedBg(C.ACCENT, 11))
    local gbLp = LinearLayout.LayoutParams(0, -2, 1.0)
    gbLp.setMargins(dp(6), 0, 0, 0)
    goBtn.setLayoutParams(gbLp)
    btnLayout.addView(goBtn)
    dialogLayout.addView(btnLayout)

    local dlg = ctx.newDialog(dialogLayout, 40, true)
    if not dlg then return end

    ctx.setOnClick(cancelBtn, function() ctx.safeDismiss(dlg) end)
    ctx.setOnClick(goBtn, function()
        local kw = ""
        pcall(function() kw = tostring(input.getText().toString() or "") end)
        kw = kw:gsub("^%s+", ""):gsub("%s+$", "")
        if kw == "" then ctx.toast("请输入关键词"); return end
        ctx.safeDismiss(dlg)
        performSearch(ctx, kw)
    end)

    dlg.show()
    ctx.fadeInView(dialogLayout, 200, 0)
    ctx.postDelayed(function()
        pcall(function()
            if not ctx.isActivityAlive() then return end
            input.requestFocus()
            local imm = activity.getSystemService(
                android.content.Context.INPUT_METHOD_SERVICE)
            if imm then imm.showSoftInput(input, 0) end
        end)
    end, 150)
end

performSearch = function(ctx, kw)
    local state = ctx.state
    if not ctx.isActivityAlive() then return end

    state.searchGen = state.searchGen + 1
    local myGen = state.searchGen
    state.searchQuery = kw
    state.searchResults = nil
    state.searchLoading = true

    state.currentTab = ctx.TAB_HOME
    ctx.setNavActive(ctx.TAB_HOME)
    ctx.backBtn.setVisibility(View.VISIBLE)
    ctx.setTitleVisible(false)

    ctx.recycleAllCards()
    ctx.contentLayout.removeAllViews()
    showLoading(ctx, "正在搜索...")

    local catResults = {}
    local total = #ctx.CATEGORY_LIST
    local done = 0

    for _, cat in ipairs(ctx.CATEGORY_LIST) do
        if state.dataCache[cat.id] then
            catResults[cat.id] = state.dataCache[cat.id]
            done = done + 1
        end
    end

    local function finalizeIfDone()
        if state.searchGen ~= myGen or state.searchQuery ~= kw then return end
        if state.currentTab ~= ctx.TAB_HOME then return end
        if done < total then return end

        local allResults = {}
        local kwLower = kw:lower()
        for _, c in ipairs(ctx.CATEGORY_LIST) do
            local result = catResults[c.id]
            if result then
                for _, item in ipairs(result) do
                    local n = tostring(item.name or ""):lower()
                    local a = tostring(item.author or ""):lower()
                    local d = tostring(item.desc or ""):lower()
                    if n:find(kwLower, 1, true)
                        or a:find(kwLower, 1, true)
                        or d:find(kwLower, 1, true) then
                        allResults[#allResults + 1] = {
                            item = item, catId = c.id, catName = c.name,
                        }
                    end
                end
            end
        end
        state.searchLoading = false
        state.searchResults = allResults
        if state.currentTab == ctx.TAB_HOME
            and state.searchQuery == kw
            and state.searchGen == myGen then
            renderSearchResults(ctx)
        end
    end

    for _, cat in ipairs(ctx.CATEGORY_LIST) do
        local catId = cat.id
        if not state.dataCache[catId] then
            ctx.runAsync(function()
                local ok, result = pcall(ctx.fetchListData, catId)
                ctx.postUi(function()
                    if state.searchGen ~= myGen
                        or state.searchQuery ~= kw
                        or state.currentTab ~= ctx.TAB_HOME then return end
                    catResults[catId] = (ok and result) or nil
                    if ok and result then state.dataCache[catId] = result end
                    done = done + 1
                    finalizeIfDone()
                end)
            end)
        end
    end
    finalizeIfDone()
end

renderSearchResults = function(ctx)
    local activity = ctx.activity
    local dp = ctx.dp
    local C = ctx.getColors()
    local contentLayout = ctx.contentLayout
    local state = ctx.state
    if not ctx.isActivityAlive() then return end

    ctx.recycleAllCards()
    contentLayout.removeAllViews()

    if not state.searchQuery or state.searchQuery == "" then
        state.searchQuery = nil
        ctx.backBtn.setVisibility(View.GONE)
        ctx.setTitleVisible(false)
        ctx.reloadCurrentTab()
        return
    end

    local bar = LinearLayout(activity)
    bar.setOrientation(LinearLayout.HORIZONTAL)
    bar.setGravity(Gravity.CENTER_VERTICAL)
    bar.setBackground(ctx.cachedRoundedBg(C.CARD, 14))
    bar.setPadding(dp(14), dp(12), dp(14), dp(12))
    local barLp = LinearLayout.LayoutParams(-1, -2)
    barLp.setMargins(0, 0, 0, dp(12))
    bar.setLayoutParams(barLp)

    local resultCount = state.searchResults and #state.searchResults or 0
    local kwTv = TextView(activity)
    kwTv.setText("🔍 " .. tostring(state.searchQuery) .. "（" .. resultCount .. " 条）")
    kwTv.setTextColor(C.TEXT); kwTv.setTextSize(13)
    ctx.setTextBold(kwTv, true)
    kwTv.setLayoutParams(LinearLayout.LayoutParams(0, -2, 1.0))
    kwTv.setMaxLines(1); kwTv.setSingleLine(true)
    kwTv.setEllipsize(TextUtils.TruncateAt.END)
    bar.addView(kwTv)

    local clearTv = TextView(activity)
    clearTv.setText("✕")
    clearTv.setTextColor(C.TEXT_SUB); clearTv.setTextSize(14)
    clearTv.setPadding(dp(8), dp(4), dp(8), dp(4))
    bar.addView(clearTv)
    ctx.setOnClick(clearTv, function()
        state.searchQuery = nil
        state.searchResults = nil
        state.searchLoading = false
        state.searchGen = state.searchGen + 1
        ctx.backBtn.setVisibility(View.GONE)
        ctx.setTitleVisible(false)
        ctx.reloadCurrentTab()
    end)

    contentLayout.addView(bar)
    ctx.fadeInView(bar, 220, 0)

    if state.searchLoading then showLoading(ctx, "正在搜索..."); return end
    if not state.searchResults or #state.searchResults == 0 then
        showLoading(ctx, "没有找到匹配的内容"); return
    end

    for i, hit in ipairs(state.searchResults) do
        local item = hit.item
        local rawName = (item.name or "未命名"):gsub("%.zip$", "")
        local prefix = item.pinned and "📌 " or ""
        local sub = tostring(item.time or "") .. " · " .. hit.catName
        if item.today then sub = "今日 · " .. sub end
        local card = ctx.makeCard(prefix .. rawName, sub, function()
            showDetailDialog(ctx, item)
        end)
        contentLayout.addView(card)
        ctx.slideUpFadeIn(card, 220, math.min(60 + i * 25, 400))
    end
end

showDetailDialog = function(ctx, item)
    ctx.showDetailDialog(ctx, item)
end

-- ==================== 模块入口 ====================
return function(ctx)
    local contentLayout = ctx.contentLayout
    local state = ctx.state
    if not ctx.isActivityAlive() then return end

    ctx.recycleAllCards()
    contentLayout.removeAllViews()

    if state.searchQuery ~= nil then
        renderSearchResults(ctx)
        return
    end

    if state.bannerData == nil and not state.bannerLoading then
        state.bannerLoading = true
        ctx.runAsync(function()
            local ok, result = pcall(ctx.fetchBannersData)
            ctx.postUi(function()
                state.bannerLoading = false
                state.bannerData = (ok and result and #result > 0) and result or {}
                if state.currentTab == ctx.TAB_HOME and state.searchQuery == nil then
                    if state.dataCache[ctx.HOME_CACHE_KEY] then ctx.reloadCurrentTab() end
                end
            end)
        end)
    end
    if state.noticeData == nil and not state.noticeLoading then
        state.noticeLoading = true
        ctx.runAsync(function()
            local ok, result = pcall(ctx.fetchNoticeData)
            ctx.postUi(function()
                state.noticeLoading = false
                state.noticeData = (ok and result) and result or { text = "", link = "" }
                if state.currentTab == ctx.TAB_HOME and state.searchQuery == nil then
                    if state.dataCache[ctx.HOME_CACHE_KEY] then ctx.reloadCurrentTab() end
                end
            end)
        end)
    end

    if state.dataCache[ctx.HOME_CACHE_KEY] then
        renderList(ctx, state.dataCache[ctx.HOME_CACHE_KEY])
        return
    end
    if state.isLoading[ctx.HOME_CACHE_KEY] then return end
    state.isLoading[ctx.HOME_CACHE_KEY] = true

    renderList(ctx, {})
    showLoading(ctx, "正在加载...")

    local finished = false
    local function onDone(ok, merged, err)
        if finished then return end
        finished = true
        state.isLoading[ctx.HOME_CACHE_KEY] = false
        if not ctx.isActivityAlive() then return end
        if state.currentTab ~= ctx.TAB_HOME or state.searchQuery ~= nil then return end

        if ok and merged then
            state.dataCache[ctx.HOME_CACHE_KEY] = merged
            renderList(ctx, merged)
        else
            renderList(ctx, {})
            local msg = "加载失败"
            if err and tostring(err) ~= "" then
                local s = tostring(err)
                if #s > 80 then s = s:sub(1, 80) .. "…" end
                msg = msg .. "：" .. s
            else msg = msg .. "，请检查网络" end
            showLoading(ctx, msg)
        end
    end

    ctx.postDelayed(function()
        if not finished then onDone(false, nil, "网络较慢，请稍后重试") end
    end, math.max(ctx.HOME_LOAD_TIMEOUT_MS or 30000, 30000))

    ctx.fetchAllLatest(onDone)
end