// WebHome extension for https://www.ymvid.com/ // Enhancements: // - Adds a native App play panel on play pages. // - Resolves the current m3u8 from the site's own decrypted player data. // - Removes first-view popups and player ad layers. // - Improves mobile layout and TV remote focus for cards and episode links. (function () { const CONFIG = { rootClass: "fm-ymvid-enhanced", tvClass: "fm-ymvid-tv", panelId: "fm-ymvid-panel", episodeId: "fm-ymvid-episodes", mediaEvent: "fm-ymvid-media", mediaRequestEvent: "fm-ymvid-request-media", primaryClass: "fm-ymvid-primary", statusClass: "fm-ymvid-status", focusClass: "fm-ymvid-focus", scanDelay: 140, focusSelector: [ "a[href]", "button", "input", "select", "textarea", "[role='button']", ".grid-content", ".feature-post-box", ".swiper-slide", ".item-row", ".aside-body li", ".play-list .item a", "#fm-ymvid-episodes a", ".tabs-nav-link" ].join(",") }; const state = { scanTimer: 0, focusRaf: 0, inputEditing: false, lastPath: "", lastMediaUrl: "", lastEpisodeKey: "", nativeBusy: false, artplayerWrapped: false, hlsWrapped: false, pageBridgeInjected: false, mediaResolvers: {} }; installArtplayerHook(); installHlsHook(); installPageBridge(); window.__fmYmvidResolveEpisode = resolveInlineEpisode; window.__fmWebHomeInlineResolver = resolveInlineEpisode; injectBaseStyle(); ready(init); window.addEventListener(CONFIG.mediaEvent, handleMediaEvent, false); window.addEventListener("fmurlchange", scheduleScan); window.addEventListener("popstate", scheduleScan); window.addEventListener("focusin", handleFocusIn, true); window.addEventListener("focusout", handleFocusOut, true); function log() { const args = Array.prototype.slice.call(arguments); if (typeof GM_log === "function") GM_log.apply(null, args); else console.log.apply(console, ["[fm-ymvid]"].concat(args)); } function ready(fn) { if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn, { once: true }); else fn(); } function whenFm() { if (window.fm) return Promise.resolve(window.fm); return new Promise((resolve) => window.addEventListener("fmsdk", () => resolve(window.fm), { once: true })); } function isTv() { return !!(window.fongmiClient && window.fongmiClient.isLeanback); } function cleanText(value) { return String(value || "").replace(/\s+/g, " ").trim(); } function absoluteUrl(url) { const value = cleanText(url); if (!value || value === "#" || /^javascript:/i.test(value)) return ""; try { return new URL(value, location.href).href; } catch (e) { return value; } } function init() { document.body.classList.add(CONFIG.rootClass); if (isTv()) document.body.classList.add(CONFIG.tvClass); observeDom(); scheduleScan(); } function scheduleScan() { clearTimeout(state.scanTimer); state.scanTimer = setTimeout(enhancePage, CONFIG.scanDelay); } function enhancePage() { if (!document.body) return; document.body.classList.add(CONFIG.rootClass); document.body.classList.toggle(CONFIG.tvClass, isTv()); if (location.pathname !== state.lastPath) { state.lastPath = location.pathname; state.lastMediaUrl = ""; state.lastEpisodeKey = ""; } resetMediaCacheIfEpisodeChanged(); clearPopups(); enhanceCards(); enhanceEpisodeList(); enhancePlayPage(); } function observeDom() { const observer = new MutationObserver(() => { clearPopups(); scheduleScan(); }); observer.observe(document.documentElement || document.body, { childList: true, subtree: true }); } function injectBaseStyle() { const css = ` body.${CONFIG.rootClass} .first-pop-layer, body.${CONFIG.rootClass} .detail-ads-img, body.${CONFIG.rootClass} .ads-view-btn, body.${CONFIG.rootClass} .side_toolbar_code { display: none !important; } body.${CONFIG.rootClass} .fm-ymvid-hidden { display: none !important; } body.${CONFIG.rootClass} #${CONFIG.panelId} { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 12px; background: #f7f8fa; color: #1f2937; } body.${CONFIG.rootClass} #${CONFIG.panelId} button { border: 0; min-height: 38px; padding: 0 14px; border-radius: 8px; font-size: 14px; font-weight: 700; line-height: 38px; white-space: nowrap; } body.${CONFIG.rootClass} .${CONFIG.primaryClass} { background: #16a34a; color: #fff; } body.${CONFIG.rootClass} .fm-ymvid-secondary { background: #e5e7eb; color: #111827; } body.${CONFIG.rootClass} .${CONFIG.statusClass} { flex: 1; min-width: 0; color: #4b5563; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } body.${CONFIG.rootClass} #${CONFIG.episodeId} { margin-top: 10px; padding: 12px; background: #fff; color: #111827; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-title { flex: 1; min-width: 0; font-size: 15px; font-weight: 800; line-height: 22px; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-current { color: #64748b; font-size: 12px; line-height: 20px; white-space: nowrap; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); gap: 8px; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-grid a { display: flex; align-items: center; justify-content: center; min-width: 0; min-height: 40px; padding: 0 8px; border-radius: 8px; background: #f1f5f9; color: #111827; font-size: 14px; font-weight: 800; line-height: 40px; text-align: center; white-space: nowrap; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-grid a.fm-ymvid-active { background: #16a34a; color: #fff; } body.${CONFIG.rootClass} .fm-ymvid-quick { display: inline-flex; align-items: center; justify-content: center; min-width: 52px; min-height: 30px; margin-left: 8px; padding: 0 10px; border: 0; border-radius: 8px; background: #16a34a; color: #fff; font-size: 13px; font-weight: 700; } body.${CONFIG.tvClass} .${CONFIG.focusClass}, body.${CONFIG.tvClass} a:focus-visible, body.${CONFIG.tvClass} button:focus-visible, body.${CONFIG.tvClass} input:focus-visible { outline: 3px solid #f59e0b !important; outline-offset: 3px !important; } body.${CONFIG.tvClass} .grid-content.${CONFIG.focusClass}, body.${CONFIG.tvClass} .feature-post-box.${CONFIG.focusClass}, body.${CONFIG.tvClass} .item-row.${CONFIG.focusClass}, body.${CONFIG.tvClass} .aside-body li.${CONFIG.focusClass} { transform: translateY(-2px); } body.${CONFIG.tvClass} #${CONFIG.panelId} button { min-height: 48px; padding: 0 20px; font-size: 16px; } @media (max-width: 760px) { body.${CONFIG.rootClass} { background: #f7f8fa !important; } body.${CONFIG.rootClass} .header { position: sticky; top: 0; z-index: 20; } body.${CONFIG.rootClass} #main { padding-bottom: 60px; } body.${CONFIG.rootClass} .side-toolbar, body.${CONFIG.rootClass} .comment-section, body.${CONFIG.rootClass} #footer, body.${CONFIG.rootClass} .toolbar, body.${CONFIG.rootClass} .search-play-aside { display: none !important; } body.${CONFIG.rootClass} .section-content, body.${CONFIG.rootClass} .section-container, body.${CONFIG.rootClass} .section-main, body.${CONFIG.rootClass} .main-left, body.${CONFIG.rootClass} .play-aside { width: 100% !important; max-width: none !important; margin-left: 0 !important; margin-right: 0 !important; } body.${CONFIG.rootClass} .player-section { padding: 0 !important; margin: 0 !important; background: #0f172a !important; } body.${CONFIG.rootClass} #player { width: 100% !important; aspect-ratio: 16 / 9; min-height: 210px; background: #0f172a !important; } body.${CONFIG.rootClass} #${CONFIG.panelId} { margin: 0; padding: 10px 12px 12px; background: #fff; } body.${CONFIG.rootClass} #${CONFIG.panelId} button { min-height: 40px; border-radius: 8px; line-height: 40px; } body.${CONFIG.rootClass} article.section-container { display: grid !important; grid-template-columns: 92px 1fr; column-gap: 12px; row-gap: 8px; padding: 14px 12px !important; background: #fff !important; } body.${CONFIG.rootClass} article.section-container .media-thumb { width: 92px !important; height: 128px !important; margin: 0 !important; } body.${CONFIG.rootClass} article.section-container .media-thumb img { width: 100% !important; height: 100% !important; object-fit: cover; border-radius: 8px; } body.${CONFIG.rootClass} article.section-container .media-info { min-width: 0; padding: 0 !important; } body.${CONFIG.rootClass} article.section-container .media-info h1 { margin: 0 0 6px !important; font-size: 18px !important; line-height: 1.25 !important; color: #111827 !important; } body.${CONFIG.rootClass} article.section-container .type-row, body.${CONFIG.rootClass} article.section-container .play-count-row { display: inline-flex !important; align-items: center; margin: 0 6px 6px 0 !important; padding: 2px 7px !important; border-radius: 6px; background: #eef2f7; color: #374151 !important; font-size: 12px !important; line-height: 20px !important; } body.${CONFIG.rootClass} article.section-container .intro-row { grid-column: 1 / -1; margin: 2px 0 0 !important; color: #4b5563 !important; } body.${CONFIG.rootClass} article.section-container .intro-detail { max-height: none !important; margin: 0 !important; font-size: 13px !important; line-height: 1.65 !important; } body.${CONFIG.rootClass} .intro-mobile-mask, body.${CONFIG.rootClass} .intro-detail-container { display: none !important; } body.${CONFIG.rootClass} .play-aside { padding: 0 12px 12px !important; background: #fff !important; } body.${CONFIG.rootClass}.fm-ymvid-has-native-episodes .play-aside { display: none !important; } body.${CONFIG.rootClass} .play-aside .right-aside:first-child { margin: 0 !important; padding: 0 !important; background: transparent !important; } body.${CONFIG.rootClass} .play-aside .right-aside:not(:first-child) { display: none !important; } body.${CONFIG.rootClass} .play-aside .header-box { display: flex !important; align-items: center; justify-content: space-between; margin: 0 0 8px !important; } body.${CONFIG.rootClass} .play-aside .header-box .title { font-size: 15px !important; font-weight: 800 !important; color: #111827 !important; } body.${CONFIG.rootClass} #${CONFIG.episodeId} { margin: 0; padding: 12px; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-grid { grid-template-columns: repeat(auto-fill, minmax(46px, 1fr)); gap: 8px; } body.${CONFIG.rootClass} #${CONFIG.episodeId} .fm-ymvid-episode-grid a { min-height: 42px; line-height: 42px; } body.${CONFIG.rootClass} .live-anime { display: none !important; } body.${CONFIG.rootClass} .swiper-section { display: none !important; } body.${CONFIG.rootClass} .section-title { padding: 0 12px !important; margin: 14px 0 8px !important; } body.${CONFIG.rootClass} .el-row { display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 8px; width: 100% !important; max-width: none !important; margin: 0 !important; padding: 0 12px !important; } body.${CONFIG.rootClass} .el-col, body.${CONFIG.rootClass} .el-col-adapt { width: auto !important; max-width: none !important; min-width: 0 !important; padding: 0 !important; float: none !important; } body.${CONFIG.rootClass} .grid-content { width: 100% !important; max-width: none !important; background: transparent !important; } body.${CONFIG.rootClass} .grid-content .img-container, body.${CONFIG.rootClass} .grid-content .img-box, body.${CONFIG.rootClass} .grid-content .img-box a { display: block !important; width: 100% !important; max-width: none !important; min-width: 0 !important; margin: 0 !important; } body.${CONFIG.rootClass} .grid-content .img-container { position: relative; aspect-ratio: 3 / 4; border-radius: 8px; overflow: hidden; background: #e5e7eb; } body.${CONFIG.rootClass} .grid-content .img-box, body.${CONFIG.rootClass} .grid-content .img-box a { height: 100% !important; } body.${CONFIG.rootClass} .grid-content .img-container img { display: block; width: 100% !important; height: 100% !important; object-fit: cover; } body.${CONFIG.rootClass} .grid-content .title { margin-top: 5px !important; font-size: 13px !important; line-height: 1.35 !important; } body.${CONFIG.rootClass} .grid-content .title a { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } body.${CONFIG.rootClass} .search-list .item-row, body.${CONFIG.rootClass} .ranking-content .item-row { display: grid !important; grid-template-columns: 28px 82px 1fr; gap: 10px; padding: 12px !important; background: #fff !important; } body.${CONFIG.rootClass} .item-row .item-thumb img { width: 82px !important; height: 112px !important; object-fit: cover; border-radius: 8px; } body.${CONFIG.rootClass} .item-row .item-title { font-size: 16px !important; line-height: 1.3 !important; } body.${CONFIG.rootClass} .item-row .item-sub, body.${CONFIG.rootClass} .item-row .item-desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 12px !important; line-height: 1.5 !important; } } @media (max-width: 340px) { body.${CONFIG.rootClass} .el-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } } `; addStyle(css); } function addStyle(css) { if (typeof GM_addStyle === "function") { GM_addStyle(css); return; } const style = document.createElement("style"); style.textContent = css; const target = document.head || document.documentElement; if (target) target.appendChild(style); } function handleMediaEvent(event) { try { const detail = event && event.detail; const requestId = detail && detail.requestId; const url = typeof detail === "string" ? detail : detail && detail.url; if (requestId && state.mediaResolvers[requestId]) { state.mediaResolvers[requestId](playableMediaUrl(url)); delete state.mediaResolvers[requestId]; return; } const source = detail && detail.source || "page"; rememberPlayerUrl(url, source); } catch (e) { log("media event skipped", e && e.message || e); } } function installArtplayerHook() { wrapArtplayerIfReady(); if (state.artplayerWrapped) return; try { let current = window.Artplayer; Object.defineProperty(window, "Artplayer", { configurable: true, get: function () { return current; }, set: function (value) { current = wrapArtplayer(value); } }); } catch (e) { log("artplayer hook skipped", e && e.message || e); } } function wrapArtplayerIfReady() { if (typeof window.Artplayer !== "function") return; window.Artplayer = wrapArtplayer(window.Artplayer); } function wrapArtplayer(Original) { if (typeof Original !== "function" || Original.__fmYmvidWrapped) return Original; function WrappedArtplayer() { const args = Array.prototype.slice.call(arguments); rememberPlayerUrl(args[0] && args[0].url, "artplayer"); return Reflect.construct(Original, args, WrappedArtplayer); } try { Object.setPrototypeOf(WrappedArtplayer, Original); WrappedArtplayer.prototype = Original.prototype; Object.defineProperty(WrappedArtplayer, "__fmYmvidWrapped", { value: true }); } catch (e) { // Best effort only. The wrapper still captures constructor options. } state.artplayerWrapped = true; return WrappedArtplayer; } function installHlsHook() { wrapHlsIfReady(); if (state.hlsWrapped) return; try { let current = window.Hls; Object.defineProperty(window, "Hls", { configurable: true, get: function () { return current; }, set: function (value) { current = wrapHls(value); } }); } catch (e) { log("hls hook skipped", e && e.message || e); } } function wrapHlsIfReady() { if (typeof window.Hls !== "function") return; window.Hls = wrapHls(window.Hls); } function wrapHls(Original) { if (typeof Original !== "function" || Original.__fmYmvidWrapped) return Original; const proto = Original.prototype; if (proto && typeof proto.loadSource === "function" && !proto.loadSource.__fmYmvidWrapped) { const loadSource = proto.loadSource; proto.loadSource = function () { rememberPlayerUrl(arguments[0], "hls"); return loadSource.apply(this, arguments); }; try { Object.defineProperty(proto.loadSource, "__fmYmvidWrapped", { value: true }); } catch (e) { // Best effort only. } } try { Object.defineProperty(Original, "__fmYmvidWrapped", { value: true }); } catch (e) { // Best effort only. } state.hlsWrapped = true; return Original; } function installPageBridge() { if (state.pageBridgeInjected) return; state.pageBridgeInjected = true; try { const script = document.createElement("script"); script.textContent = "(" + function (mediaEvent, requestEvent) { if (window.__fmYmvidPageBridge) { window.dispatchEvent(new Event(requestEvent)); return; } window.__fmYmvidPageBridge = true; function clean(value) { return String(value || "").replace(/\s+/g, " ").trim(); } function absoluteUrl(url) { const value = clean(url); if (!value || value === "#" || /^javascript:/i.test(value)) return ""; try { return new URL(value, location.href).href; } catch (e) { return value; } } function emit(url, source, requestId) { const resolved = absoluteUrl(url); if (!resolved) return; if (!/^https?:\/\//i.test(resolved) && !/\.m3u8(?:[?#]|$)/i.test(resolved)) return; window.dispatchEvent(new CustomEvent(mediaEvent, { detail: { url: resolved, source: source || "page", requestId: requestId || "" } })); } function buildMediaUrlFromParts(encrypted, listId, videoId) { if (!encrypted || !listId || !videoId) return ""; let decrypted = ""; try { decrypted = clean(window.decryptByAES(encrypted)); } catch (e) { return ""; } const marker = "?t="; const index = decrypted.indexOf(marker); if (index < 0) return ""; const base = decrypted.slice(0, index); const token = decrypted.slice(index + marker.length); const listParts = String(listId || "").split("-"); const seriesId = listParts[0] || ""; const route = listParts[1] || "0"; return route === "0" || !seriesId ? base + "?t=" + token + "&vId=" + videoId : base + "/" + seriesId + "?t=" + token + "&vId=" + videoId; } function buildMediaUrl() { const list = document.querySelector(".play-list"); const input = document.querySelector(".section-content > input[type='hidden'],.section-content > input"); const main = document.getElementById("main"); if (!list || !input || !main || typeof window.decryptByAES !== "function") return ""; return buildMediaUrlFromParts(clean(input.value), clean(list.getAttribute("data-id")), clean(main.getAttribute("data-id"))); } function computeAndEmit(event) { const detail = event && event.detail; if (detail && detail.requestId) { emit(buildMediaUrlFromParts(clean(detail.encrypted), clean(detail.listId), clean(detail.videoId)), "decrypt", detail.requestId); return; } const url = buildMediaUrl(); if (url) emit(url, "compute"); const video = document.querySelector("#player video,video"); if (video) emit(video.currentSrc || video.src, "video"); } function poll() { let count = 0; const tick = function () { computeAndEmit(); count += 1; if (count < 80) setTimeout(tick, 150); }; tick(); } function wrapArtplayer(Original) { if (typeof Original !== "function" || Original.__fmYmvidPageWrapped) return Original; function WrappedArtplayer() { const args = Array.prototype.slice.call(arguments); emit(args[0] && args[0].url, "artplayer"); return Reflect.construct(Original, args, WrappedArtplayer); } try { Object.setPrototypeOf(WrappedArtplayer, Original); WrappedArtplayer.prototype = Original.prototype; Object.defineProperty(WrappedArtplayer, "__fmYmvidPageWrapped", { value: true }); } catch (e) { // Best effort only. } return WrappedArtplayer; } function wrapHls(Original) { if (typeof Original !== "function" || Original.__fmYmvidPageWrapped) return Original; const proto = Original.prototype; if (proto && typeof proto.loadSource === "function" && !proto.loadSource.__fmYmvidPageWrapped) { const loadSource = proto.loadSource; proto.loadSource = function () { emit(arguments[0], "hls"); return loadSource.apply(this, arguments); }; try { Object.defineProperty(proto.loadSource, "__fmYmvidPageWrapped", { value: true }); } catch (e) { // Best effort only. } } try { Object.defineProperty(Original, "__fmYmvidPageWrapped", { value: true }); } catch (e) { // Best effort only. } return Original; } function installConstructorHooks() { try { let artplayer = window.Artplayer; Object.defineProperty(window, "Artplayer", { configurable: true, get: function () { return artplayer; }, set: function (value) { artplayer = wrapArtplayer(value); } }); if (typeof artplayer === "function") window.Artplayer = artplayer; } catch (e) { // Best effort only. } try { let hls = window.Hls; Object.defineProperty(window, "Hls", { configurable: true, get: function () { return hls; }, set: function (value) { hls = wrapHls(value); } }); if (typeof hls === "function") window.Hls = hls; } catch (e) { // Best effort only. } } installConstructorHooks(); window.addEventListener(requestEvent, computeAndEmit, false); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", poll, { once: true }); else poll(); } + ")(" + JSON.stringify(CONFIG.mediaEvent) + "," + JSON.stringify(CONFIG.mediaRequestEvent) + ");"; const target = document.head || document.documentElement; if (target) { target.appendChild(script); if (script.parentNode) script.parentNode.removeChild(script); } } catch (e) { state.pageBridgeInjected = false; log("page bridge skipped", e && e.message || e); } } function requestPageMediaUrl() { try { window.dispatchEvent(new CustomEvent(CONFIG.mediaRequestEvent)); } catch (e) { // Best effort only. } } function playableMediaUrl(url) { const resolved = absoluteUrl(url); if (!resolved || /^(?:blob|data):/i.test(resolved)) return ""; if (/\.m3u8(?:[?#]|$)/i.test(resolved)) return resolved; if (/^https?:\/\//i.test(resolved) && !/\.(?:avif|css|gif|jpe?g|js|png|svg|webp)(?:[?#]|$)/i.test(resolved)) return resolved; return ""; } function rememberPlayerUrl(url, source) { const resolved = playableMediaUrl(url); if (resolved) { if (resolved === state.lastMediaUrl) return; state.lastMediaUrl = resolved; updatePanelStatus("已捕获播放地址"); log("captured media", source || "unknown", resolved); } } function clearPopups() { const selectors = [ ".first-pop-layer", ".art-layer-ads", ".ads-view-btn", ".detail-ads-img" ]; selectors.forEach((selector) => { const nodes = document.querySelectorAll(selector); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const adLayer = node.closest && (node.closest(".art-layer") || node.closest(".first-pop-layer")); const target = adLayer || node; if (target && target.parentNode) target.parentNode.removeChild(target); } }); } function enhancePlayPage() { if (!isPlayPage()) { removeNativeEpisodes(); return; } const playerSection = document.querySelector(".player-section") || document.getElementById("player"); if (!playerSection) return; let panel = document.getElementById(CONFIG.panelId); if (!panel) { panel = document.createElement("div"); panel.id = CONFIG.panelId; panel.innerHTML = [ '', '', '准备当前集' ].join(""); const primary = panel.querySelector("." + CONFIG.primaryClass); const refresh = panel.querySelector(".fm-ymvid-secondary"); primary.addEventListener("click", function () { nativePlay(); }); refresh.addEventListener("click", function () { state.lastMediaUrl = ""; updatePanelStatus(currentEpisodeText() || "已刷新"); requestPageMediaUrl(); scheduleScan(); }); playerSection.appendChild(panel); } updatePanelStatus(currentEpisodeText() || "当前集"); requestPageMediaUrl(); renderNativeEpisodes(); } function isPlayPage() { return /^\/(?:hk\/)?play\/\d+(?:\/\d+)?/i.test(location.pathname) && !!document.getElementById("main") && !!document.querySelector(".player-section,#player") && !!encryptedPlayerValue(); } function currentEpisodeText() { const active = document.querySelector(".play-list .item.active a em,.play-list .item.active a"); const episode = cleanText(active && active.textContent); if (!episode) return ""; return "当前第 " + episode + " 集"; } function pageTitle() { const title = document.querySelector("article .media-info h1,.media-info h1,h1"); const text = cleanText(title && title.textContent); const episode = currentEpisodeText(); return [text || cleanText(document.title.replace(/\s*\|\s*粤漫之家\s*$/i, "")), episode].filter(Boolean).join(" "); } async function nativePlay(selectedEpisode) { if (state.nativeBusy) return; state.nativeBusy = true; updatePanelBusy(true); try { resetMediaCacheIfEpisodeChanged(); const sdk = await whenFm(); if (sdk.vodInline) { const payload = await buildInlineVodPayload(sdk, selectedEpisode); if (payload && payload.episodes && payload.episodes.length) { updatePanelStatus(selectedEpisode && selectedEpisode.label ? "打开 " + selectedEpisode.label : "打开 App 播放列表"); return sdk.vodInline(payload); } } const mediaUrl = await waitForMediaUrl(6000, { allowCached: false }); if (!mediaUrl) { updatePanelStatus("未取得播放地址"); toast("播放地址未准备好"); return; } updatePanelStatus("交给 App 播放"); return sdk.play(mediaUrl, pageTitle(), { headers: { Referer: location.href, "User-Agent": navigator.userAgent }, credentials: "include" }); } catch (error) { log("native play failed", error && (error.stack || error.message) || error); toast("调用 App 播放失败"); updatePanelStatus("App 播放失败"); } finally { state.nativeBusy = false; updatePanelBusy(false); } } async function buildInlineVodPayload(sdk, selectedEpisode) { const episodes = collectEpisodes(); if (!episodes.length) return null; const title = resourceTitle(); const pic = posterUrl(); const selected = normalizeEpisodeSelection(selectedEpisode); const selectedPath = selected ? normalizePath(selected.href) : ""; const current = selectedPath ? episodes.find((item) => normalizePath(item.href) === selectedPath) || selected : episodes.find((item) => item.active) || episodes[0]; const currentPath = current ? normalizePath(current.href) : ""; const inlineEpisodes = episodes.map((item) => ({ name: item.label, label: item.label, active: currentPath ? normalizePath(item.href) === currentPath : !!item.active, url: item.href, pageUrl: item.href, resolve: true, credentials: "include", headers: { Referer: item.href, "User-Agent": navigator.userAgent } })); return { vod_id: "ymvid-" + (mainVideoId() || normalizePath(location.href)), vod_name: title, title: title, vod_pic: pic, pic: pic, vod_content: cleanText(document.querySelector(".intro-detail,.intro-row") && document.querySelector(".intro-detail,.intro-row").textContent), vod_play_from: "粤漫之家", mark: current && current.label || "", credentials: "include", headers: { Referer: location.href, "User-Agent": navigator.userAgent }, episodes: inlineEpisodes }; } function normalizeEpisodeSelection(episode) { if (!episode) return null; const href = absoluteUrl(episode.href || episode.url || episode.pageUrl); if (!href) return null; return { href: href, label: cleanText(episode.label || episode.name || episode.title) }; } async function resolveInlineEpisode(payload) { const data = payload || {}; const sdk = await whenFm(); const pageUrl = absoluteUrl(data.pageUrl || data.url || data.href || location.href); if (!pageUrl) throw new Error("missing episode page"); const samePage = normalizePath(pageUrl) === normalizePath(location.href); let mediaUrl = ""; if (!mediaUrl) { const parts = samePage ? currentEpisodeParts() : await fetchEpisodeParts(pageUrl, sdk); mediaUrl = parts ? await decryptEpisodeParts(parts) : ""; } if (!mediaUrl && samePage) mediaUrl = currentMediaUrl({ allowCached: false }) || await waitForMediaUrl(5000, { allowCached: false }); mediaUrl = playableMediaUrl(mediaUrl); if (!mediaUrl) throw new Error("episode media unavailable"); return { url: mediaUrl, format: "application/x-mpegURL", credentials: "include", headers: { Referer: pageUrl, "User-Agent": navigator.userAgent } }; } async function fetchEpisodeParts(url, sdk) { const response = await sdk.req(url, { headers: { Referer: location.href, "User-Agent": navigator.userAgent }, credentials: "include", timeout: 20 }); if (!response || !response.ok || !response.body) return null; return parseEpisodeHtml(response.body, url); } function currentEpisodeParts() { const encrypted = encryptedPlayerValue(); const listId = playListId(); const videoId = mainVideoId(); if (!encrypted || !listId || !videoId) return null; return { encrypted: encrypted, listId: listId, videoId: videoId }; } function parseEpisodeHtml(html, pageUrl) { const text = String(html || ""); const sectionIndex = text.search(/]*\bsection-content\b/i); const section = sectionIndex >= 0 ? text.slice(sectionIndex, sectionIndex + 1600) : text; const encrypted = attrFrom(section, /]*\btype=(["'])hidden\1)[^>]*\bvalue=(["'])(.*?)\2[^>]*>/i, 3) || attrFrom(section, /]*\bvalue=(["'])(.*?)\1[^>]*>/i, 2); const mainTag = tagById(text, "main"); const listTag = tagByClass(text, "play-list"); const videoId = attrFrom(mainTag, /\bdata-id=(["'])(.*?)\1/i, 2) || mainVideoId(); const listId = attrFrom(listTag, /\bdata-id=(["'])(.*?)\1/i, 2); if (!encrypted || !listId || !videoId) { log("episode parts missing", pageUrl, !!encrypted, listId, videoId); return null; } return { encrypted: htmlDecode(encrypted), listId: htmlDecode(listId), videoId: htmlDecode(videoId) }; } function attrFrom(text, pattern, group) { const match = String(text || "").match(pattern); return cleanText(match && match[group || 1]); } function tagById(html, id) { const pattern = new RegExp("<[^>]+\\bid=[\"']" + escapeRegex(id) + "[\"'][^>]*>", "i"); const match = String(html || "").match(pattern); return match ? match[0] : ""; } function tagByClass(html, className) { const pattern = new RegExp("<[^>]+\\bclass=[\"'][^\"']*\\b" + escapeRegex(className) + "\\b[^\"']*[\"'][^>]*>", "i"); const match = String(html || "").match(pattern); return match ? match[0] : ""; } function htmlDecode(value) { const text = String(value || ""); if (text.indexOf("&") < 0) return text; const box = document.createElement("textarea"); box.innerHTML = text; return box.value; } function escapeRegex(value) { return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function decryptEpisodeParts(parts) { if (!parts || !parts.encrypted || !parts.listId || !parts.videoId) return Promise.resolve(""); const requestId = "fm_ymvid_media_" + Date.now() + "_" + Math.random().toString(36).slice(2); return new Promise((resolve) => { const timer = setTimeout(() => { if (state.mediaResolvers[requestId]) { delete state.mediaResolvers[requestId]; resolve(""); } }, 5000); state.mediaResolvers[requestId] = function (url) { clearTimeout(timer); resolve(url || ""); }; try { window.dispatchEvent(new CustomEvent(CONFIG.mediaRequestEvent, { detail: { requestId: requestId, encrypted: parts.encrypted, listId: parts.listId, videoId: parts.videoId } })); } catch (e) { clearTimeout(timer); delete state.mediaResolvers[requestId]; resolve(""); } }); } function mainVideoId() { const main = document.getElementById("main"); return cleanText(main && main.getAttribute("data-id")); } function resourceTitle() { const title = document.querySelector("article .media-info h1,.media-info h1,h1"); return cleanText(title && title.textContent) || cleanText(document.title.replace(/\s*\|\s*粤漫之家\s*$/i, "")); } function posterUrl() { const image = document.querySelector("article .media-thumb img,.media-thumb img,.grid-content img"); return image ? absoluteUrl(image.getAttribute("src") || image.src) : ""; } function updatePanelBusy(busy) { const panel = document.getElementById(CONFIG.panelId); if (!panel) return; const button = panel.querySelector("." + CONFIG.primaryClass); if (button) button.textContent = busy ? "处理中" : "App播放"; } function updatePanelStatus(message) { const panel = document.getElementById(CONFIG.panelId); if (!panel) return; const status = panel.querySelector("." + CONFIG.statusClass); if (status && message) status.textContent = message; } function waitForMediaUrl(timeout, options) { const deadline = Date.now() + timeout; return new Promise((resolve) => { const tick = function () { requestPageMediaUrl(); const mediaUrl = currentMediaUrl(options); if (mediaUrl || Date.now() >= deadline) { resolve(mediaUrl); return; } setTimeout(tick, 120); }; tick(); }); } function currentMediaUrl(options) { const allowCached = !options || options.allowCached !== false; const computed = computeMediaUrl(); if (computed) { state.lastMediaUrl = computed; return computed; } if (allowCached && state.lastMediaUrl) return state.lastMediaUrl; const video = document.querySelector("#player video,video"); if (video && video.currentSrc) return playableMediaUrl(video.currentSrc); if (video && video.src) return playableMediaUrl(video.src); return ""; } function resetMediaCacheIfEpisodeChanged() { const key = [normalizePath(location.href), encryptedPlayerValue(), playListId(), mainVideoId()].join("|"); if (!key || key === state.lastEpisodeKey) return; state.lastEpisodeKey = key; state.lastMediaUrl = ""; } function computeMediaUrl() { const encrypted = encryptedPlayerValue(); const listId = playListId(); const main = document.getElementById("main"); const videoId = main && main.getAttribute("data-id"); if (!encrypted || !listId || !videoId || typeof window.decryptByAES !== "function") return ""; let decrypted = ""; try { decrypted = window.decryptByAES(encrypted); } catch (e) { log("decrypt failed", e && e.message || e); } if (!decrypted) return ""; const marker = "?t="; const index = decrypted.indexOf(marker); if (index < 0) return ""; const base = decrypted.slice(0, index); const token = decrypted.slice(index + marker.length); const listParts = String(listId || "").split("-"); const seriesId = listParts[0] || ""; const route = listParts[1] || "0"; const url = route === "0" || !seriesId ? base + "?t=" + token + "&vId=" + videoId : base + "/" + seriesId + "?t=" + token + "&vId=" + videoId; return playableMediaUrl(url); } function encryptedPlayerValue() { const input = document.querySelector(".section-content > input[type='hidden'],.section-content > input"); return input ? cleanText(input.value) : ""; } function playListId() { const list = document.querySelector(".play-list"); return list ? cleanText(list.getAttribute("data-id")) : ""; } function renderNativeEpisodes() { const episodes = collectEpisodes(); if (!episodes.length) { removeNativeEpisodes(); return; } let container = document.getElementById(CONFIG.episodeId); if (!container) { container = document.createElement("section"); container.id = CONFIG.episodeId; const panel = document.getElementById(CONFIG.panelId); const playerSection = document.querySelector(".player-section") || document.getElementById("player"); if (panel && panel.parentNode) panel.parentNode.insertBefore(container, panel.nextSibling); else if (playerSection) playerSection.appendChild(container); else return; } const signature = episodes.map((item) => [item.href, item.label, item.active ? "1" : "0"].join("#")).join("|"); if (container.dataset.fmYmvidSignature !== signature) { container.dataset.fmYmvidSignature = signature; const current = episodes.find((item) => item.active); container.innerHTML = [ '
', '剧集', '' + escapeHtml(current ? "当前 " + current.label : "") + '', '
', '
', episodes.map((item) => { const activeClass = item.active ? " fm-ymvid-active" : ""; return '' + escapeHtml(item.label) + ''; }).join(""), '
' ].join(""); } document.body.classList.add("fm-ymvid-has-native-episodes"); const links = container.querySelectorAll("a[href]"); for (let i = 0; i < links.length; i++) { bindEpisodeLink(links[i]); } const active = container.querySelector(".fm-ymvid-active"); if (active && active.scrollIntoView && !active.dataset.fmYmvidSeen) { active.dataset.fmYmvidSeen = "1"; setTimeout(() => active.scrollIntoView({ block: "nearest", inline: "center" }), 180); } } function removeNativeEpisodes() { const container = document.getElementById(CONFIG.episodeId); if (container && container.parentNode) container.parentNode.removeChild(container); if (document.body) document.body.classList.remove("fm-ymvid-has-native-episodes"); } function collectEpisodes() { const links = document.querySelectorAll(".play-list .item a[href]"); const seen = {}; const episodes = []; for (let i = 0; i < links.length; i++) { const link = links[i]; const href = absoluteUrl(link.getAttribute("href")); if (!href || seen[href]) continue; seen[href] = true; const em = link.querySelector("em"); const rawLabel = cleanText(em && em.textContent || link.textContent); const label = rawLabel || String(i + 1).padStart(2, "0"); const item = link.closest && link.closest(".item"); const active = !!(item && item.classList && item.classList.contains("active")) || normalizePath(href) === normalizePath(location.href); episodes.push({ href: href, label: label, active: active }); } return episodes; } function normalizePath(url) { try { const parsed = new URL(url, location.href); return parsed.pathname.replace(/\/+$/, ""); } catch (e) { return String(url || "").replace(/\/+$/, ""); } } function escapeHtml(value) { return String(value || "").replace(/[&<>"']/g, function (char) { return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]; }); } function escapeAttr(value) { return escapeHtml(value).replace(/`/g, "`"); } function enhanceEpisodeList() { const links = document.querySelectorAll(".play-list .item a[href]"); for (let i = 0; i < links.length; i++) { bindEpisodeLink(links[i]); } const active = document.querySelector(".play-list .item.active a"); if (active && active.scrollIntoView && !active.dataset.fmYmvidSeen) { active.dataset.fmYmvidSeen = "1"; setTimeout(() => active.scrollIntoView({ block: "nearest", inline: "center" }), 180); } } function bindEpisodeLink(link) { if (!link || link.dataset.fmYmvidEpisode === "1") return; link.dataset.fmYmvidEpisode = "1"; link.setAttribute("tabindex", "0"); link.addEventListener("click", function (event) { handleEpisodeClick(link, event); }); link.addEventListener("keydown", function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleEpisodeClick(link, event); } }); } function handleEpisodeClick(link, event) { if (!canNativePlay()) return false; if (event && (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button > 0)) return false; const episode = episodeFromLink(link); if (!episode) return false; if (event) { event.preventDefault(); event.stopPropagation(); } nativePlay(episode); return true; } function episodeFromLink(link) { const href = absoluteUrl(link && link.getAttribute("href")); if (!href) return null; const path = normalizePath(href); const fromList = collectEpisodes().find((item) => normalizePath(item.href) === path); if (fromList) return fromList; return { href: href, label: cleanText(link && (link.querySelector("em") && link.querySelector("em").textContent || link.textContent)) }; } function canNativePlay() { return !!(window.fm || window.fongmiBridge || window.fongmiClient); } function enhanceCards() { const cards = document.querySelectorAll(".grid-content,.feature-post-box,.swiper-slide,.item-row,.aside-body li"); for (let i = 0; i < cards.length; i++) enhanceCard(cards[i]); const direct = document.querySelectorAll(CONFIG.focusSelector); for (let i = 0; i < direct.length; i++) { if (direct[i].dataset.fmYmvidFocus === "1") continue; direct[i].dataset.fmYmvidFocus = "1"; direct[i].addEventListener("focus", focusCurrent, true); } } function enhanceCard(card) { if (!card || card.dataset.fmYmvidCard === "1") return; const link = card.querySelector("a[href]"); if (!link) return; card.dataset.fmYmvidCard = "1"; card.setAttribute("tabindex", "0"); card.setAttribute("role", "link"); card.addEventListener("click", function (event) { if (event.target && event.target.closest && event.target.closest("a,button,input,textarea,select")) return; link.click(); }); card.addEventListener("keydown", function (event) { if (state.inputEditing) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); link.click(); } }); card.addEventListener("focus", focusCurrent, true); card.addEventListener("blur", function () { card.classList.remove(CONFIG.focusClass); }, true); } function focusCurrent(event) { if (!isTv()) return; const target = event.currentTarget || event.target; if (!target || !target.classList) return; clearTimeout(state.focusRaf); target.classList.add(CONFIG.focusClass); state.focusRaf = setTimeout(() => { try { target.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (e) { // ignore } }, 40); } function handleFocusIn(event) { const tag = event.target && event.target.tagName; state.inputEditing = /^(INPUT|TEXTAREA|SELECT)$/i.test(tag || ""); } function handleFocusOut(event) { const target = event.target; if (target && target.classList) target.classList.remove(CONFIG.focusClass); const tag = target && target.tagName; if (/^(INPUT|TEXTAREA|SELECT)$/i.test(tag || "")) state.inputEditing = false; } function toast(message) { try { if (window.fm && fm.ext && fm.ext.toast) return fm.ext.toast(message); } catch (e) { // ignore } return Promise.resolve(); } })();