diff --git a/api/plugins.json b/api/plugins.json index 20432c8..8fbce00 100644 --- a/api/plugins.json +++ b/api/plugins.json @@ -3,8 +3,9 @@ "plugins/LingyeNBird/avatar-fall/avatar-fall.json", "plugins/Little100/theme-palette/theme-palette.json", "plugins/Little100/theme-widgets/theme-widgets.json", + "plugins/sealantern/podcast-reader/podcast-reader.json", "plugins/zhuxiaojt/yuanshen-splash/yuanshen-splash.json" ], - "total": 4, - "updated_at": "2026-02-25T15:37:21.332Z" + "total": 5, + "updated_at": "2026-06-14T04:35:57.679Z" } diff --git a/plugins/ieshishinjin/podcast-reader/icon.png b/plugins/ieshishinjin/podcast-reader/icon.png new file mode 100644 index 0000000..a121efc Binary files /dev/null and b/plugins/ieshishinjin/podcast-reader/icon.png differ diff --git a/plugins/ieshishinjin/podcast-reader/main.lua b/plugins/ieshishinjin/podcast-reader/main.lua new file mode 100644 index 0000000..727ca25 --- /dev/null +++ b/plugins/ieshishinjin/podcast-reader/main.lua @@ -0,0 +1,564 @@ +-- ============================================================ +-- 播客订阅器 FINAL +-- ============================================================ + +local plugin = {} +local APP_CORE = "pr-core" +local STORAGE_PODS = "podcast_feeds" +local STORAGE_EPS = "podcast_episodes" +local STORAGE_HEARD = "podcast_heard" + +local state = { podcasts = {}, episodes = {}, heard = {}, currentPod = nil, currentEp = nil, showAdd = false, playerOpen = true } +local coreInjected = false + +-- ============================================================ +-- 工具 +-- ============================================================ +local function esc(s) + if not s then return "" end + return s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub('"', """) +end +local function trim(s) return s and s:match("^%s*(.-)%s*$") or "" end +local function mkId(s) + if not s or #s == 0 then return "id_" .. math.random(100000, 999999) end + local id = s:gsub("[^%w%-_]", "_"):lower() + return #id > 64 and id:sub(1, 64) or id +end +local function fmtDur(s) + s = tonumber(s) or 0 + local h = math.floor(s / 3600); local m = math.floor((s % 3600) / 60); local sec = math.floor(s % 60) + if h > 0 then return string.format("%d:%02d:%02d", h, m, sec) end + return string.format("%d:%02d", m, sec) +end +local function parseDur(s) + if not s or #s == 0 then return 0 end + local h, m, s2 = s:match("^(%d+):(%d+):(%d+)$") + if h then return tonumber(h)*3600+tonumber(m)*60+tonumber(s2) end + local m2, s2 = s:match("^(%d+):(%d+)$") + if m2 then return tonumber(m2)*60+tonumber(s2) end + return tonumber(s) or 0 +end +local function fmtDate(s) + if not s or #s == 0 then return "" end + local y, m, d = s:match("(%d+)%-(%d+)%-(%d+)") + if y then return string.format("%d-%02d-%02d", tonumber(y), tonumber(m), tonumber(d)) end + local months = {Jan = 1, Feb = 2, Mar = 3, Apr = 4, May = 5, Jun = 6, Jul = 7, Aug = 8, Sep = 9, Oct = 10, Nov = 11, Dec = 12} + local dd, mmm, yyyy = s:match("(%d+)%s+(%a%a%a)%s+(%d+)") + if dd and mmm and months[mmm] and yyyy then return string.format("%d-%02d-%02d", tonumber(yyyy), months[mmm], tonumber(dd)) end + return s:sub(1, 10) +end +local function parseTime(s) + if not s or #s == 0 then return 0 end + local y, m, d = s:match("(%d+)%-(%d+)%-(%d+)") + if y then return tonumber(y)*10000+tonumber(m)*100+tonumber(d) end + local months = {Jan=1,Feb=2,Mar=3,Apr=4,May=5,Jun=6,Jul=7,Aug=8,Sep=9,Oct=10,Nov=11,Dec=12} + local dd, mmm, yyyy = s:match("(%d+)%s+(%a%a%a)%s+(%d+)") + if dd and mmm and months[mmm] and yyyy then return tonumber(yyyy)*10000+months[mmm]*100+tonumber(dd) end + return 0 +end +local function deepCopy(t) + if type(t) ~= "table" then return t end + local r = {}; for k, v in pairs(t) do r[k] = deepCopy(v) end + return r +end +-- JS 字符串安全转义(处理所有破坏 JS 字符串的字符) +local function jsStr(s) + if not s then return "" end + s = s:gsub("\\", "\\\\") + s = s:gsub("'", "\\'") + s = s:gsub('"', '\\"') + s = s:gsub("\n", "\\n") + s = s:gsub("\r", "\\r") + s = s:gsub("\t", "\\t") + s = s:gsub("", '') + return s +end + +-- ============================================================ +-- XML / RSS +-- ============================================================ +local function parseXml(xml) + if not xml or #xml == 0 then return nil end + xml = xml:gsub("<%?xml.-%?>", "") + local root, stack, i, len = nil, {}, 1, #xml + while i <= len do + local ts = xml:find("<", i); if not ts then break end + if ts > i and #stack > 0 then + local txt = trim(xml:sub(i, ts - 1)) + if #txt > 0 then table.insert(stack[#stack].children, {type = "text", content = txt}) end + end + if ts + 1 > len then break end + local nc = xml:sub(ts + 1, ts + 1) + if nc == "/" then + local ce = xml:find(">", ts); if not ce then break end + local tn = trim(xml:sub(ts + 2, ce - 1)) + if #stack > 0 and stack[#stack].tag == tn then table.remove(stack) end; i = ce + 1 + elseif nc == "?" or nc == "!" then + if xml:sub(ts + 2, ts + 4) == "--" then local ce = xml:find("-->", ts); i = (ce or ts) + 3 + elseif xml:sub(ts + 2, ts + 8) == "[CDATA[" then + local ce = xml:find("]]>", ts) + if ce and #stack > 0 then table.insert(stack[#stack].children, {type = "text", content = xml:sub(ts + 9, ce - 1)}) end + i = (ce or ts) + 3 + else local ce = xml:find(">", ts); i = (ce or ts) + 1 end + else + local ce = xml:find(">", ts); if not ce then break end + local tc = xml:sub(ts + 1, ce - 1) + if tc:match("^%s*$") then i = ce + 1; break end + local sc = tc:sub(-1) == "/" + if sc then tc = tc:sub(1, -2) end + local tn = tc:match("^([%w:_%-]+)"); if not tn then i = ce + 1; break end + local attrs = {} + for k, v in tc:gmatch('([%w:_%-]+)%s*=%s*"([^"]*)"') do attrs[k] = v end + for k, v in tc:gmatch("([%w:_%-]+)%s*=%s*'([^']*)'") do attrs[k] = v end + local node = {tag = tn, attrs = attrs, children = {}} + if #stack == 0 then root = node else table.insert(stack[#stack].children, node) end + if not sc then table.insert(stack, node) end; i = ce + 1 + end + end + return root +end +local function getText(node, tag) + if not node or not node.children then return nil end + for _, c in ipairs(node.children) do + if c.tag == tag then + local t = "" + for _, x in ipairs(c.children or {}) do if x.type == "text" then t = t .. (x.content or "") end end + return trim(t) + end + end + return nil +end +local function getNodes(node, tag) + local r = {} + if node and node.children then for _, c in ipairs(node.children) do if c.tag == tag then table.insert(r, c) end end end + return r +end +local function getAttr(node, tag, attr) + for _, c in ipairs(node.children or {}) do if c.tag == tag and c.attrs then return c.attrs[attr] end end + return nil +end +local function parseFeed(xml, url) + local p = parseXml(xml); if not p then return nil end + local info = {title = url, author = "", artwork = "", desc = "", episodes = {}} + if p.tag == "rss" then + local chs = getNodes(p, "channel"); if #chs == 0 then return nil end + local ch = chs[1] + info.title = getText(ch, "title") or url + info.author = getAttr(ch, "itunes:author", "content") or getText(ch, "itunes:author") or getText(ch, "author") or "" + info.artwork = getAttr(ch, "itunes:image", "href") or getText(ch, "itunes:image") or ((getNodes(ch, "image") or {})[1] and getText((getNodes(ch, "image"))[1], "url")) or "" + info.desc = (getText(ch, "description") or ""):gsub("<[^>]+>", " "):gsub("%s+", " "):sub(1, 500) + for _, item in ipairs(getNodes(ch, "item")) do + local eUrl = "" + for _, c in ipairs(item.children or {}) do if c.tag == "enclosure" and c.attrs then eUrl = c.attrs.url or "" end end + if eUrl ~= "" then + local guid = getText(item, "guid") or eUrl + table.insert(info.episodes, { + id = mkId(guid), title = getText(item, "title") or "无标题", + desc = (getText(item, "description") or ""):gsub("<[^>]+>", " "):gsub("%s+", " "):sub(1, 300), + url = eUrl, pubDate = getText(item, "pubDate") or "", + duration = parseDur(getAttr(item, "itunes:duration", "content") or getText(item, "itunes:duration") or getText(item, "duration") or ""), + artwork = getAttr(item, "itunes:image", "href") or getText(item, "itunes:image") or info.artwork, + }) + end + end + table.sort(info.episodes, function(a, b) return parseTime(a.pubDate) > parseTime(b.pubDate) end) + end + return info +end + +-- ============================================================ +-- 存储 +-- ============================================================ +local function loadState() + local ok1, p = pcall(sl.storage.get, STORAGE_PODS) + local ok2, e = pcall(sl.storage.get, STORAGE_EPS) + local ok3, h = pcall(sl.storage.get, STORAGE_HEARD) + state.podcasts = (ok1 and p) and deepCopy(p) or {} + state.episodes = (ok2 and e) and deepCopy(e) or {} + state.heard = (ok3 and h) and deepCopy(h) or {} + state.playerOpen = sl.storage.get("show_player") ~= false +end +local function saveAll() + pcall(function() sl.storage.set(STORAGE_PODS, deepCopy(state.podcasts)) end) + pcall(function() sl.storage.set(STORAGE_EPS, deepCopy(state.episodes)) end) + pcall(function() sl.storage.set(STORAGE_HEARD, deepCopy(state.heard)) end) +end + +-- ============================================================ +-- 播客管理 +-- ============================================================ +function addPodcast(url) + url = trim(url) + if not url or #url == 0 then return false, "请输入地址" end + if not url:match("^https?://") then return false, "请输入有效地址" end + for _, p in ipairs(state.podcasts) do if p.url == url then return false, "已添加过" end end + local pod = {id = mkId(url), url = url, title = url, author = "", artwork = "", desc = ""} + table.insert(state.podcasts, pod); saveAll() + local ok, msg = fetchPod(pod) + return true, ok and "已添加" or "添加失败: " .. tostring(msg) +end +function removePodcast(id) + local np = {}; for _, p in ipairs(state.podcasts) do if p.id ~= id then table.insert(np, p) end end + state.podcasts = np + local ne = {}; for _, e in ipairs(state.episodes) do if e.podId ~= id then table.insert(ne, e) end end + state.episodes = ne; saveAll() + if state.currentPod == id then state.currentPod = nil end + if state.currentEp then + local found = false; for _, e in ipairs(state.episodes) do if e.id == state.currentEp then found = true; break end end + if not found then state.currentEp = nil end + end +end +function fetchPod(pod) + local resp, err = sl.http.get(pod.url, {timeout = 20}) + if not resp then return false, tostring(err) end + if resp.status ~= 200 then return false, "HTTP " .. tostring(resp.status) end + local info = parseFeed(resp.body, pod.url) + if not info then return false, "无法解析 RSS" end + pod.title = info.title or pod.title; pod.author = info.author or pod.author + pod.artwork = info.artwork or pod.artwork; pod.desc = info.desc or "" + local exist = {}; for _, e in ipairs(state.episodes) do if e.podId == pod.id then exist[e.id] = true end end + local n = 0 + for _, ed in ipairs(info.episodes) do + if not exist[ed.id] then ed.podId = pod.id; table.insert(state.episodes, 1, ed); n = n + 1 end + end + saveAll(); return true, n +end +function refreshPod(podId) + for _, p in ipairs(state.podcasts) do if p.id == podId then return fetchPod(p) end end + return false, "未找到" +end +function getEps(podId) + local r = {}; for _, e in ipairs(state.episodes) do if e.podId == podId then table.insert(r, e) end end + table.sort(r, function(a, b) return parseTime(a.pubDate) > parseTime(b.pubDate) end) + return r +end +function getUnheard(podId) + local c = 0; for _, e in ipairs(state.episodes) do if e.podId == podId and not state.heard[e.id] then c = c + 1 end end + return c +end +function getPodById(podId) + for _, p in ipairs(state.podcasts) do if p.id == podId then return p end end + return nil +end +function getCurrentEpData() + if not state.currentEp then return nil end + for _, e in ipairs(state.episodes) do if e.id == state.currentEp then return e end end + return nil +end + +-- ============================================================ +-- UI 生成 +-- ============================================================ +function genWelcome() + return '
' + .. '
' + .. '' + .. '

欢迎使用播客订阅器

' + .. '

添加 RSS 订阅地址,自动获取最新剧集

' + .. '
' +end +function genHeader() + local title = state.currentPod and (getPodById(state.currentPod) or {}).title or "播客" + local left = "" + if state.currentPod then left = '' end + left = left .. '' .. esc(title) .. '' + local right = "" + if not state.currentPod then right = '' + else right = '' end + return '
' .. left .. right .. '
' +end +function genGrid() + if #state.podcasts == 0 then return "" end + local html = '
' + for _, p in ipairs(state.podcasts) do + local art = "" + if p.artwork and #p.artwork > 0 then art = '' + else art = '
' end + html = html .. '
' + .. '
' + end + return html .. '
' +end +function genEpisodeList() + if not state.currentPod then return "" end + local eps = getEps(state.currentPod); local pod = getPodById(state.currentPod) + if #eps == 0 then return '
还没有剧集
' end + local html = '
' + for _, e in ipairs(eps) do + local src = e.artwork or (pod and pod.artwork) or "" + local art = "" + if src and #src > 0 then art = '' + else art = '
' end + html = html .. '
' + .. '' .. art .. '' + .. '' .. esc(e.title) .. '' + .. '' .. fmtDate(e.pubDate) .. '' + .. (e.duration and e.duration > 0 and ' · ' .. fmtDur(e.duration) .. '' or "") + .. (e.id == state.currentEp and ' · 播放中' or "") + .. '' .. (e.desc and #e.desc > 0 and '' .. esc(e.desc) .. '' or '') .. '' + .. '
' + end + return html .. '
' +end +function genContent() + local body = "" + if #state.podcasts == 0 then body = genWelcome() + else + body = genHeader() + local pod = getPodById(state.currentPod) + if state.currentPod and pod then + if (pod.author and #pod.author > 0) or (pod.desc and #pod.desc > 0) then + body = body .. '
' + if pod.author and #pod.author > 0 then body = body .. '' .. esc(pod.author) .. '' end + if pod.desc and #pod.desc > 0 then body = body .. '' .. esc(pod.desc) .. '' end + body = body .. '
' + end + end + body = body .. '
' .. (state.currentPod and genEpisodeList() or genGrid()) .. '
' + end + if state.showAdd then + body = body .. '
' + .. '
添加播客
' + .. '' + .. '
' + .. '' + .. '
' + end + return '
' .. body .. '
' +end + +-- ============================================================ +-- 播放器 UI 更新(注入脚本直接操作 DOM) +-- ============================================================ +function syncPlayerUI(url) + local ep = nil + for _, e in ipairs(state.episodes) do if e.url == url or e.id == url then ep = e; break end end + if not ep then return end + local pod = getPodById(ep.podId) or {} + local art = ep.artwork or pod.artwork or "" + local artJS = "" + if art and #art > 0 then + artJS = "ar.innerHTML='';" + else + artJS = "ar.innerHTML='';" + end + local js = "try{var d=document;" + -- 注意:不设 audio.src!播放由点击事件直接处理,这里只更新 UI + js = js .. "var t=d.getElementById('pr-pb-title');if(t)t.textContent='" .. jsStr(ep.title) .. "';" + js = js .. "var p=d.getElementById('pr-pb-pod');if(p)p.textContent='" .. jsStr(pod.title or "") .. "';" + js = js .. "var u=d.getElementById('pr-pb-total');if(u)u.textContent='" .. (ep.duration and fmtDur(ep.duration) or "0:00") .. "';" + js = js .. "var ar=d.getElementById('pr-pb-art');" .. artJS + if state.playerOpen ~= false then + js = js .. "var b=d.getElementById('pr-player-bar');if(b)b.style.display='flex';" + end + js = js .. "}catch(e){}" + pcall(function() sl.ui.inject_html("pr-pb-upd", "") end) +end + +-- ============================================================ +-- 核心脚本 +-- ============================================================ +function genCoreHtml() + return [==[ + +
+ + 未播放 + + + + + + 0:000:00 + +
+]==] +end + +-- ============================================================ +-- 命令处理 +-- ============================================================ +local function processCmd(cmd, args) + if cmd == "page-ready" then renderUI(); return end + if cmd == "show-add" then state.showAdd = true; renderUI(); return end + if cmd == "hide-add" then state.showAdd = false; renderUI(); return end + if cmd == "add-podcast" then + state.showAdd = false + local url = args.url or "" + if url and #url > 0 then local ok2, msg = addPodcast(url); sl.ui.toast(ok2 and "success" or "error", msg or "") end + renderUI(); return + end + if cmd == "select" then state.currentPod = args.id; state.currentEp = nil; renderUI(); return end + if cmd == "back" then state.currentPod = nil; state.currentEp = nil; renderUI(); return end + if cmd == "remove" then removePodcast(args.id); sl.ui.toast("info", "已取消订阅"); renderUI(); return end + if cmd == "refresh" then + local ok2, msg = refreshPod(args.id) + sl.ui.toast(ok2 and "success" or "error", ok2 and ((tonumber(msg) or 0) > 0 and "新增 " .. msg .. " 集" or "没有新剧集") or "失败: " .. tostring(msg)) + renderUI(); return + end + if cmd == "play" then + local url = args.url or "" + for _, e in ipairs(state.episodes) do if e.url == url then state.currentEp = e.id; state.heard[e.id] = true; saveAll(); break end end + syncPlayerUI(url); return + end + if cmd == "select_ep" then + state.currentEp = args.id; state.heard[args.id] = true; saveAll() + syncPlayerUI(args.id); renderUI(); return + end + if cmd == "prev" or cmd == "next" then + if not state.currentEp or not state.currentPod then return end + local eps = getEps(state.currentPod); local idx = -1 + for i, e in ipairs(eps) do if e.id == state.currentEp then idx = i; break end end + if idx >= 0 then + local ni = (cmd == "next") and idx + 1 or idx - 1 + if ni >= 1 and ni <= #eps then + state.currentEp = eps[ni].id; state.heard[eps[ni].id] = true; saveAll() + for _, e in ipairs(state.episodes) do if e.id == eps[ni].id then + syncPlayerUI(e.url) + local u = jsStr(e.url) + pcall(function() sl.ui.inject_html("pr-pb-switch", "") end) + break + end end + end + end + return + end +end + +-- ============================================================ +-- UI +-- ============================================================ +function renderUI() + sl.ui.insert("prepend", ".plugin-content", genContent()) + pcall(function() sl.ui.inject_css("pr-hide", [[ + .plugin-page-view .page-header { display: none !important; } + .plugin-page-view .plugin-content > .info-card { display: none !important; } + .plugin-page-view .plugin-content > .settings-card { display: none !important; } + .plugin-page-view .plugin-content > .presets-card { display: none !important; } + .plugin-page-view .plugin-content > .action-buttons { display: none !important; } + ]]) end) +end +function injectUI() + if not coreInjected then + sl.ui.inject_html(APP_CORE, genCoreHtml()) + coreInjected = true + end + renderUI() + if state.currentEp then + for _, e in ipairs(state.episodes) do if e.id == state.currentEp then syncPlayerUI(e.url); break end end + end +end +function removeUI() + pcall(function() sl.ui.remove_html(APP_CORE) end) + pcall(function() sl.ui.remove_selector('[data-plugin-inserted="podcast-reader"]') end) + pcall(function() sl.ui.remove_css("pr-hide") end) +end + +-- ============================================================ +-- 生命周期 +-- ============================================================ +function plugin.onLoad() + pcall(function() sl.log.info("播客订阅器加载中"); loadState(); sl.log.debug("加载 " .. tostring(#state.podcasts) .. " 播客") end) +end +function plugin.onEnable() + pcall(function() sl.log.info("播客订阅器已启用"); injectUI() end) +end +function plugin.onDisable() + pcall(function() sl.log.warn("播客订阅器已禁用"); removeUI(); saveAll() end) +end +function plugin.onUnload() + pcall(function() sl.log.info("播客订阅器已卸载") end) +end +function plugin.onPageChanged(path) + local ok, err = pcall(function() + local cmdData = path:match("^//pr/(.+)$") + if cmdData then + local parts = {}; for part in cmdData:gmatch("[^|]+") do parts[#parts+1] = part end + local args = {} + for i = 2, #parts do + local k, v = parts[i]:match("^([^=]+)=(.*)$") + if k then v = v:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end); args[k] = v end + end + processCmd(parts[1], args) + return + end + if not path or not path:find("podcast%-reader") then return end + injectUI() + end) + if not ok then sl.log.error("onPageChanged 错误: " .. tostring(err)) end +end + +return plugin diff --git a/plugins/ieshishinjin/podcast-reader/manifest.json b/plugins/ieshishinjin/podcast-reader/manifest.json new file mode 100644 index 0000000..11ee492 --- /dev/null +++ b/plugins/ieshishinjin/podcast-reader/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "podcast-reader", + "name": "播客订阅器", + "version": "1.0.0", + "description": "订阅和收听播客节目。添加播客 RSS 订阅源,自动获取最新剧集,支持后台播放、倍速播放和播放进度记忆。", + "author": { + "name": "ieshishinjin" + }, + "main": "main.lua", + "icon": "icon.png", + "permissions": [ + "log", + "storage", + "network", + "ui", + "element" + ], + "settings": [ + { + "key": "show_player", + "label": "显示底部播放栏", + "type": "boolean", + "default": true, + "description": "关闭后播放栏隐藏,播放继续" + } + ], + "ui": { + "sidebar": { + "label": "播客", + "icon": "headphones", + "priority": 80 + } + } +} diff --git a/plugins/ieshishinjin/podcast-reader/podcast-reader.json b/plugins/ieshishinjin/podcast-reader/podcast-reader.json new file mode 100644 index 0000000..c142288 --- /dev/null +++ b/plugins/ieshishinjin/podcast-reader/podcast-reader.json @@ -0,0 +1,58 @@ +{ + "id": "podcast-reader", + "repo": "sealantern/podcast-reader", + "name": { + "zh-CN": "播客订阅器", + "en-US": "Podcast Reader" + }, + "description": { + "zh-CN": "订阅和收听播客节目。添加播客 RSS 订阅源,自动获取最新剧集,支持后台播放、倍速播放和播放进度记忆。", + "en-US": "Subscribe to and listen to podcasts. Add podcast RSS feeds, auto-fetch latest episodes with background playback, speed control, and progress tracking." + }, + "version": "1.0.0", + "author": { + "name": "sealantern", + "url": "https://github.com/sealantern" + }, + "categories": [ + "utility" + ], + "main": "main.lua", + "permissions": [ + "log", + "storage", + "network", + "ui", + "element" + ], + "sidebar": { + "mode": "self", + "label": "播客订阅器", + "icon": "headphones", + "priority": 80 + }, + "settings": [ + { + "key": "playback_speed", + "label": "默认播放速度", + "type": "number", + "default": 1.0, + "description": "默认播放速度,0.5 到 3.0 之间" + }, + { + "key": "fetch_interval", + "label": "自动刷新间隔", + "type": "number", + "default": 120, + "description": "自动刷新间隔(分钟),0 表示不自动刷新" + }, + { + "key": "max_episodes", + "label": "每播客最大剧集数", + "type": "number", + "default": 100, + "description": "每个播客保留的最大剧集数量" + } + ], + "icon_url": "icon.png" +} diff --git a/plugins/ieshishinjin/podcast-reader/style.css b/plugins/ieshishinjin/podcast-reader/style.css new file mode 100644 index 0000000..5723306 --- /dev/null +++ b/plugins/ieshishinjin/podcast-reader/style.css @@ -0,0 +1,767 @@ +/* ============================================================ + * 持久化播放器(overlay 底部栏) + * ============================================================ */ + +.pr-player-bar { + position: fixed; + bottom: 0; + left: 220px; + right: 0; + height: 64px; + background: var(--sl-surface, #1a1f2e); + border-top: 1px solid var(--sl-border, #2a3344); + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + z-index: 9997; +} + +.pr-pb-left { flex-shrink: 0; } +.pr-pb-img { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; display: block; } +.pr-pb-img.ph { width: 40px; height: 40px; border-radius: 6px; background: var(--sl-bg-secondary, #0f172a); display: flex; align-items: center; justify-content: center; } +.pr-pb-img.ph svg { width: 20px; height: 20px; opacity: 0.3; color: var(--sl-text-tertiary, #64748b); } + +.pr-pb-center { flex: 1; min-width: 0; max-width: 300px; } +.pr-pb-title { display:block; font-size:13px; font-weight:600; color:var(--sl-text-primary,#f1f5f9); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.pr-pb-pod { display:block; font-size:11px; color:var(--sl-text-tertiary,#64748b); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; } + +.pr-pb-controls { display:flex; align-items:center; gap:2px; flex-shrink:0; } +.pr-pb-seek { flex:1; min-width:80px; display:flex; align-items:center; gap:6px; } + +@media (max-width:600px) { + .pr-player-bar { left:0; } + .pr-pb-center { max-width:120px; } + .pr-pb-seek { min-width:60px; } +} +/* ============================================================ + * 播客订阅器 — Podcast Reader + * Apple 播客风格 · 内嵌页面 + * ============================================================ */ + +/* ============================================================ + * 欢迎界面 — Apple 风格 + * ============================================================ */ + +.pr-welcome { + position: relative; + text-align: center; + padding: 72px 24px 48px; + overflow: hidden; + border-radius: var(--sl-radius-lg, 12px); +} + +.pr-welcome-bg { + position: absolute; + inset: -40px 0 0; + background: radial-gradient(ellipse at 50% 50%, var(--sl-primary-bg, rgba(14,165,233,0.08)) 0%, transparent 60%), + radial-gradient(ellipse at 80% 80%, rgba(14,165,233,0.04) 0%, transparent 50%); + pointer-events: none; +} + +.pr-welcome-content { + position: relative; + z-index: 1; + max-width: 380px; + margin: 0 auto; +} + +.pr-welcome-icon { + width: 88px; + height: 88px; + margin: 0 auto 24px; + background: var(--sl-surface, #1a1f2e); + border: 1.5px solid var(--sl-border, #2a3344); + border-radius: 22px; + display: flex; + align-items: center; + justify-content: center; + color: var(--sl-primary, #0ea5e9); + box-shadow: 0 8px 32px rgba(14,165,233,0.1); +} + +.pr-welcome-title { + font-size: 22px; + font-weight: 700; + color: var(--sl-text-primary, #f1f5f9); + margin: 0 0 12px; + letter-spacing: -0.3px; +} + +.pr-welcome-text { + font-size: 15px; + line-height: 1.6; + color: var(--sl-text-secondary, #94a3b8); + margin: 0 0 28px; +} + +.pr-welcome-hint { + font-size: 12px; + color: var(--sl-text-tertiary, #64748b); + margin: 20px 0 0; + line-height: 1.5; + opacity: 0.7; +} + +/* ============================================================ + * 布局 + * ============================================================ */ + +.pr-app { + margin: 40px -8px 0; + border-radius: var(--sl-radius-lg, 12px); + overflow: hidden; +} + +.pr-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 4px 4px; + flex-shrink: 0; +} + +.pr-title { + font-size: 21px; + font-weight: 700; + color: var(--sl-text-primary, #f1f5f9); + margin-left: 6px; + letter-spacing: -0.2px; +} + +.pr-content { + padding: 4px 0 8px; +} + +/* ============================================================ + * 按钮 + * ============================================================ */ + +.pr-btn, +.pr-back-btn, +.pr-add-btn, +.pr-refresh-btn, +.pr-cancel-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid var(--sl-border, #334155); + border-radius: var(--sl-radius-md, 8px); + background: transparent; + color: var(--sl-text-secondary, #94a3b8); + font-size: 13px; + font-weight: 500; + cursor: pointer; + + white-space: nowrap; + user-select: none; + font-family: inherit; +} + +.pr-back-btn:hover, +.pr-add-btn:hover, +.pr-refresh-btn:hover, +.pr-cancel-btn:hover { + background: var(--sl-surface-hover, #1e293b); + color: var(--sl-text-primary, #f1f5f9); + border-color: var(--sl-primary, #0ea5e9); +} + +.pr-back-btn { + border: none; + padding: 6px 4px; +} + +.pr-primary-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + border: none; + border-radius: var(--sl-radius-md, 8px); + background: var(--sl-primary, #0ea5e9); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + + white-space: nowrap; + font-family: inherit; +} + +.pr-primary-btn:hover { + filter: brightness(1.12);; + box-shadow: 0 4px 16px rgba(14,165,233,0.25); +} + +.pr-primary-btn:active {; +} + +/* ============================================================ + * 播客网格 + * ============================================================ */ + +.pr-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(144px, 1fr)); + gap: 14px; + padding: 8px 4px 4px; +} + +.pr-card-wrap { + position: relative; +} + +.pr-card { + display: block; + width: 100%; + text-align: left; + background: var(--sl-surface, #1a1f2e); + border: 1.5px solid var(--sl-border, #2a3344); + border-radius: var(--sl-radius-lg, 12px); + cursor: pointer; + + overflow: hidden; + padding: 0; + color: inherit; + font: inherit; + font-family: inherit; +} + +.pr-card:hover { + border-color: var(--sl-primary, #0ea5e9); + box-shadow: 0 6px 20px rgba(14,165,233,0.12);; +} + +.pr-card-selected { + border-color: var(--sl-primary, #0ea5e9); + box-shadow: 0 0 0 2px var(--sl-primary-bg, rgba(14,165,233,0.2)); +} + +.pr-card-art { + position: relative; + display: block; + width: 100%; + aspect-ratio: 1; + background: var(--sl-bg-secondary, #0f172a); + border-radius: calc(var(--sl-radius-lg, 12px) - 2px) calc(var(--sl-radius-lg, 12px) - 2px) 0 0; + overflow: hidden; +} + +.pr-card-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.pr-card-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.pr-card-placeholder svg { + width: 40px; + height: 40px; + opacity: 0.35; + color: var(--sl-text-tertiary, #64748b); +} + +.pr-card-body { + display: block; + padding: 10px 12px 12px; +} + +.pr-card-name { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--sl-text-primary, #f1f5f9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pr-card-meta { + display: block; + font-size: 11px; + color: var(--sl-text-tertiary, #64748b); + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 徽章 */ +.pr-badge { + position: absolute; + top: 6px; + right: 6px; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: var(--sl-primary, #0ea5e9); + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 20px; + text-align: center; + border-radius: 99px; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); + z-index: 1; +} + +/* 删除按钮 */ +.pr-remove-btn { + position: absolute; + top: 6px; + left: 6px; + width: 26px; + height: 26px; + border-radius: 50%; + border: none; + background: rgba(0,0,0,0.6); + color: #fca5a5; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + + z-index: 2; +} + +.pr-card-wrap:hover .pr-remove-btn { opacity: 1; } + +.pr-remove-btn:hover { + background: rgba(239,68,68,0.85); + color: #fff; +} + +/* ============================================================ + * 剧集列表 + * ============================================================ */ + +.pr-ep-list { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0 4px; +} + +.pr-ep { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: var(--sl-radius-md, 10px); + cursor: pointer; + +} + +.pr-ep:hover { + background: var(--sl-surface-hover, #1e293b); +} + +.pr-ep-heard .pr-ep-title { + color: var(--sl-text-tertiary, #64748b); + font-weight: 400; +} + +.pr-ep-playing { + background: var(--sl-primary-bg, rgba(14,165,233,0.07)); +} + +.pr-ep-art { + width: 48px; + height: 48px; + border-radius: var(--sl-radius-sm, 8px); + overflow: hidden; + flex-shrink: 0; + background: var(--sl-bg-secondary, #0f172a); +} + +.pr-ep-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.pr-ep-img-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.pr-ep-img-placeholder svg { + width: 20px; + height: 20px; + opacity: 0.3; + color: var(--sl-text-tertiary, #64748b); +} + +.pr-ep-body { + flex: 1; + min-width: 0; +} + +.pr-ep-title { + display: block; + font-size: 16px; + font-weight: 600; + color: var(--sl-text-primary, #f1f5f9); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.35; +} + +.pr-ep-meta { + display: block; + font-size: 12px; + color: var(--sl-text-tertiary, #64748b); + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pr-ep-dot { + margin: 0 4px; + opacity: 0.5; +} + +.pr-ep-now { + color: var(--sl-primary, #0ea5e9); + font-weight: 500; +} + +/* 播放按钮 */ +.pr-play-btn { + width: 34px; + height: 34px; + border-radius: 50%; + border: 1.5px solid var(--sl-border, #334155); + background: transparent; + color: var(--sl-primary, #0ea5e9); + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + +} + +.pr-play-btn:hover { + background: var(--sl-primary, #0ea5e9); + border-color: var(--sl-primary, #0ea5e9); + color: #fff;; +} + +/* ============================================================ + * 播放器 + * ============================================================ */ + +.pr-player { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + margin: 8px -8px -8px; + background: var(--sl-surface, #1a1f2e); + border-top: 1px solid var(--sl-border, #2a3344); + border-radius: 0 0 var(--sl-radius-lg, 12px) var(--sl-radius-lg, 12px); +} + +.pr-player-left { + flex-shrink: 0; +} + +.pr-player-img { + width: 44px; + height: 44px; + border-radius: var(--sl-radius-sm, 8px); + object-fit: cover; + display: block; +} + +.pr-player-img.ph { + width: 44px; + height: 44px; + border-radius: var(--sl-radius-sm, 8px); + background: var(--sl-bg-secondary, #0f172a); + display: flex; + align-items: center; + justify-content: center; +} + +.pr-player-img.ph svg { + width: 22px; + height: 22px; + opacity: 0.3; + color: var(--sl-text-tertiary, #64748b); +} + +.pr-player-center { + flex: 1; + min-width: 0; + max-width: 200px; +} + +.pr-player-ep-title { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--sl-text-primary, #f1f5f9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pr-player-pod-name { + display: block; + font-size: 11px; + color: var(--sl-text-tertiary, #64748b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.pr-player-controls { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.pr-prev-btn, +.pr-next-btn { + width: 30px; + height: 30px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--sl-text-secondary, #94a3b8); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + +} + +.pr-prev-btn:hover, +.pr-next-btn:hover { + color: var(--sl-primary, #0ea5e9); + background: var(--sl-primary-bg, rgba(14,165,233,0.1)); +} + +.pr-toggle-play { + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: var(--sl-primary, #0ea5e9); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + +} + +.pr-toggle-play:hover { + filter: brightness(1.1);; +} + +.pr-player-seek { + flex: 1; + min-width: 80px; + display: flex; + align-items: center; + gap: 6px; +} + +.pr-time { + font-size: 11px; + color: var(--sl-text-tertiary, #64748b); + font-variant-numeric: tabular-nums; + white-space: nowrap; + min-width: 32px; + font-family: var(--sl-font-mono, monospace); +} + +.pr-seek-bar { + flex: 1; + height: 6px; + background: var(--sl-border, #334155); + border-radius: 99px; + cursor: pointer; + position: relative; +} + +.pr-seek-fill { + height: 100%; + background: var(--sl-primary, #60a5fa); + border-radius: 99px; + width: 0%; + box-shadow: 0 0 6px rgba(96, 165, 250, 0.3); + pointer-events: none; +} + +.pr-speed-btn { + min-width: 36px; + height: 30px; + font-size: 11px; + font-weight: 700; + font-family: var(--sl-font-mono, monospace); + color: var(--sl-text-tertiary, #64748b); + background: transparent; + border: 1px solid var(--sl-border, #334155); + border-radius: var(--sl-radius-sm, 6px); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px; + +} + +.pr-speed-btn:hover { + color: var(--sl-primary, #0ea5e9); + border-color: var(--sl-primary, #0ea5e9); +} + +/* ============================================================ + * 空状态 + * ============================================================ */ + +.pr-empty { + text-align: center; + padding: 40px 24px; + color: var(--sl-text-tertiary, #64748b); + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; +} + +.pr-empty-desc { + font-size: 14px; + line-height: 1.5; + margin: 0; + color: var(--sl-text-tertiary, #64748b); +} + +/* ============================================================ + * 添加播客弹窗 + * ============================================================ */ + +/* ---- 添加播客对话框(无遮罩、无动画) ---- */ +.pr-dialog { padding: 20px 0 0; } + +.pr-dialog-inner { + background: var(--sl-bg-secondary, #0f172a); + border: 1px solid var(--sl-border, #2a3344); + border-radius: 12px; + padding: 20px; +} + +.pr-dialog-title { + font-size: 15px; + font-weight: 600; + color: var(--sl-text-primary, #f1f5f9); + margin-bottom: 12px; +} + +.pr-input { + width: 100%; + padding: 10px 14px; + background: var(--sl-bg-secondary, #0f172a); + border: 1px solid var(--sl-border, #334155); + border-radius: 8px; + color: var(--sl-text-primary, #f1f5f9); + font-size: 14px; + outline: none; + box-sizing: border-box; +} + +.pr-input:focus { + border-color: var(--sl-primary, #0ea5e9); +} + +.pr-input::placeholder { + color: var(--sl-text-tertiary, #475569); +} + +.pr-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} + +/* ============================================================ + * Toast(由 sl.ui.toast 原生提供,这里做备选) + * ============================================================ */ + +/* 已由 sl.ui.toast 系统处理,无需额外样式 */ + +/* ============================================================ + * 响应式 + * ============================================================ */ + +@media (max-width: 600px) { + .pr-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; + } + .pr-player-center { max-width: 120px; } + .pr-player-seek { min-width: 60px; } + .pr-welcome { padding: 48px 20px 36px; } + .pr-welcome-icon { width: 72px; height: 72px; } + .pr-welcome-title { font-size: 19px; } +} + +@media (prefers-reduced-motion: reduce) { + .pr-card, + .pr-primary-btn, + .pr-play-btn, + .pr-toggle-play, + .pr-ep, + .pr-remove-btn, + .pr-prev-btn, + .pr-next-btn, + .pr-speed-btn, + .pr-seek-fill { } + .pr-card:hover {; } + .pr-primary-btn:hover {; } + .pr-play-btn:hover {; } + .pr-toggle-play:hover {; } +} + +/* ---- 播客作者和简介 ---- */ +.pr-pod-info { + padding: 8px 12px; margin: 4px 0; + border-left: 2px solid var(--sl-primary, #0ea5e9); + display: flex; flex-direction: column; gap: 4px; +} +.pr-pod-author { font-size: 12px; font-weight: 600; color: var(--sl-text-secondary, #94a3b8); } +.pr-pod-desc { font-size: 12px; color: var(--sl-text-tertiary, #64748b); line-height: 1.5; } + +/* ---- 剧集简介 ---- */ +.pr-ep-desc { + display: block; font-size: 11px; color: var(--sl-text-tertiary, #64748b); + line-height: 1.4; margin-top: 4px; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +}