Files
2026-07-30 06:33:04 +00:00

3563 lines
139 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><title>YouTube</title><!-- ============================================================
兼容引导层:文档第一个内联脚本,只用 ES5 语法书写
参考开发文档 15.5
============================================================ --><script>
(function () {
var d = document, root = d.documentElement;
/* ---- polyfill ---- */
if (!Element.prototype.matches) {
Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
}
if (!Element.prototype.closest) {
Element.prototype.closest = function (s) {
var el = this;
while (el && el.nodeType === 1) {
if (el.matches(s)) return el;
el = el.parentElement || el.parentNode;
}
return null;
};
}
if (!Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function () {
while (this.firstChild) this.removeChild(this.firstChild);
for (var i = 0; i < arguments.length; i++) {
var n = arguments[i];
this.appendChild(typeof n === 'string' ? d.createTextNode(n) : n);
}
};
}
if (!Object.values) {
Object.values = function (o) {
var r = [], k;
for (k in o) if (Object.prototype.hasOwnProperty.call(o, k)) r.push(o[k]);
return r;
};
}
if (!Object.entries) {
Object.entries = function (o) {
var r = [], k;
for (k in o) if (Object.prototype.hasOwnProperty.call(o, k)) r.push([k, o[k]]);
return r;
};
}
if (!Array.from) {
Array.from = function (a) {
var r = [], i;
for (i = 0; i < a.length; i++) r.push(a[i]);
return r;
};
}
if (!Array.prototype.includes) {
Array.prototype.includes = function (v) { return this.indexOf(v) !== -1; };
}
if (!String.prototype.includes) {
String.prototype.includes = function (v) { return this.indexOf(v) !== -1; };
}
if (window.NodeList && !NodeList.prototype.forEach) {
NodeList.prototype.forEach = Array.prototype.forEach;
}
if (window.HTMLCollection && !HTMLCollection.prototype.forEach) {
HTMLCollection.prototype.forEach = Array.prototype.forEach;
}
if (!Promise.prototype['finally']) {
Promise.prototype['finally'] = function (cb) {
return this.then(function (v) { return Promise.resolve(cb()).then(function () { return v; }); },
function (e) { return Promise.resolve(cb()).then(function () { throw e; }); });
};
}
if (!window.requestIdleCallback) {
window.requestIdleCallback = function (cb) { return setTimeout(function () { cb({ timeRemaining: function () { return 8; } }); }, 1); };
}
/* ---- 特性检测:失败加降级类 ---- */
var supports = (window.CSS && CSS.supports) ? function (p, v) { try { return CSS.supports(p, v); } catch (e) { return false; } } : function () { return false; };
/* flex gap 必须实测 */
function testFlexGap() {
try {
var box = d.createElement('div');
box.style.cssText = 'display:flex;flex-direction:column;position:absolute;visibility:hidden;';
box.style.rowGap = '10px';
box.appendChild(d.createElement('div'));
box.appendChild(d.createElement('div'));
(d.body || root).appendChild(box);
var ok = box.scrollHeight === 10;
box.parentNode.removeChild(box);
return ok;
} catch (e) { return false; }
}
function testAspectRatio() {
if (!supports('aspect-ratio', '16 / 9')) return false;
try {
var el = d.createElement('div');
el.style.cssText = 'position:absolute;visibility:hidden;width:160px;';
el.style.aspectRatio = '16 / 9';
(d.body || root).appendChild(el);
var h = el.getBoundingClientRect().height;
el.parentNode.removeChild(el);
return h > 85 && h < 95;
} catch (e) { return false; }
}
function detect() {
if (!testFlexGap()) root.className += ' no-layout-gap';
if (!supports('width', 'clamp(1px, 2px, 3px)')) root.className += ' no-css-functions';
if (!testAspectRatio()) root.className += ' no-aspect-ratio';
if (!supports('display', 'grid')) root.className += ' no-grid';
if (window.fongmiBridge || window.fm) root.className += ' fm-native';
if (window.fongmiClient && window.fongmiClient.isLeanback) root.className += ' fm-tv';
}
if (d.body) detect();
else d.addEventListener('DOMContentLoaded', detect);
})();
</script><style>
/* ============ tokens ============ */
:root{
--ink:#080A0F;
--panel:rgba(16,19,27,0.92);
--panel-2:rgba(24,28,38,0.95);
--line:rgba(255,255,255,0.10);
--text:#ECF1F6;
--muted:#9AA6B4;
/* 浮层里是实底,控件用实色,别再叠半透明 */
--solid:#0A0D13;
--solid-2:#161A24;
--solid-3:#1E2330;
--amber:#FFC24B; /* 焦点 / 选中 */
--signal:#F0453A; /* 播放 */
--radius:14px;
--safe-top:0px;
--safe-bottom:0px;
--safe-left:0px;
--safe-right:0px;
}
:root{
--safe-top:var(--fm-safe-top,0px);
--safe-bottom:var(--fm-safe-bottom,0px);
--safe-left:var(--fm-safe-left,0px);
--safe-right:var(--fm-safe-right,0px);
}
@supports (top: max(0px, 1px)){
:root{
--safe-top:max(var(--fm-safe-top,0px), env(safe-area-inset-top,0px));
--safe-bottom:max(var(--fm-safe-bottom,0px), env(safe-area-inset-bottom,0px));
--safe-left:max(var(--fm-safe-left,0px), env(safe-area-inset-left,0px));
--safe-right:max(var(--fm-safe-right,0px), env(safe-area-inset-right,0px));
}
}
*{ -webkit-tap-highlight-color:transparent; }
html,body{
margin:0; padding:0;
height:100%;
background:transparent; /* App 壁纸透出,不写死背景 */
color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans CJK SC","Source Han Sans SC","Microsoft YaHei",Roboto,sans-serif;
-webkit-font-smoothing:antialiased;
}
/* 浏览器预览时给一个底色,App 内保持透明 */
html:not(.fm-native) body{ background:#0A0D13; }
body{
min-height:100vh;
min-height:var(--fm-web-height,100vh);
overflow-x:hidden;
position:relative;
}
/* 压暗蒙版:WebHome 页面是透明的,App 壁纸直接透上来,
卡片和文字压在星云上根本看不清。这层垫在内容底下、壁纸之上,
把壁纸压下去但不盖死 —— 氛围还在,可读性回来了。 */
body::before{
content:'';
position:fixed;
top:0; left:0; right:0; bottom:0;
z-index:-1;
background:linear-gradient(180deg,
rgba(6,8,13,0.78) 0%,
rgba(6,8,13,0.88) 45%,
rgba(6,8,13,0.94) 100%);
pointer-events:none;
}
/* 浏览器预览没有壁纸,就别压了 */
html:not(.fm-native) body::before{ display:none; }
img{ border:0; display:block; }
button,input{ font:inherit; color:inherit; }
button{ background:none; border:0; cursor:pointer; }
/* ============ 顶栏 ============ */
.top{
position:sticky; top:0; z-index:30;
padding:10px 12px 8px;
padding-top:var(--safe-top);
padding-left:12px; padding-right:12px;
background:rgba(8,10,15,0.92);
border-bottom:1px solid var(--line);
}
@supports (backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px)){
.top{ -webkit-backdrop-filter:blur(18px); backdrop-filter:blur(18px); }
}
.brand{
display:inline-block; vertical-align:middle;
padding:0; margin:0;
line-height:40px;
white-space:nowrap;
}
/* 播放键:状态灯藏在这里 —— 网络断了它会变红,平时是 YouTube 红 */
.brand .mark{
display:inline-block; vertical-align:middle;
color:var(--signal);
line-height:0;
transition:color .25s ease;
}
.brand .mark svg{ display:block; }
.brand .mark.ok{ color:var(--signal); }
.brand .mark.bad{ color:#8A93A0; } /* 断网:褪成灰,不刺眼但看得出来 */
.brand .word{
display:inline-block; vertical-align:middle;
margin-left:7px;
font-size:17px; font-weight:700;
letter-spacing:-.02em; /* 字标要紧凑,不是通栏大字 */
color:var(--text);
}
.brand:active .mark{ transform:scale(0.94); }
.brand:focus .word{ color:var(--amber); }
.brand:focus .mark{ color:var(--amber); }
.gear{
display:inline-block; vertical-align:middle;
width:40px; height:40px;
border:0; background:none;
color:var(--muted);
line-height:0;
text-align:center;
transition:color .18s ease, transform .3s ease;
}
.gear svg{ display:inline-block; vertical-align:middle; }
.gear:active{ transform:rotate(60deg); }
.gear:focus{ color:var(--amber); transform:rotate(60deg); }
@media (prefers-reduced-motion: reduce){
.gear{ transition:none; }
.gear:active, .gear:focus{ transform:none; }
}
/* 搜索联想 */
.sug{
position:absolute;
top:44px; left:0; right:0;
z-index:50;
display:none;
border-radius:14px;
overflow:hidden;
background:rgba(18,21,29,0.98);
border:1px solid var(--line);
box-shadow:0 16px 40px rgba(0,0,0,0.5);
}
.sug.open{ display:block; }
/* 搜索态:内容区轻微压暗,注意力收到搜索框上 */
body.searching .wrap{ opacity:.45; transition:opacity .2s ease; }
body.searching .tabs{ opacity:.45; transition:opacity .2s ease; }
.wrap, .tabs{ transition:opacity .2s ease; }
@media (prefers-reduced-motion: reduce){
body.searching .wrap, body.searching .tabs, .wrap, .tabs{ transition:none; }
}
.sug-row{
position:relative;
border-bottom:1px solid rgba(255,255,255,0.05);
}
.sug-row:last-child{ border-bottom:0; }
.sug-hit{
display:block; width:100%;
padding:12px 42px 12px 14px;
text-align:left;
font-size:14px;
color:var(--text);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.sug-row.on,
.sug-hit:focus{ background:rgba(255,194,75,0.14); }
.sug-row.on .sug-hit,
.sug-hit:focus{ color:var(--amber); }
.sug .k{
color:var(--muted);
margin-right:10px;
display:inline-flex;
vertical-align:-2px;
}
.sug-del{
position:absolute;
right:4px; top:4px;
width:36px; height:36px;
color:var(--muted);
font-size:17px; line-height:36px; text-align:center;
}
.sug-del:focus{ color:var(--amber); }
.search{
display:inline-block; vertical-align:middle;
width:auto; max-width:none;
position:relative;
margin-right:0;
}
/* logo 和齿轮一行,搜索框独占一行、左右顶满 */
.top-row{
padding-top:8px;
margin-bottom:10px;
white-space:nowrap;
position:relative;
}
.top-row .gear{ position:absolute; right:0; top:8px; margin-left:0; }
.search{ display:block; width:100%; }
.search input{ height:42px; line-height:42px; font-size:15px; }
.search .go{ top:6px; right:6px; }
.sug{ top:48px; }
.search input{
width:100%; box-sizing:border-box;
height:38px; line-height:38px;
padding:0 40px 0 14px;
border-radius:999px;
border:1px solid var(--line);
background:var(--panel-2);
outline:none;
font-size:14px;
}
.search input:focus{ border-color:var(--amber); }
.search .go{
position:absolute;
top:5px; right:6px;
width:32px; height:32px;
border-radius:50%;
color:var(--muted);
display:inline-flex;
align-items:center; justify-content:center;
padding:0;
}
.search .go:active{ color:var(--text); }
.search .go:focus{ color:var(--amber); }
.icon-btn{
display:inline-block; vertical-align:middle;
height:38px; min-width:38px; padding:0 10px;
border-radius:999px; border:1px solid var(--line);
background:var(--panel-2);
font-size:13px; color:var(--muted);
margin-left:6px;
}
.icon-btn:focus{ color:var(--ink); background:var(--amber); border-color:var(--amber); }
/* ============ 分类 tab ============ */
.tabs{
white-space:nowrap; overflow-x:auto; overflow-y:hidden;
-webkit-overflow-scrolling:touch;
margin-top:10px;
padding-bottom:2px;
/* 让 tabs 自己吃掉横向手势,不要交给页面(否则会被判成切类别) */
touch-action:pan-x;
scroll-behavior:smooth;
}
.tabs::-webkit-scrollbar{ display:none; }
/* 二级页标题栏:和 tabs 互斥显示 */
.subbar{
display:none;
position:relative;
margin-top:10px;
padding-bottom:2px;
white-space:nowrap;
}
.subbar.on{ display:block; }
.tabs.off{ display:none; }
.sub-back{
display:inline-flex; vertical-align:middle;
align-items:center; justify-content:center;
width:36px; height:36px;
border-radius:50%;
background:var(--panel-2);
border:1px solid var(--line);
color:var(--text);
padding:0;
}
.sub-back svg{ margin-left:-1px; }
.sub-back:focus{ background:var(--amber); border-color:var(--amber); color:#0A0D13; }
.sub-meta{
display:inline-block; vertical-align:middle;
margin-left:10px;
max-width:calc(100% - 150px);
overflow:hidden;
}
.sub-name{
font-size:14px; font-weight:600;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.sub-desc{
font-size:11px; color:var(--muted);
margin-top:2px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.sub-play{
position:absolute;
right:0; top:1px;
z-index:3;
padding:9px 15px;
border-radius:999px;
background:var(--signal);
color:#fff;
font-size:12px; font-weight:600;
white-space:nowrap;
}
.sub-play:active{ transform:scale(0.95); }
.sub-play.busy{ opacity:.6; }
.sub-play:focus{ background:var(--amber); color:#0A0D13; }
.sub-play.hide{ display:none; }
.tab{
display:inline-block;
padding:7px 15px; margin-right:8px;
border-radius:999px;
border:1px solid var(--line);
background:var(--panel-2);
color:var(--muted);
font-size:13px; font-weight:500;
white-space:nowrap;
transition:background .16s ease, color .16s ease;
}
.tab.on{ background:var(--text); color:#0A0D13; border-color:var(--text); font-weight:600; }
.tab:focus{ border-color:var(--amber); color:var(--amber); }
.tab.on:focus{ color:#0A0D13; background:var(--amber); }
/* ============ 列表 ============ */
/* 下拉刷新指示器:藏在顶栏下面,跟着手指下来 */
.pull{
position:fixed;
left:0; right:0;
top:0;
z-index:20;
height:0;
text-align:center;
pointer-events:none;
}
.pull-spin{
display:inline-block;
width:36px; height:36px;
border-radius:50%;
background:var(--solid-2);
border:1px solid rgba(255,255,255,0.12);
color:var(--muted);
line-height:34px;
opacity:0;
transform:translateY(-40px);
box-shadow:0 6px 20px rgba(0,0,0,0.4);
}
.pull-spin svg{ vertical-align:middle; }
.pull-spin.ready{ color:var(--amber); border-color:var(--amber); }
.pull-spin.spin{ color:var(--amber); border-color:var(--amber); animation:spin .7s linear infinite; }
/* 橡皮筋:整块内容跟着手指走,松手弹回 */
#wrap{ will-change:transform; }
#wrap.snap{ transition:transform .42s cubic-bezier(.22,1,.36,1); }
/* 左右切类别:跟手位移 → 松手滑出 → 新内容从另一侧滑入 */
#wrap.swipe{ transition:transform .2s ease-out, opacity .2s ease-out; }
#wrap.in-left{ animation:inLeft .34s cubic-bezier(.22,1,.36,1); }
#wrap.in-right{ animation:inRight .34s cubic-bezier(.22,1,.36,1); }
@keyframes inLeft{
from{ transform:translateX(-28%); opacity:0; }
to{ transform:translateX(0); opacity:1; }
}
@keyframes inRight{
from{ transform:translateX(28%); opacity:0; }
to{ transform:translateX(0); opacity:1; }
}
@media (prefers-reduced-motion: reduce){
#wrap.swipe{ transition:none; }
#wrap.in-left, #wrap.in-right{ animation:none; }
}
@media (prefers-reduced-motion: reduce){
#wrap.snap{ transition:none; }
.pull-spin.spin{ animation:none; }
}
.wrap{
padding:16px 8px 40px;
padding-left:8px; padding-right:8px;
padding-bottom:calc(var(--safe-bottom) + 40px);
}
.grid{ font-size:0; margin:0 -6px; }
.card{
display:inline-block; vertical-align:top;
width:50%;
box-sizing:border-box;
padding:0 6px 18px;
font-size:14px;
text-align:left;
}
@media (min-width:640px){ .card{ width:33.3333%; } }
@media (min-width:960px){ .card{ width:25%; } }
@media (min-width:1400px){ .card{ width:20%; } }
.card-inner{
display:block; width:100%;
border-radius:var(--radius);
overflow:hidden;
background:var(--panel);
border:1px solid rgba(255,255,255,0.04);
text-align:left;
padding:0;
transition:transform .16s ease, border-color .16s ease;
}
.card-inner:active{ transform:scale(0.97); }
.card-inner:focus{
border-color:var(--amber);
box-shadow:0 0 0 2px rgba(255,194,75,0.45);
transform:translateY(-2px);
}
@media (prefers-reduced-motion: reduce){
.card-inner{ transition:none; }
.card-inner:active{ transform:none; }
}
.thumb{
position:relative;
width:100%;
padding-top:56.25%; /* 固定 16:9,不依赖 aspect-ratio */
background:rgba(255,255,255,0.05);
overflow:hidden;
}
.thumb img{
position:absolute;
top:0; left:0; width:100%; height:100%;
object-fit:cover;
}
.thumb img.lazy{ opacity:0; }
.thumb img.in{ opacity:1; transition:opacity .28s ease; }
@media (prefers-reduced-motion: reduce){
.thumb img.in{ transition:none; }
}
.badge{
position:absolute;
right:6px; bottom:6px;
padding:1px 6px;
border-radius:6px;
background:rgba(0,0,0,0.82);
color:#fff;
font-size:11px; line-height:18px;
font-weight:600;
font-variant-numeric:tabular-nums;
letter-spacing:.02em;
}
.badge.live{ background:var(--signal); }
.badge.list{ left:6px; right:auto; background:rgba(0,0,0,0.78); }
.meta{ padding:9px 10px 12px; }
.title{
font-size:13px; line-height:1.4; font-weight:600;
color:var(--text);
display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
overflow:hidden;
max-height:37px;
letter-spacing:.01em;
}
.sub{
margin-top:7px;
font-size:11px; line-height:1.3;
color:var(--muted);
opacity:.85;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* ============ 状态 ============ */
.state{
padding:60px 20px;
text-align:center;
color:var(--muted);
font-size:13px;
}
.state strong{ display:block; color:var(--text); font-size:15px; margin-bottom:8px; }
.state .act{
display:inline-block;
margin-top:14px; padding:9px 18px;
border-radius:999px;
background:var(--panel-2); border:1px solid var(--line);
color:var(--text); font-size:13px;
}
.state .act:focus{ border-color:var(--amber); color:var(--amber); }
.skel .card-inner{
background:var(--panel);
border:1px solid transparent;
border-radius:var(--radius);
overflow:hidden;
width:100%;
display:block;
cursor:default;
}
.skel .thumb{ background:rgba(255,255,255,0.06); }
.skel .meta{ padding:9px 10px 12px; }
.skel .bar{
height:11px;
border-radius:5px;
background:rgba(255,255,255,0.08);
margin-bottom:7px;
}
.skel .bar.short{ width:55%; margin-bottom:0; height:9px; background:rgba(255,255,255,0.05); }
/* 微微呼吸,别做跑马灯高光:老 WebView 上渐变动画很容易掉帧 */
.skel .card-inner{ animation:pulse 1.4s ease-in-out infinite; }
.skel .card:nth-child(2n) .card-inner{ animation-delay:.18s; }
.skel .card:nth-child(3n) .card-inner{ animation-delay:.36s; }
@keyframes pulse{
0%,100%{ opacity:1; }
50%{ opacity:.55; }
}
@media (prefers-reduced-motion: reduce){
.skel .card-inner{ animation:none; }
}
.spinner{
width:22px; height:22px; margin:0 auto 12px;
border-radius:50%;
border:2px solid rgba(255,255,255,0.16);
border-top-color:var(--amber);
animation:spin .8s linear infinite;
}
@keyframes spin{ to{ transform:rotate(360deg); } }
@media (prefers-reduced-motion: reduce){ .skel .card-inner{
background:var(--panel);
border:1px solid transparent;
border-radius:var(--radius);
overflow:hidden;
width:100%;
display:block;
cursor:default;
}
.skel .thumb{ background:rgba(255,255,255,0.06); }
.skel .meta{ padding:9px 10px 12px; }
.skel .bar{
height:11px;
border-radius:5px;
background:rgba(255,255,255,0.08);
margin-bottom:7px;
}
.skel .bar.short{ width:55%; margin-bottom:0; height:9px; background:rgba(255,255,255,0.05); }
/* 微微呼吸,别做跑马灯高光:老 WebView 上渐变动画很容易掉帧 */
.skel .card-inner{ animation:pulse 1.4s ease-in-out infinite; }
.skel .card:nth-child(2n) .card-inner{ animation-delay:.18s; }
.skel .card:nth-child(3n) .card-inner{ animation-delay:.36s; }
@keyframes pulse{
0%,100%{ opacity:1; }
50%{ opacity:.55; }
}
@media (prefers-reduced-motion: reduce){
.skel .card-inner{ animation:none; }
}
.spinner{ animation:none; } }
.more{ padding:18px 0 4px; text-align:center; }
/* ============ 浮层 ============ */
.sheet{
position:fixed;
top:0; left:0; right:0; bottom:0;
z-index:60;
display:none;
overflow-y:auto;
-webkit-overflow-scrolling:touch;
background:#0A0D13;
}
.sheet.open{ display:block; }
body.locked{ overflow:hidden; }
.hero{ position:relative; width:100%; padding-top:56.25%; background:#101319; overflow:hidden; }
.hero img{ position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; }
.hero .fade{
position:absolute; left:0; right:0; bottom:0;
height:38%;
background:linear-gradient(to bottom, rgba(6,8,12,0), rgba(6,8,12,0.96));
}
.hero .play{
position:absolute;
left:50%; top:50%;
margin-left:-32px; margin-top:-32px;
width:64px; height:64px; border-radius:50%;
background:var(--signal);
color:#fff; font-size:22px; line-height:64px; text-align:center;
box-shadow:0 8px 28px rgba(240,69,58,0.4);
}
.hero .play:focus{ background:var(--amber); color:#0A0D13; box-shadow:0 0 0 3px rgba(255,194,75,0.5); }
.sheet-head{
position:sticky; top:0; z-index:10;
background:#0A0D13;
/* 安全区:先写不带 env() 的兜底,再写带 env() 的,老内核只吃第一条 */
padding-top:var(--fm-safe-top, 0px);
padding-top:var(--safe-top);
padding-left:12px; padding-right:12px;
border-bottom:1px solid var(--line);
white-space:nowrap;
}
.sheet-back{
display:inline-flex; vertical-align:middle;
align-items:center; justify-content:center;
width:36px; height:36px; margin:9px 0;
border-radius:50%;
background:var(--solid-2);
border:1px solid rgba(255,255,255,0.12);
color:var(--text);
padding:0;
}
.sheet-back svg{ margin-left:-1px; } /* 视觉居中:箭头的重心偏右 */
.sheet-back:focus{ background:var(--amber); border-color:var(--amber); color:#0A0D13; }
.sheet-title{
display:inline-block; vertical-align:middle;
margin-left:12px;
font-size:16px; font-weight:700; letter-spacing:.02em;
}
.row{ margin:0 -5px 18px; font-size:0; }
.btn{
display:inline-block;
margin:0 5px 8px;
padding:12px 20px;
border-radius:999px;
background:var(--solid-2);
border:1px solid rgba(255,255,255,0.12);
font-size:13px; font-weight:600;
color:var(--text);
}
.btn:active{ transform:scale(0.96); }
.btn.primary{ background:var(--signal); border-color:var(--signal); color:#fff; }
.btn:focus{ border-color:var(--amber); color:var(--amber); }
.btn.primary:focus{ background:var(--amber); color:#0A0D13; }
.section-title{
font-size:12px; letter-spacing:.16em; color:var(--muted);
text-transform:uppercase;
margin:6px 0 10px;
}
/* 设置 / 自检 */
.panel{
max-width:640px; margin:0 auto;
padding:18px 16px 40px;
padding-bottom:calc(var(--safe-bottom) + 40px);
}
.field{ margin-bottom:16px; }
.field label{
display:block;
font-size:12px; letter-spacing:.02em;
color:#B7C2CE;
margin-bottom:8px;
}
.field input, .field select{
width:100%; box-sizing:border-box;
height:46px; padding:0 14px;
border-radius:12px;
border:1px solid rgba(255,255,255,0.12);
background:var(--solid-2);
color:var(--text);
outline:none; font-size:14px;
-webkit-appearance:none; appearance:none;
}
.field select,
.field select option,
.field select optgroup{
background-color:var(--solid-2);
color:#ECF1F6; /* 有的 WebView 用系统调色板渲染 option,会出黑字 */
-webkit-text-fill-color:#ECF1F6;
}
.field select{
/* 自绘箭头:系统默认箭头在深色背景下经常是黑的,看不见 */
background-image:linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position:calc(100% - 18px) 19px, calc(100% - 13px) 19px;
background-size:5px 5px, 5px 5px;
background-repeat:no-repeat;
padding-right:38px;
}
.field input:focus, .field select:focus{
border-color:var(--amber);
background-color:var(--solid-3);
}
.field input::placeholder{ color:rgba(154,166,180,0.5); }
.adv-toggle{
display:block; width:100%;
padding:14px 0;
margin:6px 0 4px;
text-align:left;
font-size:13px; color:var(--muted);
border-top:1px solid rgba(255,255,255,0.08);
border-bottom:1px solid rgba(255,255,255,0.08);
position:relative;
}
.adv-toggle:focus{ color:var(--amber); }
.adv-toggle .chev{
position:absolute; right:2px; top:13px;
font-size:18px; line-height:1;
transition:transform .22s ease;
display:inline-block;
}
.adv-toggle.open .chev{ transform:rotate(90deg); }
.adv{
max-height:0;
overflow:hidden;
opacity:0;
transition:max-height .3s ease, opacity .22s ease;
}
.adv.open{ max-height:1200px; opacity:1; padding-top:16px; }
@media (prefers-reduced-motion: reduce){
.adv, .adv-toggle .chev{ transition:none; }
}
.note{
font-size:12px; line-height:1.75; color:#98A4B2;
background:var(--solid-2); border:1px solid rgba(255,255,255,0.08);
border-left:3px solid rgba(255,194,75,0.55);
border-radius:12px; padding:13px 15px; margin-top:16px;
}
.note b{ color:var(--text); font-weight:600; }
pre.code{
margin:10px 0 0;
padding:12px;
background:#05070B;
border:1px solid var(--line);
border-radius:10px;
font-size:11px; line-height:1.6;
color:#CBD6E2;
overflow-x:auto;
white-space:pre;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
.diag-line{
display:block;
padding:12px 14px; margin-bottom:9px;
border-radius:12px;
background:var(--solid-2); border:1px solid rgba(255,255,255,0.08);
font-size:12px; line-height:1.5; color:var(--muted);
}
.diag-line b{ color:var(--text); }
.ok{ color:#57D9A3; }
.bad{ color:#FF6B5E; }
/* toast */
.toast{
position:fixed;
left:50%; bottom:34px;
bottom:calc(var(--safe-bottom) + 34px);
z-index:90;
transform:translateX(-50%);
max-width:80%;
padding:10px 16px;
border-radius:999px;
background:rgba(20,24,32,0.95);
border:1px solid var(--line);
font-size:13px;
opacity:0; visibility:hidden;
transition:opacity .18s ease;
}
.toast.show{ opacity:1; visibility:visible; }
/* TV */
html.fm-tv .card{ width:25%; }
html.fm-tv .title{ font-size:15px; max-height:42px; }
html.fm-tv .sub{ font-size:12px; }
html.fm-tv .tab{ font-size:15px; padding:8px 18px; }
html.fm-tv .card-inner:focus{ box-shadow:0 0 0 3px var(--amber); }
/* 降级 */
html.no-css-functions .top{ padding-top:16px; }
html.no-css-functions .wrap{ padding-bottom:48px; }
</style></head><body><header class="top" id="top"><div class="top-row"><!-- logo 兼作网络自检入口:小圆点就是状态灯 --><button class="brand" id="netBtn" aria-label="网络自检"><span class="mark" id="netDot"><svg viewbox="0 0 28 20" width="26" height="19" aria-hidden="true"><rect x="0.5" y="0.5" width="27" height="19" rx="5.5" fill="currentColor"></rect><path d="M11 5.6 L19 10 L11 14.4 Z" fill="#0A0D13"></path></svg></span><span class="word">YouTube</span></button><button class="gear" id="setBtn" aria-label="设置"><svg viewbox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></button></div><span class="search"><input id="q" type="search" placeholder="搜索视频、频道、播放列表" autocomplete="off"><button class="go" id="goBtn" aria-label="搜索"><svg viewbox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M16.5 16.5 L21 21"></path></svg></button><div class="sug" id="sug"></div></span><nav class="tabs" id="tabs"></nav><!-- 二级页(频道 / 播放列表):占掉 tabs 的位置 --><div class="subbar" id="subbar"><button class="sub-back" id="subBack" aria-label="返回"><svg viewbox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5 L8 12 L15 19"></path></svg></button><div class="sub-meta"><div class="sub-name" id="subName"></div><div class="sub-desc" id="subDesc"></div></div><button class="sub-play" id="subPlay">播放全部</button></div></header><div class="pull" id="pull"><div class="pull-spin" id="pullSpin"><svg viewbox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6"></path></svg></div></div><main class="wrap" id="wrap"><div id="list" class="grid"></div><div id="state" class="state"></div><div id="more" class="more"></div></main><!-- 设置浮层 --><section class="sheet" id="settings"><div class="sheet-head"><button class="sheet-back" id="setBack" aria-label="返回"><svg viewbox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5 L8 12 L15 19"></path></svg></button><span class="sheet-title">设置</span></div><div class="panel"><div class="field"><label for="quality">画质</label><select id="quality"><option value="best">最高(有 4K 就 4K</option><option value="2160">2160P / 4K</option><option value="1440">1440P / 2K</option><option value="1080">1080P</option><option value="720">720P</option><option value="480">480P</option><option value="360">360P</option></select></div><div class="field"><label for="wall">播放页背景</label><select id="wall"><option value="none">App 默认壁纸(干净,字看得清)</option><option value="pic">视频封面(花,但有氛围)</option></select></div><div class="field"><label for="hl">界面语言 hl</label><input id="hl" type="text" placeholder="zh-CN"></div><div class="field"><label for="gl">内容地区 gl(代理出口所在地区效果最好)</label><input id="gl" type="text" placeholder="US"></div><button class="adv-toggle" id="advToggle" aria-expanded="false"><span>高级(排查用,平时不用动)</span><span class="chev" id="advChev"></span></button><div class="adv" id="adv"><div class="field"><label for="engine">播放引擎</label><select id="engine"><option value="hls">HLSYouTube 官方清单,推荐)</option><option value="dash">DASHInvidious 清单,实例常被墙)</option><option value="newpipe">App 内置 NewPipe</option></select></div><div class="field"><label for="client">取流客户端(播一会就 403 时,逐个换着试)</label><select id="client"><option value="auto">自动(VR → IOS → ANDROID → MWEB → TV</option><option value="ANDROID_VR">ANDROID_VRQuest 3,通常不吃 PoToken</option><option value="IOS">IOS</option><option value="ANDROID">ANDROID</option><option value="TVHTML5">TVHTML5</option><option value="MWEB">MWEB</option></select></div><div class="field"><label for="seg">DASH 分片走哪条路</label><select id="seg"><option value="gw">/webResource 网关(UA 每次请求都带得上,理论上能拖动)</option><option value="direct">googlevideo 直链(UA 只在首次请求,拖动可能 403)</option></select></div><div class="field"><label for="hlsmode">HLS 模式</label><select id="hlsmode"><option value="variant">直推变体(把该档 m3u8 原地址给播放器,最稳)</option><option value="pin">锁定画质(改写主清单 + data URI,播放器可能不吃)</option><option value="master">原始主清单(播放器自适应,会从低码率起步)</option></select></div><div class="field"><label for="dashmode">DASH 模式</label><select id="dashmode"><option value="pin">锁定画质(改写清单,播放器没得挑)</option><option value="raw">原始清单(播放器自适应,可能从低码率起步)</option></select></div><div class="field"><label for="inv">Invidious 实例(留空自动挑一个能用的)</label><input id="inv" type="text" placeholder="https://inv.nadeko.net"></div><div class="field"><label for="ua">请求 User-Agent</label><input id="ua" type="text"></div></div><div class="row"><button class="btn primary" id="saveBtn">保存</button><button class="btn" id="resetBtn">恢复默认</button></div><div class="note"><b>关于画质。</b>YouTube 的单流上限就是<b>720P</b>;1080P 以上一律是视频、音频分离的自适应流,必须有人把两条轨拼成一份清单。 网页自己做不到(没有本地服务端),所以<b>DASH 引擎</b>用 Invidious 实例现成的 MPD<code>/api/manifest/dash/id/…</code>,里面已经拼好了。 清单地址不以<code>.mpd</code>结尾,播放器猜不出类型,所以走<code>fm.vodInline</code>的逐集 resolver,显式回一个<code>format: application/dash+xml</code>——之前 DASH 播不了就卡在这。DASH 是自适应的,播放器会自己上到最高,清晰度在播放页里调。<b>HLS 引擎</b>是备选,能按档位精确挑一条,但 YouTube 的 iOS 清单一般到 1080P 为止。两条都不通才退回 NewPipe。</div><div class="note"><b>关于代理。</b>本页所有接口走<b>fm.req</b>、所有图片走<b>fm.res</b>,两者都使用 App 统一 OkHttp,会继承配置里的<b>proxy / hosts / doh</b>和增强功能里的<b>Proxy 壳代理</b>规则。页面本身不做任何代理,请在 App 侧配置一次即可。 播放使用 App 内置 NewPipe 解析 YouTube 链接,取到的是<b>googlevideo.com</b>直链,所以代理规则必须同时覆盖播放域名,否则会出现“能刷列表、不能起播”。<pre class="code" id="proxySnippet"></pre><button class="btn" id="copyCfg" style="margin-top:10px">复制这段 proxy 配置</button></div></div></section><!-- 网络自检浮层 --><section class="sheet" id="net"><div class="sheet-head"><button class="sheet-back" id="netBack" aria-label="返回"><svg viewbox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5 L8 12 L15 19"></path></svg></button><span class="sheet-title">网络自检</span></div><div class="panel"><div id="diag"></div><div class="row"><button class="btn primary" id="runDiag">重新检测</button></div><div class="note">检测项分别对应「拉列表」「出图」「起播」三条链路。任意一条失败,先去<b>设置 → 增强功能 → Proxy 壳代理</b>打开代理并补规则,或在点播配置顶层加<code>proxy</code>段。</div></div></section><div class="toast" id="toast"></div><script>
/* =============================================================
YouTube WebHome
语法基线 ES2017:无 ?. / ?? / ||= / replaceAll / allSettled
============================================================= */
(function () {
'use strict';
var YT = 'https://www.youtube.com';
var API = YT + '/youtubei/v1/';
var KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'; // YouTube Web 端公开 key
var CVER = '2.20240401.00.00';
var DEF_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
var PROXY_SNIPPET = [
'{',
' "proxy": [',
' {',
' "name": "youtube",',
' "hosts": [',
' "youtube.com", "youtu.be", "ytimg.com", "ggpht.com",',
' "googlevideo.com", "googleapis.com", "gstatic.com"',
' ],',
' "urls": ["socks5://127.0.0.1:7897"]',
' }',
' ]',
'}'
].join('\n');
var TABS = [
{ id: 'rec', name: '推荐', type: 'browse', arg: 'FEwhat_to_watch' },
{ id: 'hot', name: '热门', type: 'browse', arg: 'FEtrending' },
{ id: 'music', name: '音乐', type: 'search', arg: '音乐 MV' },
{ id: '韩国短剧', name: '韩国短剧', type: 'search', arg: '韩国短剧' },
{ id: '谍战剧', name: '谍战剧', type: 'search', arg: '谍战剧' },
{ id: '沙雕', name: '沙雕', type: 'search', arg: '沙雕' },
{ id: '劇集', name: '劇集', type: 'search', arg: '劇集' },
{ id: '밀크필름MilkFilm', name: '밀크필름MilkFilm', type: 'search', arg: '밀크필름MilkFilm' },
{ id: 'YouTube 新聞 Live', name: '新聞直播', type: 'search', arg: 'YouTube 新聞 Live' },
{ id: '音樂', name: '音樂', type: 'search', arg: '音樂' },
{ id: '短劇', name: '短劇', type: 'search', arg: '短劇' },
{ id: 'game', name: '游戏', type: 'search', arg: '游戏 实况' },
{ id: 'tech', name: '科技', type: 'search', arg: '科技 数码 评测' },
{ id: 'movie', name: '影视', type: 'search', arg: '电影 解说' },
{ id: 'news', name: '新闻', type: 'search', arg: '新闻' },
{ id: 'recent',name: '最近', type: 'recent', arg: '' }
];
/* ---------- DOM ---------- */
function $(id) { return document.getElementById(id); }
function setVal(id, v) { var el = $(id); if (el) el.value = v; }
function getVal(id) { var el = $(id); return el ? String(el.value) : ''; }
function on(id, evt, fn) {
var el = $(id);
if (el) el.addEventListener(evt, fn);
}
var elList = $('list'), elState = $('state'), elMore = $('more'), elTabs = $('tabs'), elToast = $('toast');
/* ---------- 状态 ---------- */
var prefs = { hl: 'zh-CN', gl: 'US', ua: DEF_UA, quality: 'best', wall: 'none', engine: 'hls', hlsmode: 'variant', seg: 'gw', client: 'auto', dashmode: 'pin', inv: '' };
var view = { kind: 'browse', arg: 'FEwhat_to_watch', tab: 'rec', token: '', items: [], loading: false, seq: 0, ep: '' };
var subStack = []; // 二级页栈:支持频道→播放列表→再进频道这种连跳
var native = false;
/* ---------- 基础工具 ---------- */
/* logo 里的小圆点 = 网络状态灯:绿=通,红=断。点 logo 打开自检 */
function netDot(ok) {
var d = $('netDot');
if (d) d.className = 'mark ' + (ok ? 'ok' : 'bad');
}
/* body 上同时可能有 locked / searching,必须增删而不是整体覆盖 */
function bodyClass(name, on) {
var b = document.body;
var cls = (' ' + b.className + ' ').replace(new RegExp('\\s' + name + '\\s', 'g'), ' ');
b.className = (on ? cls + name : cls).replace(/\s+/g, ' ').replace(/^ | $/g, '');
}
function hasBodyClass(name) {
return (' ' + document.body.className + ' ').indexOf(' ' + name + ' ') !== -1;
}
function toast(msg) {
elToast.textContent = msg;
elToast.className = 'toast show';
clearTimeout(toast._t);
toast._t = setTimeout(function () { elToast.className = 'toast'; }, 2200);
}
function get(obj, path) { // 代替可选链
var cur = obj, parts = path.split('.'), i, k;
for (i = 0; i < parts.length; i++) {
if (cur == null) return undefined;
k = parts[i];
cur = cur[k];
}
return cur;
}
function text(node) {
if (!node) return '';
if (typeof node === 'string') return node;
if (node.simpleText) return node.simpleText;
var runs = node.runs;
if (runs && runs.length) {
var s = '', i;
for (i = 0; i < runs.length; i++) s += (runs[i].text || '');
return s;
}
if (node.content) return node.content;
return '';
}
/* ---------- 存储:App 内 fm.cache,预览时 localStorage ---------- */
var store = {
get: async function (k) {
try {
if (native && window.fm && fm.cache) {
var v = await fm.cache.get(k, 'yt');
return v || '';
}
} catch (e) {}
try { return localStorage.getItem('yt_' + k) || ''; } catch (e) { return ''; }
},
set: async function (k, v) {
try {
if (native && window.fm && fm.cache) { await fm.cache.set(k, v, 'yt'); return; }
} catch (e) {}
try { localStorage.setItem('yt_' + k, v); } catch (e) {}
}
};
/* ---------- 网络:一律走 Native ---------- */
async function req(url, options) {
var opt = options || {};
if (native && window.fm && fm.req) {
var headers = opt.headers || {};
if (!headers['User-Agent']) headers['User-Agent'] = prefs.ua;
var r = await fm.req(url, {
method: opt.method || 'GET',
headers: headers,
body: opt.body || '',
responseType: opt.responseType || 'text',
timeout: opt.timeout || 20
});
if (!r || !r.ok) {
var msg = (r && (r.error || ('HTTP ' + r.status))) || '请求失败';
var err = new Error(msg);
err.status = r ? r.status : 0;
throw err;
}
return r.body;
}
// 电脑预览兜底(会受浏览器 CORS 限制)
var res = await fetch(url, {
method: opt.method || 'GET',
headers: opt.headers || {},
body: opt.body || undefined
});
if (!res.ok) throw new Error('HTTP ' + res.status);
return (opt.responseType === 'json') ? res.json() : res.text();
}
function resUrl(url) { // 图片一律过本地网关 → 走代理
if (!url) return '';
if (native && window.fm && fm.res) {
try { return fm.res(url, { headers: { 'User-Agent': prefs.ua, Referer: YT + '/' } }); } catch (e) { return url; }
}
return url;
}
/* ---------- 图片懒加载 ----------
loading="lazy" 旧 WebView 根本不认,列表一长就一次性发几十个封面请求,
全走代理,首屏被拖死。改成进视口再加载 + 淡入。
IntersectionObserver 不可用时退回滚动兜底。 */
var lazyIO = null;
var lazyList = [];
function lazyLoad(img) {
if (img._done) return;
img._done = true;
var url = img.getAttribute('data-src');
if (!url) return;
img.onload = function () { img.className = 'in'; };
img.onerror = function () { img.onerror = null; img.style.opacity = '0'; };
img.src = resUrl(url);
}
function lazyWatch(img) {
if (window.IntersectionObserver) {
if (!lazyIO) {
lazyIO = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) {
lazyLoad(en.target);
lazyIO.unobserve(en.target);
}
});
}, { rootMargin: '400px 0px' }); // 提前 400px 开始加载,滑动时看不到空白
}
lazyIO.observe(img);
return;
}
lazyList.push(img);
}
function lazyScan() {
if (!lazyList.length) return;
var h = window.innerHeight + 400, i, img, r;
for (i = lazyList.length - 1; i >= 0; i--) {
img = lazyList[i];
r = img.getBoundingClientRect();
if (r.top < h && r.bottom > -400) {
lazyLoad(img);
lazyList.splice(i, 1);
}
}
}
function setImg(img, url) {
img.src = resUrl(url);
img.onerror = function () {
img.onerror = null;
img.style.opacity = '0';
};
}
/* ---------- InnerTube ---------- */
var TRENDING_QUERY = { CN: '热门视频', TW: '熱門影片', HK: '熱門影片', JP: '人気の動画', US: 'trending' };
async function innertube(endpoint, payload) {
// browse(推荐 / 热门)必须带 visitorData,否则 YouTube 返回一个空信息流:
// 不报错、也没内容。搜索没这个限制,所以只有这两个 tab 会空。
var cfg = await ytcfg('');
var client = {
clientName: 'WEB',
clientVersion: CVER,
hl: prefs.hl,
gl: prefs.gl,
userAgent: prefs.ua
};
if (cfg.visitor) client.visitorData = cfg.visitor;
var body = { context: { client: client } };
var k;
for (k in payload) if (Object.prototype.hasOwnProperty.call(payload, k)) body[k] = payload[k];
var headers = {
'Content-Type': 'application/json',
'Accept-Language': prefs.hl,
'Origin': YT,
'Referer': YT + '/',
'X-YouTube-Client-Name': '1',
'X-YouTube-Client-Version': CVER,
'User-Agent': prefs.ua
};
if (cfg.visitor) headers['X-Goog-Visitor-Id'] = cfg.visitor;
return req(API + endpoint + '?key=' + (cfg.key || KEY) + '&prettyPrint=false', {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
responseType: 'json',
timeout: 25
});
}
/* 深度遍历,兼容 YouTube 随时变化的 renderer 结构 */
function collect(node, keys, out, depth) {
// 深度别卡太死:频道页嵌套是
// contents→twoColumnBrowseResultsRenderer→tabs[]→tabRenderer→content
// →richGridRenderer→contents[]→richItemRenderer→content→videoRenderer
// 数组每层也算一级,14 层够不到,列表就会是空的。
if (!node || depth > 40) return out;
var i, k;
if (Object.prototype.toString.call(node) === '[object Array]') {
for (i = 0; i < node.length; i++) collect(node[i], keys, out, depth + 1);
return out;
}
if (typeof node !== 'object') return out;
for (k in node) {
if (!Object.prototype.hasOwnProperty.call(node, k)) continue;
if (keys.indexOf(k) !== -1 && node[k] && typeof node[k] === 'object') out.push(node[k]);
collect(node[k], keys, out, depth + 1);
}
return out;
}
var VIDEO_KEYS = [
'videoRenderer', 'compactVideoRenderer', 'gridVideoRenderer',
'playlistVideoRenderer', // 播放列表里的条目
'playlistPanelVideoRenderer' // 播放列表面板
];
var LIST_KEYS = ['playlistRenderer', 'compactPlaylistRenderer', 'gridPlaylistRenderer'];
function thumbOf(r, id, kind) {
var arr = get(r, 'thumbnail.thumbnails');
if (arr && arr.length) return arr[arr.length - 1].url;
if (kind === 'list') return 'https://i.ytimg.com/vi/' + id + '/hqdefault.jpg';
return 'https://i.ytimg.com/vi/' + id + '/hqdefault.jpg';
}
function chanOf(r) {
// 频道 ID 藏在 byline 的 browseEndpoint 里,几种 renderer 位置不一样,挨个找
var paths = [
'ownerText.runs.0.navigationEndpoint.browseEndpoint.browseId',
'longBylineText.runs.0.navigationEndpoint.browseEndpoint.browseId',
'shortBylineText.runs.0.navigationEndpoint.browseEndpoint.browseId',
'channelThumbnailSupportedRenderers.channelThumbnailWithLinkRenderer.navigationEndpoint.browseEndpoint.browseId'
];
var i, v;
for (i = 0; i < paths.length; i++) {
v = get(r, paths[i]);
if (v && String(v).indexOf('UC') === 0) return v;
}
return '';
}
function toVideo(r) {
var id = r.videoId;
if (!id) return null;
var badges = JSON.stringify(r.badges || r.thumbnailOverlays || '');
return {
kind: 'video',
id: id,
chan: chanOf(r),
title: text(r.title),
author: text(r.ownerText) || text(r.longBylineText) || text(r.shortBylineText),
length: text(r.lengthText),
views: text(r.shortViewCountText) || text(r.viewCountText),
time: text(r.publishedTimeText),
live: badges.indexOf('LIVE') !== -1,
pic: thumbOf(r, id, 'video'),
wall: 'https://i.ytimg.com/vi/' + id + '/maxresdefault.jpg',
url: YT + '/watch?v=' + id
};
}
function toList(r) {
var id = r.playlistId;
if (!id) return null;
var vid = get(r, 'navigationEndpoint.watchEndpoint.videoId') || '';
var count = text(r.videoCountShortText) || text(r.videoCountText) || '';
var pic = get(r, 'thumbnails.0.thumbnails');
return {
kind: 'list',
id: id,
chan: chanOf(r),
title: text(r.title),
author: text(r.longBylineText) || text(r.shortBylineText),
count: count,
pic: (pic && pic.length ? pic[pic.length - 1].url : (vid ? 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg' : '')),
wall: vid ? 'https://i.ytimg.com/vi/' + vid + '/maxresdefault.jpg' : '',
url: YT + '/playlist?list=' + id
};
}
function parseItems(json) {
var out = [], seen = {}, i, v;
var vids = collect(json, VIDEO_KEYS, [], 0);
for (i = 0; i < vids.length; i++) {
v = toVideo(vids[i]);
if (v && !seen['v' + v.id]) { seen['v' + v.id] = 1; out.push(v); }
}
var lists = collect(json, LIST_KEYS, [], 0);
for (i = 0; i < lists.length; i++) {
v = toList(lists[i]);
if (v && !seen['l' + v.id]) { seen['l' + v.id] = 1; out.push(v); }
}
return out;
}
/* 诊断:响应到底长什么样。解析不出东西时直接摊在页面上,省得来回猜 */
function probeJson(json, kind, arg) {
if (!json) return '响应是空的';
var out = [];
out.push('kind=' + kind + ' arg=' + arg);
out.push('顶层键:' + Object.keys(json).join(', '));
var alerts = collect(json, ['alertRenderer', 'alertWithButtonRenderer'], [], 0);
if (alerts.length) {
out.push('<span class="bad">YouTube 提示:' + (text(alerts[0].text) || '未知') + '</span>');
}
if (json.error) {
out.push('<span class="bad">error: ' + (json.error.message || JSON.stringify(json.error).slice(0, 120)) + '</span>');
}
// 哪些 renderer 出现了?看看它到底给了什么结构
var seen = {}, i;
(function walk(n, d) {
if (!n || d > 40 || typeof n !== 'object') return;
if (Object.prototype.toString.call(n) === '[object Array]') {
for (i = 0; i < n.length; i++) walk(n[i], d + 1);
return;
}
var k;
for (k in n) {
if (!Object.prototype.hasOwnProperty.call(n, k)) continue;
if (k.indexOf('Renderer') !== -1) seen[k] = (seen[k] || 0) + 1;
walk(n[k], d + 1);
}
})(json, 0);
var names = Object.keys(seen).sort(function (a, b) { return seen[b] - seen[a]; }).slice(0, 14);
out.push('出现的 renderer' + (names.length ? names.map(function (n) { return n + '×' + seen[n]; }).join(', ') : '一个都没有'));
out.push('JSON 体积:' + Math.round(JSON.stringify(json).length / 1024) + ' KB');
return out.join('<br>');
}
function parseToken(json) {
var conts = collect(json, ['continuationItemRenderer'], [], 0);
var i, t;
for (i = 0; i < conts.length; i++) {
t = get(conts[i], 'continuationEndpoint.continuationCommand.token');
if (t) return t;
}
return '';
}
/* ---------- 数据源 ---------- */
function subName() {
if (!subStack || !subStack.length) return '';
var cur = subStack[subStack.length - 1];
return cur ? cur.name : '';
}
async function fetchPage(kind, arg, token) {
if (token) {
// continuation token 必须发回**产生它的那个接口**。
// 推荐/热门经常降级到 search 兜底,这时 token 是 search 的,
// 再按 tab 类型发给 browse 就会 400 —— 这就是"加载更多"报错的原因。
var ep = view.ep || ((kind === 'search') ? 'search' : 'browse');
var other = (ep === 'search') ? 'browse' : 'search';
try {
return await innertube(ep, { continuation: token });
} catch (e) {
return innertube(other, { continuation: token });
}
}
if (kind === 'search') {
view.ep = 'search';
return innertube('search', { query: arg });
}
// 频道:browseId 就是 UCxxxparams 定位到「视频」标签页
if (kind === 'channel') {
view.ep = 'browse';
var cj = null;
try {
cj = await innertube('browse', { browseId: arg, params: 'EgZ2aWRlb3PyBgQKAjoA' });
if (parseItems(cj).length) return cj;
} catch (ce) {}
// params 有时不认(频道布局改版),退回频道首页
cj = await innertube('browse', { browseId: arg });
if (parseItems(cj).length) return cj;
// 频道页解析不出来(YouTube 改版最勤的就是这块),
// 退回用频道名搜索,起码有内容可看
view.ep = 'search';
return innertube('search', { query: subName() || arg });
}
// 播放列表:browseId 是 VL + 列表ID,这是 InnerTube 的约定
if (kind === 'playlist') {
view.ep = 'browse';
return innertube('browse', { browseId: 'VL' + arg });
}
if (kind === 'browse') {
var json = null;
try {
json = await innertube('browse', { browseId: arg });
if (parseItems(json).length) { view.ep = 'browse'; return json; }
} catch (e2) {}
// 空信息流:YouTube 有时对某些地区 / 无 cookie 的请求直接给空。
// 推荐 → 热门 → 搜索,逐级降级,别让 tab 空着。
if (arg === 'FEwhat_to_watch') {
try {
json = await innertube('browse', { browseId: 'FEtrending' });
if (parseItems(json).length) { view.ep = 'browse'; return json; }
} catch (e3) {}
}
view.ep = 'search'; // 降级到搜索兜底,之后的 continuation 也得发给 search
return innertube('search', { query: TRENDING_QUERY[prefs.gl] || 'trending' });
}
return null;
}
/* ---------- 渲染 ---------- */
function card(item) {
var btn = document.createElement('button');
btn.className = 'card-inner';
btn.setAttribute('tabindex', '0');
var thumb = document.createElement('div');
thumb.className = 'thumb';
var img = document.createElement('img');
img.alt = '';
img.className = 'lazy';
img.setAttribute('data-src', item.pic);
lazyWatch(img);
thumb.appendChild(img);
if (item.kind === 'list') {
var lb = document.createElement('span');
lb.className = 'badge list';
lb.textContent = '播放列表 ' + (item.count || '');
thumb.appendChild(lb);
} else if (item.live) {
var live = document.createElement('span');
live.className = 'badge live';
live.textContent = 'LIVE';
thumb.appendChild(live);
} else if (item.length) {
var lg = document.createElement('span');
lg.className = 'badge';
lg.textContent = item.length;
thumb.appendChild(lg);
}
var meta = document.createElement('div');
meta.className = 'meta';
var t = document.createElement('div');
t.className = 'title';
t.textContent = item.title || '(无标题)';
var s = document.createElement('div');
s.className = 'sub';
var subParts = [];
if (item.author) subParts.push(item.author);
if (item.views) subParts.push(item.views);
if (item.time) subParts.push(item.time);
s.textContent = subParts.join(' · ');
meta.appendChild(t);
meta.appendChild(s);
btn.appendChild(thumb);
btn.appendChild(meta);
btn.addEventListener('click', function () {
// 播放列表:先展开成视频列表,让你挑,而不是整个丢给 App
if (item.kind === 'list') {
enterSub('playlist', item.id, item.title, (item.author || '') + (item.count ? ' · ' + item.count : ''), item.url);
return;
}
play(item);
});
// 长按视频 → 进这个频道
btn.addEventListener('contextmenu', function (e) {
e.preventDefault();
if (item.kind === 'video' && item.chan) {
enterSub('channel', item.chan, item.author || '频道', '频道视频', '');
} else if (native && fm.search) {
fm.search(item.title, { direct: true, pic: item.pic, wallPic: item.wall })['catch'](function () {});
}
});
return btn;
}
function renderInto(box, items) {
var frag = document.createDocumentFragment(), i, wrap;
for (i = 0; i < items.length; i++) {
wrap = document.createElement('div');
wrap.className = 'card';
wrap.appendChild(card(items[i]));
frag.appendChild(wrap);
}
box.appendChild(frag);
}
function showState(html) { elState.innerHTML = html; }
// 骨架屏是塞进 elState 的,所以清空逻辑不变,clearState 会一起干掉
function clearState() { elState.innerHTML = ''; }
/* 骨架屏:形状和真实卡片完全一致,数据到了直接换掉,不会跳动 */
function skeleton(n) {
var box = document.createDocumentFragment();
var i, card, inner, thumb, meta;
for (i = 0; i < n; i++) {
card = document.createElement('div');
card.className = 'card';
inner = document.createElement('div');
inner.className = 'card-inner';
thumb = document.createElement('div');
thumb.className = 'thumb';
meta = document.createElement('div');
meta.className = 'meta';
meta.innerHTML = '<div class="bar"></div><div class="bar short"></div>';
inner.appendChild(thumb);
inner.appendChild(meta);
card.appendChild(inner);
box.appendChild(card);
}
return box;
}
function loadingState() {
clearState();
elList.className = 'grid skel';
elList.replaceChildren(skeleton(12));
}
function endLoading() {
elList.className = 'grid';
}
function errorState(msg) {
showState(
'<strong>没能连上 YouTube</strong>' +
'<div>' + String(msg).replace(/</g, '&lt;') + '</div>' +
'<div style="margin-top:10px">这台设备访问 YouTube 需要代理。请在 App 里打开 <b>设置 → 增强功能 → Proxy 壳代理</b>' +
'或在点播配置顶层加 <code>proxy</code> 规则,覆盖 youtube.com / ytimg.com / googlevideo.com。</div>' +
'<button class="act" id="errNet">打开网络自检</button>'
);
var b = $('errNet');
if (b) b.addEventListener('click', function () { openSheet('net'); runDiag(); });
}
/* ---------- 列表加载 ---------- */
/* 允许随时切类别:
- 不再用 view.loading 互斥。之前"上一个还在加载就直接 return"
新类别的请求根本没发出去 —— 这才是"停在上个类别"的真正原因。
- 请求参数在发起瞬间快照。fetchPage 里读的是 view.kind/view.arg
等 await 回来时它们早被下一次滑动改掉了,拿旧参数的结果去渲染必然错乱。
- seq 严格守门:只有最后一次发起的请求才有权改 DOM,先回来的旧请求一律丢弃。 */
async function load(reset) {
var seq = ++view.seq;
// 快照,之后一律用局部变量,不再读 view.*
var kind = view.kind;
var arg = view.arg;
var token = reset ? '' : view.token;
view.loading = true;
if (reset) {
view.items = [];
view.token = '';
lazyList = [];
if (lazyIO) { lazyIO.disconnect(); lazyIO = null; }
elList.replaceChildren();
elMore.replaceChildren();
loadingState();
window.scrollTo(0, 0);
}
if (kind === 'recent') {
view.loading = false;
if (seq === view.seq) await renderRecent();
return;
}
try {
// 兜一层硬超时:fm.req 自带的 timeout 未必生效,
// 请求一旦悬住,await 永远不返回,骨架屏就卡死在那儿、连错误都报不出来。
var json = await Promise.race([
fetchPage(kind, arg, token),
new Promise(function (_, rej) {
setTimeout(function () { rej(new Error('请求超时(25s 无响应)')); }, 25000);
})
]);
if (seq !== view.seq) return; // 已经被新的一次滑动接管,这次结果作废
var items = parseItems(json);
view.token = parseToken(json);
netDot(true);
endLoading();
if (reset) elList.replaceChildren();
if (!items.length && !view.items.length) {
if (kind === 'channel' || kind === 'playlist') {
showState(
'<strong>解析不出内容</strong>' +
'<div style="text-align:left;margin-top:12px;font-size:11px;line-height:1.6;word-break:break-all">' +
probeJson(json, kind, arg) + '</div>'
);
} else {
showState('<strong>没有内容</strong><div>换个关键词,或到设置里把地区 gl 改成代理出口所在地区。</div>');
}
} else {
clearState();
view.items = view.items.concat(items);
renderInto(elList, items);
}
renderMore();
if (subStack.length) idleWarm(); // 频道里翻了新一页,把新的也热上
} catch (e) {
if (seq !== view.seq) return;
netDot(false);
endLoading();
if (view.items.length) toast('加载失败:' + e.message);
else { elList.replaceChildren(); errorState(e.message); }
} finally {
if (seq === view.seq) view.loading = false;
}
}
function renderMore() {
elMore.replaceChildren();
if (!view.token) return;
var b = document.createElement('button');
b.className = 'state act';
b.textContent = '加载更多';
b.addEventListener('click', function () {
if (!view.loading) load(false);
});
elMore.appendChild(b);
}
/* 可见时自动翻页(IntersectionObserver 不可用时用被动 scroll 兜底) */
function autoPage() {
function near() {
if (view.loading || !view.token || !elMore.firstChild) return;
var r = elMore.getBoundingClientRect();
if (r.top < (window.innerHeight + 400)) load(false);
}
window.addEventListener('scroll', function () { near(); lazyScan(); }, { passive: true });
setInterval(function () { near(); lazyScan(); }, 1200);
}
/* ---------- 最近观看(本页自己记录,不依赖 push_agent 历史过滤) ---------- */
async function pushRecent(item) {
try {
var raw = await store.get('recent');
var arr = raw ? JSON.parse(raw) : [];
arr = arr.filter(function (x) { return x.id !== item.id; });
arr.unshift({
kind: item.kind, id: item.id, title: item.title, author: item.author,
length: item.length, pic: item.pic, wall: item.wall, url: item.url, ts: Date.now()
});
if (arr.length > 60) arr = arr.slice(0, 60);
await store.set('recent', JSON.stringify(arr));
} catch (e) {}
}
async function renderRecent() {
endLoading();
elList.replaceChildren();
elMore.replaceChildren();
var arr = [];
try {
var raw = await store.get('recent');
arr = raw ? JSON.parse(raw) : [];
} catch (e) { arr = []; }
if (!arr.length) {
showState('<strong>还没有播放记录</strong><div>播放过的视频会留在这里,重启 App 后依然保留。</div>');
return;
}
clearState();
renderInto(elList, arr);
}
/* ---------- 画质解析 ----------
真相:YouTube 的 progressive 单流最高只有 720p1080p 以上一律是
video/audio 分离的自适应流。要让 App 播放器真的拿到 1080p+,唯一
稳的路子是 HLS:用 IOS 客户端问 player 接口要 hlsManifestUrl
再把主清单拆成一档一档的变体 m3u8。
然后用 fm.vodInline 把每一档画质注册成"一集",播放页的集数列表
就是画质切换器,mark 决定默认选中哪一档。
这样 URL 以 index.m3u8 结尾,播放器能正确识别 HLS;
headers 由 vodInline 直接交给播放器,不经过 /webResource
避免网关吃掉扩展名导致播放器按普通文件处理。
------------------------------------------------------------------ */
var PLAYER_CLIENTS = [
{
// Quest 3。目前少数不需要 PoToken 的客户端,取到的流不会播几十秒就被切断。
// youtube.py 把它排第一位,就是这个原因。
name: 'ANDROID_VR', id: '28', ver: '1.65.10',
ua: 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
client: {
clientName: 'ANDROID_VR', clientVersion: '1.65.10',
deviceMake: 'Oculus', deviceModel: 'Quest 3',
androidSdkVersion: 32, osName: 'Android', osVersion: '12L'
}
},
{
// IOS 常常给 HLS,但没有 PoToken 时流会在几十秒后 403
name: 'IOS', id: '5', ver: '21.02.3',
ua: 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)',
client: {
clientName: 'IOS', clientVersion: '21.02.3',
deviceMake: 'Apple', deviceModel: 'iPhone16,2',
osName: 'iPhone', osVersion: '18.3.2.22D82'
}
},
{
name: 'ANDROID', id: '3', ver: '21.02.35',
ua: 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip',
client: {
clientName: 'ANDROID', clientVersion: '21.02.35',
androidSdkVersion: 30, osName: 'Android', osVersion: '11'
}
},
{
name: 'MWEB', id: '2', ver: '2.20260115.01.00',
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
client: { clientName: 'MWEB', clientVersion: '2.20260115.01.00' }
},
{
name: 'TVHTML5', id: '85', ver: '2.0',
ua: 'Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15',
client: { clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', clientVersion: '2.0' },
embed: true
}
];
/* 硬编码 key 已经会被风控挡掉:先抓 watch 页取真实 INNERTUBE_API_KEY 和 visitorData */
var ytcfgCache = { key: '', visitor: '', ts: 0 };
async function ytcfg(videoId) {
if (ytcfgCache.key && (Date.now() - ytcfgCache.ts) < 1800000) return ytcfgCache;
try {
var html = await req(videoId ? (YT + '/watch?v=' + videoId) : (YT + '/'), {
headers: { 'User-Agent': DEF_UA, 'Accept-Language': prefs.hl },
timeout: 20
});
var k = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
var v = html.match(/"visitorData":"([^"]+)"/);
ytcfgCache = {
key: k ? k[1] : KEY,
visitor: v ? decodeURIComponent(JSON.parse('"' + v[1] + '"')) : '',
ts: Date.now()
};
} catch (e) {
ytcfgCache = { key: KEY, visitor: '', ts: Date.now() };
}
return ytcfgCache;
}
async function playerData(videoId, c, cfg) {
var client = { hl: prefs.hl, gl: prefs.gl };
var k;
for (k in c.client) client[k] = c.client[k];
client.userAgent = c.ua;
if (cfg.visitor) client.visitorData = cfg.visitor;
var body = {
videoId: videoId,
context: { client: client },
contentCheckOk: true,
racyCheckOk: true
};
if (c.embed) body.context.thirdParty = { embedUrl: YT };
var headers = {
'Content-Type': 'application/json',
'User-Agent': c.ua,
'Origin': YT,
'Referer': YT + '/',
'X-YouTube-Client-Name': c.id,
'X-YouTube-Client-Version': c.ver
};
if (cfg.visitor) headers['X-Goog-Visitor-Id'] = cfg.visitor;
return req(API + 'player?key=' + cfg.key + '&prettyPrint=false', {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
responseType: 'json',
timeout: 25
});
}
/* 主清单 → [{ height, url }],从高到低 */
function parseHls(txt) {
var lines = txt.split(/\r?\n/);
var out = [], seen = {}, i, m, h, url;
for (i = 0; i < lines.length; i++) {
if (lines[i].indexOf('#EXT-X-STREAM-INF') !== 0) continue;
m = lines[i].match(/RESOLUTION=(\d+)x(\d+)/);
h = m ? parseInt(m[2], 10) : 0;
url = '';
while (++i < lines.length) {
if (lines[i] && lines[i].charAt(0) !== '#') { url = lines[i].trim(); break; }
}
if (!url || !h || seen[h]) continue;
seen[h] = 1;
out.push({ height: h, url: url });
}
out.sort(function (a, b) { return b.height - a.height; });
return out;
}
/* 只留选中那一档的主清单。
#EXT-X-MEDIA(音轨/字幕)整组保留,否则挑了纯视频变体会没声音。
YouTube 主清单里的 URI 都是绝对地址,不用做相对路径拼接。 */
function pinMaster(txt, want) {
var lines = String(txt).split(/\r?\n/);
var head = [], streams = [], i, line, m, h, url;
for (i = 0; i < lines.length; i++) {
line = lines[i];
if (line.indexOf('#EXT-X-STREAM-INF') === 0) {
m = line.match(/RESOLUTION=(\d+)x(\d+)/);
h = m ? parseInt(m[2], 10) : 0;
url = '';
while (++i < lines.length) {
if (lines[i] && lines[i].charAt(0) !== '#') { url = lines[i].trim(); break; }
}
if (url && h) streams.push({ h: h, inf: line, url: url });
continue;
}
if (line.indexOf('#EXTM3U') === 0 ||
line.indexOf('#EXT-X-VERSION') === 0 ||
line.indexOf('#EXT-X-INDEPENDENT-SEGMENTS') === 0 ||
line.indexOf('#EXT-X-MEDIA') === 0) {
head.push(line);
}
}
if (!streams.length) throw new Error('主清单里没有变体');
streams.sort(function (a, b) { return b.h - a.h; });
var pick = streams[0];
var cap = (want === 'best') ? 0 : parseInt(want, 10);
if (cap) {
for (i = 0; i < streams.length; i++) {
if (streams[i].h <= cap) { pick = streams[i]; break; }
}
}
if (head.indexOf('#EXTM3U') === -1) head.unshift('#EXTM3U');
return {
text: head.join('\n') + '\n' + pick.inf + '\n' + pick.url + '\n',
height: pick.h,
url: pick.url
};
}
function m3u8DataUri(txt) {
return 'data:application/vnd.apple.mpegurl;base64,' + btoa(unescape(encodeURIComponent(txt)));
}
function heightOf(f) {
if (f.height) return f.height;
var m = (f.qualityLabel || '').match(/(\d+)p/);
return m ? parseInt(m[1], 10) : 0;
}
/* 单流兜底:有音轨,但 YouTube 上限 720p */
function parseMuxed(sd) {
var list = sd.formats || [], out = [], i, f, h;
for (i = 0; i < list.length; i++) {
f = list[i];
if (!f.url) continue; // signatureCipher 的没法直接播
if (f.mimeType && f.mimeType.indexOf('video') !== 0) continue;
h = heightOf(f);
if (!h) continue;
out.push({ height: h, url: f.url });
}
out.sort(function (a, b) { return b.height - a.height; });
return out;
}
/* 返回 { levels, ua, kind, client },失败返回 null;why 里留失败原因,供自检显示 */
var lastWhy = [];
/* 一个客户端能给的东西,按可用性排序:
hls 主清单,最省事
adaptive 分离流(video-only + audio-only),得自己拼 MPD
muxed 单流 mp4,有音轨但上限 720P,且常常只剩 360P 以下 */
function pickAdaptive(sd, json) {
var af = sd.adaptiveFormats || [];
var hasV = false, hasA = false, j, f, mime;
for (j = 0; j < af.length; j++) {
f = af[j];
if (!f.url || !f.indexRange || !f.initRange) continue; // 带 signatureCipher 的解不了
mime = f.mimeType || '';
if (mime.indexOf('video') === 0 && f.height) hasV = true;
if (mime.indexOf('audio') === 0) hasA = true;
}
if (!hasV || !hasA) return null;
return {
kind: 'adaptive',
formats: af,
duration: parseInt(get(json, 'videoDetails.lengthSeconds') || 0, 10) || 0
};
}
async function resolve(videoId) {
lastWhy = [];
var i, json, sd, status, master, levels, c;
var adaptive = null, muxed = null;
var list = PLAYER_CLIENTS;
if (prefs.client && prefs.client !== 'auto') {
list = PLAYER_CLIENTS.filter(function (x) { return x.name === prefs.client; });
if (!list.length) list = PLAYER_CLIENTS;
}
// visitorData 是拿到分离流的前提,裸打(不带 visitor)几乎必然 LOGIN_REQUIRED。
// 所以直接等 ytcfg,别浪费那次注定失败的裸打往返。
// ytcfg 缓存 30 分钟:只有冷启动第一次要等 watch 页,之后命中缓存、瞬间返回。
var cfg = await ytcfg(videoId);
var bareCfg = { key: KEY, visitor: '' };
for (i = 0; i < list.length; i++) {
c = list[i];
json = null;
try { json = await playerData(videoId, c, cfg); } catch (e) {}
// 带 visitor 反被拒时(visitorData 可能过期),裸打兜一次底
if (!json || !json.streamingData) {
try { json = await playerData(videoId, c, bareCfg); } catch (e2) {}
}
status = get(json, 'playabilityStatus.status');
sd = json ? json.streamingData : null;
if (!sd) {
lastWhy.push(c.name + '' + (status || '被拒'));
continue;
}
if (c.name === 'ANDROID_VR') {
var vr = pickAdaptive(sd, json);
if (vr) { vr.ua = c.ua; vr.client = c.name; return vr; }
lastWhy.push('ANDROID_VR:没有可用分离流');
}
if (sd.hlsManifestUrl) {
try {
master = await req(sd.hlsManifestUrl, {
headers: { 'User-Agent': c.ua, Referer: YT + '/' }, timeout: 20
});
levels = parseHls(master);
if (levels.length) {
return { kind: 'hls', levels: levels, ua: c.ua, client: c.name, master: String(master), masterUrl: sd.hlsManifestUrl };
}
} catch (e3) { lastWhy.push(c.name + ':主清单拉不动'); }
}
if (!adaptive) {
adaptive = pickAdaptive(sd, json);
if (adaptive) { adaptive.ua = c.ua; adaptive.client = c.name; }
}
if (!muxed) {
levels = parseMuxed(sd);
if (levels.length) muxed = { kind: 'muxed', levels: levels, ua: c.ua, client: c.name };
}
if (!sd.hlsManifestUrl) lastWhy.push(c.name + ':没有 HLS');
// 已经拿到分离流(能拼高画质)就别再往下试了,直接用
if (adaptive) return adaptive;
}
return adaptive || muxed;
}
/* ---------- 自己拼 MPD ----------
和 youtube.py 的 _proxy_mpd 一个套路:SegmentBase + initRange/indexRange
BaseURL 直接写 googlevideo 直链。区别是我们没有本地服务端托管,
所以拼完用 data: URI 交给播放器,并靠 vodInline resolver 显式声明
format: application/dash+xml,同时把取流客户端的 UA 从 headers 带下去(不然 403)。 */
function xmlEsc(t) {
return String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function codecsOf(mime) {
var m = String(mime).match(/codecs="([^"]+)"/);
return m ? m[1] : '';
}
function mimeOf(mime) {
return String(mime).split(';')[0];
}
/* 关键:BaseURL 不能直接写 googlevideo 直链。
resolver 下发的 headers 只作用于第一次请求,拖动时 ExoPlayer 会对 BaseURL
发新的 Range 请求,那次没带 UAgooglevideo 直接 403(播放页显示 Bad HTTP Status)。
所以把每条流包成 fm.res 的 /webResource 网关地址:网关把 headers 和 URL 绑定,
每一次分片请求都会带上正确的 UA,而且它原生支持 Range。 */
var gatewayNote = '';
/* 本地网关地址。
拖动时 ExoPlayer 会对 BaseURL 发新的 Range 请求,那次请求不带 resolver 的 headers
googlevideo 见 UA 不对直接 403 —— 这就是"能播、一拖就 Bad HTTP Status"的原因。
把流包进 /webResourceheaders 和 URL 绑在一起存在网关里,
之后每一次 Range 请求(拖动、缓冲、切轨)网关都会补上正确的 UA,且它原生透传 Range。
优先用 fm.res();万一这个版本的 fm.res 形态不对,就按文档给的 URL 形态自己拼,
端口从 location.origin 拿(WebHome 页面本来就是这个本地服务器发出来的)。 */
function gwOrigin() {
if (native && window.fm && fm.res) {
try {
var probe = fm.res('https://example.com/probe.bin');
if (typeof probe === 'string' && probe.indexOf('/webResource') !== -1) {
return probe.slice(0, probe.indexOf('/webResource'));
}
} catch (e) {}
}
if (location.protocol.indexOf('http') === 0 && location.host) return location.origin;
return '';
}
function streamUrl(url, ua) {
var headers = { 'User-Agent': ua, Referer: YT + '/', Origin: YT };
if (prefs.seg === 'direct') { gatewayNote = '按设置走直链'; return url; }
if (native && window.fm && fm.res) {
try {
var out = fm.res(url, { headers: headers });
if (typeof out === 'string' && out.indexOf('/webResource') !== -1) return out;
} catch (e) {
gatewayNote = 'fm.res 出错:' + e.message;
}
}
var origin = gwOrigin();
if (origin) {
gatewayNote = gatewayNote || '自拼网关地址';
return origin + '/webResource?url=' + encodeURIComponent(url) +
'&headers=' + encodeURIComponent(JSON.stringify(headers));
}
gatewayNote = '拿不到网关端口,只能用直链(拖动会 403)';
return url;
}
function buildMpd(r, want) {
var af = r.formats, vids = [], auds = [], i, f, mime;
for (i = 0; i < af.length; i++) {
f = af[i];
if (!f.url || !f.initRange || !f.indexRange) continue;
mime = mimeOf(f.mimeType || '');
if (mime.indexOf('video') === 0 && f.height) {
vids.push({ f: f, h: f.height, mp4: mime.indexOf('mp4') !== -1, bw: f.bitrate || 0, mime: mime });
} else if (mime.indexOf('audio') === 0) {
auds.push({ f: f, mp4: mime.indexOf('mp4') !== -1, bw: f.bitrate || 0, mime: mime });
}
}
if (!vids.length || !auds.length) throw new Error('分离流不完整');
vids.sort(function (a, b) {
if (b.h !== a.h) return b.h - a.h;
if (a.mp4 !== b.mp4) return a.mp4 ? -1 : 1; // 同档优先 avc1,老设备最稳
return b.bw - a.bw;
});
var v = vids[0];
var cap = (want === 'best') ? 0 : parseInt(want, 10);
if (cap) {
for (i = 0; i < vids.length; i++) if (vids[i].h <= cap) { v = vids[i]; break; }
}
auds.sort(function (a, b) {
if (a.mp4 !== b.mp4) return a.mp4 ? -1 : 1;
return b.bw - a.bw;
});
var a2 = auds[0];
var dur = r.duration || 0;
gatewayNote = '';
var vUrl = streamUrl(v.f.url, r.ua);
var aUrl = streamUrl(a2.f.url, r.ua);
function rep(x, base, extra) {
var f2 = x.f;
return '<Representation id="' + f2.itag + '" bandwidth="' + (f2.bitrate || 500000) + '"' +
' codecs="' + xmlEsc(codecsOf(f2.mimeType)) + '"' + extra + '>' +
'<BaseURL>' + xmlEsc(base) + '</BaseURL>' +
'<SegmentBase indexRange="' + f2.indexRange.start + '-' + f2.indexRange.end + '">' +
'<Initialization range="' + f2.initRange.start + '-' + f2.initRange.end + '"/>' +
'</SegmentBase></Representation>';
}
var mpd = '<?xml version="1.0" encoding="UTF-8"?>' +
'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static"' +
' mediaPresentationDuration="PT' + dur + 'S" minBufferTime="PT1.5S"' +
' profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">' +
'<Period id="1" start="PT0S">' +
'<AdaptationSet mimeType="' + v.mime + '" startWithSAP="1" segmentAlignment="true">' +
rep(v, vUrl, ' width="' + v.f.width + '" height="' + v.f.height + '"') +
'</AdaptationSet>' +
'<AdaptationSet mimeType="' + a2.mime + '" startWithSAP="1" segmentAlignment="true" lang="und">' +
rep(a2, aUrl, ' audioSamplingRate="44100"') +
'</AdaptationSet>' +
'</Period></MPD>';
return {
xml: mpd, height: v.h,
size: (v.f.width || '?') + '×' + (v.f.height || '?'),
proxied: vUrl.indexOf('/webResource') !== -1,
mediaUrl: vUrl,
note: gatewayNote
};
}
function mpdDataUri(xml) {
return 'data:application/dash+xml;base64,' + btoa(unescape(encodeURIComponent(xml)));
}
function pickLevel(levels, want) {
var i;
if (want === 'best' || !want) return levels[0];
var cap = parseInt(want, 10);
for (i = 0; i < levels.length; i++) if (levels[i].height && levels[i].height <= cap) return levels[i];
return levels[levels.length - 1];
}
function levelName(l) { return l.height ? (l.height + 'P') : '自动'; }
/* ---------- Invidious DASH ----------
网页没有本地服务端,合不了 video-only + audio-only。
Invidious 的 /api/manifest/dash/id/{id} 已经是拼好的 MPD。
但原始清单是自适应的:ExoPlayer 从最低码率起步再往上爬,
网络一抖就一直待在低档 —— 看起来就是"最低画质"。
所以这里把清单改写掉:只保留目标分辨率那一条视频 Representation
和一条最好的音轨,其余全删。播放器没得挑,只能播这一档。
改写后的清单没有 URL 可托管,于是用 data: URI 交给播放器,
并通过 fm.vodInline 的逐集 resolver 显式带上
format: application/dash+xml(文档 18.3)——
不然播放器靠扩展名猜类型,只会把它当普通文件。
------------------------------------------------------------------ */
var INV_LIST = [
'https://inv.nadeko.net',
'https://invidious.nerdvpn.de',
'https://yewtu.be',
'https://invidious.f5.si',
'https://iv.melmac.space',
'https://invidious.privacyredirect.com'
];
var invOk = '';
function invHosts() {
if (prefs.inv) return [prefs.inv].concat(INV_LIST);
return INV_LIST;
}
async function invPick(videoId) {
if (invOk) return invOk;
var hosts = invHosts(), i, r;
for (i = 0; i < hosts.length; i++) {
try {
r = await req(hosts[i] + '/api/v1/videos/' + videoId + '?fields=videoId', {
responseType: 'json', timeout: 10
});
if (r && r.videoId) { invOk = hosts[i]; return invOk; }
} catch (e) {}
}
return '';
}
function invDash(host, videoId) {
return host + '/api/manifest/dash/id/' + videoId + '?local=false';
}
function attrInt(el, name) {
var v = el.getAttribute(name);
return v ? parseInt(v, 10) : 0;
}
/* 只留一档视频 + 一条音轨。返回 { xml, height } */
function pinManifest(mpdText, want) {
var doc = new DOMParser().parseFromString(mpdText, 'text/xml');
if (!doc || doc.getElementsByTagName('MPD').length === 0) throw new Error('清单不是 MPD');
var sets = Array.from(doc.getElementsByTagName('AdaptationSet'));
var cap = (want === 'best') ? 0 : parseInt(want, 10);
var vids = [], auds = [], i, j, reps, r, mime, h;
for (i = 0; i < sets.length; i++) {
mime = sets[i].getAttribute('mimeType') || '';
reps = Array.from(sets[i].getElementsByTagName('Representation'));
for (j = 0; j < reps.length; j++) {
r = reps[j];
if (mime.indexOf('video') === 0) {
h = attrInt(r, 'height');
if (h) vids.push({ set: sets[i], rep: r, h: h, bw: attrInt(r, 'bandwidth'), mp4: mime.indexOf('mp4') !== -1 });
} else if (mime.indexOf('audio') === 0) {
auds.push({ set: sets[i], rep: r, bw: attrInt(r, 'bandwidth'), mp4: mime.indexOf('mp4') !== -1 });
}
}
}
if (!vids.length || !auds.length) throw new Error('清单里没有可用的视频或音频轨');
// 同一档位优先 mp4/avc1,老设备兼容性最好
vids.sort(function (a, b) {
if (b.h !== a.h) return b.h - a.h;
if (a.mp4 !== b.mp4) return a.mp4 ? -1 : 1;
return b.bw - a.bw;
});
var pickV = vids[0];
if (cap) {
for (i = 0; i < vids.length; i++) {
if (vids[i].h <= cap) { pickV = vids[i]; break; }
}
}
auds.sort(function (a, b) {
if (a.mp4 !== b.mp4) return a.mp4 ? -1 : 1;
return b.bw - a.bw;
});
var pickA = auds[0];
// 删掉没被选中的 Representation,再删掉空掉的 AdaptationSet
var all = vids.concat(auds), item;
for (i = 0; i < all.length; i++) {
item = all[i];
if (item.rep !== pickV.rep && item.rep !== pickA.rep) {
item.rep.parentNode.removeChild(item.rep);
}
}
for (i = 0; i < sets.length; i++) {
if (sets[i].getElementsByTagName('Representation').length === 0 && sets[i].parentNode) {
sets[i].parentNode.removeChild(sets[i]);
}
}
return { xml: new XMLSerializer().serializeToString(doc), height: pickV.h };
}
function toDataUri(xml) {
var b64 = btoa(unescape(encodeURIComponent(xml))); // btoa 不吃非 ASCII,先转 UTF-8
return 'data:application/dash+xml;base64,' + b64;
}
/* vodInline 的逐集解析回调:只有这里能告诉播放器 format */
var inlineMap = {};
var lazyEps = {}; // 集名 → videoId
/* resolver 必须同步返回。之前返回 Promise(异步现场解析),
App 拿到个 Promise 不知道怎么处理,就一直挂着 —— 表现就是"推送中"不动。
现在:命中缓存就给高画质直链,没命中就把 watch 链接交回去让 App 内置解析兜底,
同时后台悄悄把后面几集预解析好,切过去时就已经是高画质了。 */
window.__fmWebHomeInlineResolver = function (ep) {
var name = (ep && ep.name) || '';
var hit = inlineMap[name];
if (hit) return { url: hit.url, format: hit.format, headers: hit.headers || {} };
var vid = lazyEps[name] || (ep && ep.pageUrl ? vidOf(ep.pageUrl) : '');
if (!vid) return null;
// 缓存没命中:立刻插队解析这一集,并把它排到预热队列最前面。
// 这里只能同步返回,所以先把 watch 链接交回去让 App 内置解析顶上,
// 不至于播不了 —— 但正常情况下预热已经跑在前面,走不到这一步。
warmup(vid, name);
return { url: YT + '/watch?v=' + vid };
};
function vidOf(url) {
var m = String(url).match(/[?&]v=([\w-]{11})/);
return m ? m[1] : '';
}
/* 把某一集解析好塞进 inlineMap,供 resolver 同步取用 */
var warming = {};
/* 单集预热。优先级:分离流(1080P/4K) > 单流(720P) > 不缓存(交给 App 内置解析)。
只要拿到东西就缓存 —— 之前"只认分离流",结果预热失败的集直接掉到内置解析,
反而比 720P 还糟。低画质的会被标记,后台稍后再试着升级。 */
var lowQ = {}; // 集名 → videoId,这些集目前只有单流,等着升级
async function warmup(vid, epName) {
if (!vid || !epName || warming[epName]) return 0;
if (inlineMap[epName] && !lowQ[epName]) return 2; // 已经是高画质了
warming[epName] = 1;
var level = 0; // 0=没拿到 1=单流 2=分离流
try {
var r = await resolve(vid);
if (r && r.kind === 'adaptive') {
var built = buildMpd(r, prefs.quality);
inlineMap[epName] = {
url: mpdDataUri(built.xml),
format: 'application/dash+xml',
headers: built.proxied ? {} : { 'User-Agent': r.ua, Referer: YT + '/' }
};
delete lowQ[epName];
level = 2;
} else if (r && r.kind === 'muxed') {
// 720P 顶多算凑合,但比掉到内置解析强。先存着,标记待升级。
var lv = pickLevel(r.levels, prefs.quality);
if (!inlineMap[epName]) {
inlineMap[epName] = { url: lv.url, headers: { 'User-Agent': r.ua, Referer: YT + '/' } };
}
lowQ[epName] = vid;
level = 1;
}
} catch (e) {}
delete warming[epName];
return level;
}
function sleep(ms) {
return new Promise(function (r) { setTimeout(r, ms); });
}
var warmGen = 0;
/* 串行预热。并发是限流的根源:并发一高,player 接口立刻回 LOGIN_REQUIRED
拿不到分离流,整批退成 720P。慢一点,但拿到的是 1080P/4K。 */
async function warmupMany(eps, idxList, gen, onProgress) {
var done = 0, hq = 0, i, ep, lvl;
for (i = 0; i < idxList.length; i++) {
if (gen !== warmGen) return hq;
ep = eps[idxList[i]];
if (!ep) continue;
lvl = await warmup(lazyEps[ep.name], ep.name);
done++;
if (lvl === 2) hq++;
if (onProgress) onProgress(done, idxList.length, hq);
await sleep(lvl === 2 ? 120 : 500); // 刚被限流过就多歇会儿
}
return hq;
}
/* 进频道后的后台预热。
和推送时的预热是同一套缓存,所以点进去再推送时基本已经热好了。
节奏刻意放慢:这是"闲着没事干"的活,不能因为它把 player 接口打到限流,
否则真正要播的那一集反而拿不到分离流。 */
var idleTimer = 0;
function idleWarm() {
clearTimeout(idleTimer);
var gen = ++warmGen;
idleTimer = setTimeout(async function () {
if (gen !== warmGen || !subStack.length) return;
var vids = view.items.filter(function (x) { return x.kind === 'video'; });
if (!vids.length) return;
// 建立集名映射(和 pushList 用同一套命名,缓存才能对上)
var eps = vids.map(function (v, i) {
var n = epNameOf(v, i);
lazyEps[n] = v.id;
return { name: n };
});
var idx = [], i;
for (i = 0; i < eps.length && idx.length < 30; i++) {
if (!inlineMap[eps[i].name]) idx.push(i);
}
if (!idx.length) return;
await warmupMany(eps, idx, gen, null);
await upgradePass(gen);
}, 1200); // 等列表渲染完、手指停下来再开始,别和首屏抢带宽
}
/* 升级遍:把只拿到 720P 的集,隔一会儿再试一次分离流 */
async function upgradePass(gen) {
var names = Object.keys(lowQ), i, n;
for (i = 0; i < names.length; i++) {
if (gen !== warmGen) return;
n = names[i];
if (!lowQ[n]) continue;
await sleep(1200);
var vid = lowQ[n];
delete inlineMap[n]; // 清掉 720P 那份,逼 warmup 重新解析
delete lowQ[n];
await warmup(vid, n);
}
}
async function playDash(item) {
var host = await invPick(item.id);
if (!host) throw new Error('没有可用的 Invidious 实例');
var name, url;
if (prefs.dashmode === 'raw') {
name = '自动';
url = invDash(host, item.id);
} else {
var mpd = await req(invDash(host, item.id), { timeout: 20 });
var pinned = pinManifest(String(mpd), prefs.quality);
name = pinned.height + 'P';
url = toDataUri(pinned.xml);
}
inlineMap = {};
inlineMap[name] = {
url: url,
format: 'application/dash+xml',
headers: { 'User-Agent': DEF_UA }
};
await fm.vodInline({
vod_id: 'yt_' + item.id,
vod_name: item.title || 'YouTube',
vod_pic: item.pic,
wallPic: wallOf(item),
vod_play_from: 'DASH',
mark: name,
episodes: [{ name: name, url: url, resolve: true, pageUrl: item.url }]
});
toast('DASH · ' + (prefs.dashmode === 'raw' ? '自适应' : '锁定 ' + name) + ' · ' + host.replace(/^https?:\/\//, ''));
if (fm.ext && fm.ext.log) {
fm.ext.log('yt dash', { host: host, want: prefs.quality, pinned: name })['catch'](function () {});
}
}
/* 文档 18.3wallPic 为空时播放页用 App 默认壁纸,不会拿 pic 兜底。
视频封面当背景太花,标题和简介压在上面看不清 —— 默认干脆不传。 */
function wallOf(item) {
return prefs.wall === 'pic' ? (item.wall || item.pic || '') : '';
}
/* 集名。原生网格只显示序号,但"更多集数"弹窗、上一集/下一集提示、
以及历史记录里的 episodeName 都会显示全名,所以标题得带上。
序号前缀还负责保证唯一性 —— 同名视频会让 resolver 查表撞车。 */
function epNameOf(v, i) {
var n = String(i + 1);
if (n.length < 2) n = '0' + n;
var t = (v.title || '');
// 文档 12:集数用 # 分隔,集名和地址用 $ 分隔,线路用 $$$ 分隔。
// YouTube 标题里 # 和 $ 遍地都是(话题标签、价格),
// 不清掉的话一个 # 就能把这一集截断,后面整个列表全乱 —— 这就是"只推过去几集"的原因。
t = t.replace(/[#$]/g, ' ').replace(/\s+/g, ' ').trim();
if (t.length > 42) t = t.slice(0, 42) + '…';
return t ? (n + ' ' + t) : n;
}
/* 把整个列表推成多集。
current = 点开的那一集:它已经解析好了,直接进缓存,起播即高画质。
其余集先给 watch 链接(App 内置解析能播),后台再逐个预解析成高画质。 */
/* 推送前把列表尽量拉全:view.items 里只有已加载的那几十条,
你在频道里没翻到底,推过去自然就少。这里自动多翻几页。 */
async function fillList(maxPages) {
var pages = 0;
while (view.token && pages < maxPages) {
var before = view.items.length;
toast('正在拉取列表… ' + view.items.length + ' 个');
await load(false);
if (view.items.length <= before) break; // 没有新内容了,停
pages++;
}
}
async function pushList(items, name, pic, wall, current, ready, autoFill) {
if (!native) { toast('只能在 App 内推送'); return; }
if (!fm.vodInline) { toast('这个 App 版本没有 fm.vodInline'); return; }
if (autoFill) {
await fillList(4); // 最多再翻 4 页,约 120 个
items = view.items;
}
if (!items || !items.length) { toast('列表还没加载出来'); return; }
var vids = items.filter(function (x) { return x.kind === 'video'; });
if (!vids.length) { toast('列表里没有可播的视频'); return; }
if (vids.length > 200) vids = vids.slice(0, 200);
lazyEps = {};
inlineMap = {};
var markName = '', markIdx = 0;
var eps = vids.map(function (v, i) {
var epName = epNameOf(v, i);
lazyEps[epName] = v.id;
if (current && v.id === current.id) { markName = epName; markIdx = i; }
return { name: epName, url: v.url, resolve: true, pageUrl: v.url };
});
if (markName && ready) inlineMap[markName] = ready; // 当前这集:已解析好,直接进缓存
if (!markName) { markName = eps[0].name; markIdx = 0; }
var gen = ++warmGen;
/* 进频道时已经在后台预热了,这里大概率直接命中缓存,不用干等。
只补选中那一集往后还没热的少数几集 —— resolver 是同步的,
缓存没热就只能给 watch 链接,那就掉到 App 内置解析了。 */
var PREWARM = 5;
var order = [], i, cached = 0;
for (i = 0; i < eps.length; i++) if (inlineMap[eps[i].name]) cached++;
for (i = markIdx; i < eps.length && order.length < PREWARM; i++) if (!inlineMap[eps[i].name]) order.push(i);
for (i = 0; i < markIdx && order.length < PREWARM; i++) if (!inlineMap[eps[i].name]) order.push(i);
var got = 0;
if (order.length) {
got = await warmupMany(eps, order, gen, function (d, t, hq) {
toast('补解析 ' + d + '/' + t + '…');
});
}
if (gen !== warmGen) return; // 中途又推了一次,这批作废
var head = current || vids[0];
await fm.vodInline({
vod_id: 'ytlist_' + (head.id || Date.now()),
vod_name: name || 'YouTube',
vod_pic: pic || head.pic,
wallPic: wall,
vod_play_from: '最高画质',
mark: markName,
episodes: eps
});
// 后台:先把没热的补齐,再回头把只拿到 720P 的升级成分离流
var rest = [];
for (i = 0; i < eps.length; i++) if (!inlineMap[eps[i].name]) rest.push(i);
(async function () {
if (rest.length) await warmupMany(eps, rest, gen, null);
await upgradePass(gen);
})();
toast('已推送 ' + eps.length + ' 集 · 已热 ' + (cached + got) + ' 集');
}
/* ---------- 播放 ----------
三条路,按可靠性排序:
spider —— 交给站点自己的 Python Spideryoutube.py)。
它在 localProxy 里现场合成 MPD,把 video-only + audio-only
两条 adaptive 流合起来,所以 1080P / 2K / 4K 才有音轨。
网页做不到这件事:网页没有本地服务端,托管不了清单。
vodId 传 "VIDEOID@best|4k|2k|1080p"Spider 会把这一档
排到第一条线路,播放页里切线路就是切画质。
hls —— 不想装 Spider 时用。IOS 客户端的 HLS 主清单里挑一档变体,
有音轨,但 YouTube 的 iOS HLS 一般到 1080P 为止。
newpipe —— 完全交给 App 内置解析,画质由播放器决定。
------------------------------------------------------------------ */
var playing = false;
var lastPlay = null; // 起播现场,给自检页看
async function playHls(item) {
var r = await resolve(item.id);
if (!r) throw new Error(lastWhy.length ? lastWhy.join('') : '拿不到可播流');
var inSub = subStack.length > 0; // 在频道/播放列表里 → 整个列表一起推
var name, url, fmt, mode = prefs.hlsmode;
// ---- 分离流:自己拼 MPDdata URI 交付 ----
if (r.kind === 'adaptive') {
if (!fm.vodInline) throw new Error('App 不支持 vodInline,交付不了自拼清单');
var built = buildMpd(r, prefs.quality);
name = built.height + 'P';
url = mpdDataUri(built.xml);
inlineMap = {};
// BaseURL 走网关时,UA 由网关按 URL 绑定下发,拖动的每次 Range 请求都带得上。
// 万一退回了直链,就还是把 UA 塞进 resolver headers,起码起播能过。
inlineMap[name] = built.proxied
? { url: url, format: 'application/dash+xml' }
: { url: url, format: 'application/dash+xml', headers: { 'User-Agent': r.ua, Referer: YT + '/' } };
lastPlay = {
engine: 'dash(自拼)', mode: '-', client: r.client,
want: prefs.quality, got: name,
scheme: 'data URI' + Math.round(url.length / 1024) + 'KB)· 分片走 ' + (built.proxied ? '/webResource 网关' : 'googlevideo 直链'),
head: 'MPD: ' + built.size + ' + 音轨' + (built.note ? ' · ' + built.note : ''),
via: 'fm.vodInline + resolver', why: lastWhy.join(''),
time: new Date().toLocaleTimeString()
};
await fm.vodInline({
vod_id: 'yt_' + item.id,
vod_name: item.title || 'YouTube',
vod_pic: item.pic,
wallPic: wallOf(item),
vod_play_from: '最高画质',
mark: name,
episodes: [{ name: name, url: url, resolve: true, pageUrl: item.url }]
});
toast(r.client + ' · ' + name);
if (fm.ext && fm.ext.log) fm.ext.log('yt dash', lastPlay)['catch'](function () {});
return;
}
// ---- 单流兜底:mp4 直链,必须带 UA,否则 403 ----
if (r.kind === 'muxed') {
var lv = pickLevel(r.levels, prefs.quality);
name = levelName(lv) + '(单流)';
lastPlay = {
engine: 'muxed', mode: '-', client: r.client,
want: prefs.quality, got: name,
scheme: 'mp4 直链 → /webResource',
head: lv.url.slice(0, 90),
via: 'fm.play + headers', why: lastWhy.join(''),
time: new Date().toLocaleTimeString()
};
await fm.play(lv.url, item.title || 'YouTube', {
pic: item.pic, wallPic: wallOf(item),
headers: { 'User-Agent': r.ua, Referer: YT + '/' }
});
toast(r.client + ' · ' + name + '(只剩单流)');
return;
}
// ---- HLS ----
var splitAudio = r.master && r.master.indexOf('#EXT-X-MEDIA:TYPE=AUDIO') !== -1;
if (mode === 'variant' && splitAudio && fm.vodInline) mode = 'pin';
if (!r.master) mode = 'master';
if (mode === 'master') {
name = '自适应';
url = r.masterUrl || r.levels[0].url;
} else if (mode === 'variant') {
var lv2 = pickLevel(r.levels, prefs.quality);
name = levelName(lv2);
url = lv2.url;
} else {
var pinned = pinMaster(r.master, prefs.quality);
name = pinned.height + 'P';
url = m3u8DataUri(pinned.text);
}
fmt = 'application/x-mpegURL';
lastPlay = {
engine: 'hls', mode: mode + (mode !== prefs.hlsmode ? '(自动切)' : ''), client: r.client,
want: prefs.quality, got: name,
scheme: url.indexOf('data:') === 0 ? 'data URI' : 'https 直链',
head: url.slice(0, 90),
via: '', why: lastWhy.join(''),
time: new Date().toLocaleTimeString()
};
if (mode === 'pin' && fm.vodInline) {
inlineMap = {};
inlineMap[name] = { url: url, format: fmt, headers: { 'User-Agent': r.ua } };
lastPlay.via = 'fm.vodInline + resolver';
await fm.vodInline({
vod_id: 'yt_' + item.id,
vod_name: item.title || 'YouTube',
vod_pic: item.pic,
wallPic: wallOf(item),
vod_play_from: 'YouTube HLS',
mark: name,
episodes: [{ name: name, url: url, resolve: true, pageUrl: item.url }]
});
} else {
// m3u8 原地址:不能带 headers,一带就被改写成 /webResource,扩展名没了
lastPlay.via = 'fm.play';
await fm.play(url, item.title || 'YouTube', { pic: item.pic, wallPic: wallOf(item) });
}
toast(r.client + ' · ' + name + ' · ' + lastPlay.via);
if (fm.ext && fm.ext.log) fm.ext.log('yt hls', lastPlay)['catch'](function () {});
}
async function play(item) {
if (playing) return;
playing = true;
await pushRecent(item);
if (!native || !window.fm || !fm.play) {
window.open(item.url, '_blank');
playing = false;
return;
}
try {
if (fm.preloadArtwork) { try { await fm.preloadArtwork(item.pic, wallOf(item)); } catch (e) {} }
if (item.kind === 'list' || prefs.engine === 'newpipe') {
await fm.play(item.url, item.title || 'YouTube', { pic: item.pic, wallPic: wallOf(item) });
playing = false;
return;
}
if (prefs.engine === 'dash' && fm.vodInline) {
try {
await playDash(item);
playing = false;
return;
} catch (e) {
toast('DASH 失败:' + e.message + ',改走 HLS');
}
}
try {
await playHls(item);
} catch (e) {
lastPlay = {
engine: '退回 NewPipe', mode: '-', client: '-',
want: prefs.quality, got: '-',
scheme: '-', head: '解析/交付出错:' + e.message,
via: 'fm.play(watch url)', why: lastWhy.join(''),
time: new Date().toLocaleTimeString()
};
toast('解析失败:' + e.message + ',退回 App 内置解析');
await fm.play(item.url, item.title || 'YouTube', { pic: item.pic, wallPic: wallOf(item) });
}
} catch (e2) {
toast('起播失败:' + e2.message);
}
playing = false;
}
/* ---------- 浮层 / 路由 ---------- */
var sheets = { settings: $('settings'), net: $('net') };
var openName = '';
function openSheet(name, silent) {
var k;
for (k in sheets) sheets[k].className = 'sheet';
sheets[name].className = 'sheet open';
sheets[name].scrollTop = 0;
bodyClass('locked', true);
bodyClass('searching', false);
openName = name;
if (!silent) history.pushState({ sheet: name }, '', '#' + name);
// 自绘返回按钮时不要让 Native 再叠加恢复入口
if (native && fm.ui && fm.ui.setChrome) {
fm.ui.setChrome({ mode: 'edge', restoreAffordance: 'none', statusBarStyle: 'light' })['catch'](function () {});
}
}
function closeSheet(silent) {
var k;
for (k in sheets) sheets[k].className = 'sheet';
bodyClass('locked', false);
openName = '';
if (!silent && history.state && history.state.sheet) history.back();
}
window.addEventListener('popstate', function () {
var st = history.state;
if (st && st.sheet) { openSheet(st.sheet, true); return; }
if (openName) { closeSheet(true); return; }
// 二级页:返回键逐层弹出
var depth = (st && st.sub) ? st.sub : 0;
while (subStack.length > depth) exitSub(true);
});
/* ---------- 网络自检 ---------- */
function diagLine(name, note) {
var el = document.createElement('div');
el.className = 'diag-line';
el.innerHTML = '<b>' + name + '</b> · <span>检测中…</span><div style="margin-top:4px">' + note + '</div>';
return el;
}
async function probe(el, fn) {
var t0 = Date.now();
try {
await fn();
el.querySelector('span').innerHTML = '<span class="ok">通过 · ' + (Date.now() - t0) + 'ms</span>';
} catch (e) {
el.querySelector('span').innerHTML = '<span class="bad">失败 · ' + String(e.message).slice(0, 60) + '</span>';
}
}
var diagRunning = false;
async function runDiag() {
if (diagRunning) return;
diagRunning = true;
var box = $('diag');
box.replaceChildren();
var a = diagLine('接口 youtube.com', '拉列表、搜索走这里(fm.req');
var b = diagLine('图片 i.ytimg.com', '封面走这里(fm.res → /webResource');
var d = diagLine('画质解析(YouTube', 'HLS 优先;只有分离流时页面自拼 MPD');
var e = diagLine('视频流连通性', '用解析到的真实地址探一次 googlevideo');
box.appendChild(a); box.appendChild(b); box.appendChild(d); box.appendChild(e);
var g = diagLine('拖动能力(Range', '拖动靠 Range 请求;网关或直链哪一层不认,就拖不动');
box.appendChild(g);
await probe(g, async function () {
if (!got) throw new Error('上一步没解析出流,跳过');
var direct = '', label = '';
if (got.kind === 'adaptive') {
var k, ff;
for (k = 0; k < got.formats.length; k++) {
ff = got.formats[k];
if (ff.url && (ff.mimeType || '').indexOf('video') === 0) { direct = ff.url; break; }
}
label = '自拼 MPD 的视频轨';
} else if (got.levels && got.levels.length) {
direct = got.levels[0].url;
label = got.kind === 'hls' ? 'HLS 变体' : '单流 mp4';
}
if (!direct) throw new Error('没有可测的地址');
var gw = streamUrl(direct, got.ua);
var viaGw = gw.indexOf('/webResource') !== -1;
var r1 = null, r2 = null;
try {
r1 = await fm.req(direct, {
method: 'GET',
headers: { 'User-Agent': got.ua, Referer: YT + '/', Range: 'bytes=1000000-1000100' },
timeout: 12
});
} catch (e1) {}
try {
if (viaGw) r2 = await fm.req(gw, { method: 'GET', headers: { Range: 'bytes=1000000-1000100' }, timeout: 12 });
} catch (e2) {}
var s1 = r1 ? r1.status : '无响应';
var s2 = r2 ? r2.status : '无响应';
g.querySelector('div').innerHTML =
'测的是 <b>' + label + '</b><br>' +
'直链 + UA<b>' + s1 + '</b> 网关:<b>' + (viaGw ? s2 : '没拼出网关地址') + '</b><br>' +
'<span style="opacity:.6">206 = 支持 Range(能拖动);403 = 被拒;200 = 不认 Range</span>';
if (viaGw && r2 && r2.status === 206) return; // 网关能拖 → 播放器就该能拖
if (!viaGw && r1 && r1.status === 206) return;
throw new Error('Range 请求没拿到 206');
});
diagRunning = false;
if (lastPlay) {
var f = document.createElement('div');
f.className = 'diag-line';
f.innerHTML = '<b>上次播放</b> · <span class="ok">' + lastPlay.time + '</span>' +
'<div style="margin-top:4px">' +
'引擎 <b>' + lastPlay.engine + '/' + lastPlay.mode + '</b> · 客户端 <b>' + lastPlay.client + '</b><br>' +
'想要 <b>' + lastPlay.want + '</b> → 实际 <b>' + lastPlay.got + '</b><br>' +
'交付方式 <b>' + lastPlay.via + '</b>' + lastPlay.scheme + '<br>' +
'<span style="word-break:break-all;opacity:.7">' + lastPlay.head + '…</span>' +
(lastPlay.why ? '<br><span class="bad">被拒的客户端:' + lastPlay.why + '</span>' : '') + '</div>';
box.appendChild(f);
}
await probe(a, function () {
return innertube('search', { query: 'test' }).then(function (json) {
if (!json) throw new Error('空响应');
});
});
await probe(b, function () {
return new Promise(function (resolve2, reject) {
var img = new Image(), done = false;
var t = setTimeout(function () { if (!done) { done = true; reject(new Error('超时')); } }, 8000);
img.onload = function () { if (!done) { done = true; clearTimeout(t); resolve2(); } };
img.onerror = function () { if (!done) { done = true; clearTimeout(t); reject(new Error('无法加载')); } };
img.src = resUrl('https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg') + '&_=' + Date.now();
});
});
var got = null;
await probe(d, async function () {
got = await resolve('dQw4w9WgXcQ');
if (!got) throw new Error(lastWhy.length ? lastWhy.join('') : '所有客户端都被拒');
var desc = '', hs = '';
if (got.kind === 'hls') {
hs = got.levels.map(function (l) { return l.height ? l.height + 'P' : '自动'; }).join(' / ');
desc = 'HLS 主清单';
if (got.master && prefs.hlsmode === 'pin') {
try { hs += '<br>按当前设置会锁到 <b>' + pinMaster(got.master, prefs.quality).height + 'P</b>'; } catch (e3) {}
}
} else if (got.kind === 'adaptive') {
desc = '分离流 → 页面自拼 MPD';
var b2 = buildMpd(got, prefs.quality);
hs = '按当前设置会拼出 <b>' + b2.size + ' + 音轨</b>' + (b2.proxied ? '(分片走网关)' : '<span class="bad">(分片走直链,拖动会 403</span>');
} else {
desc = '只剩单流(上限 720P';
hs = got.levels.map(function (l) { return l.height + 'P'; }).join(' / ');
}
d.querySelector('div').innerHTML =
'客户端 <b>' + got.client + '</b> · <b>' + desc + '</b><br>' + hs +
(lastWhy.length ? '<br><span class="bad">' + lastWhy.join('') + '</span>' : '');
});
await probe(e, async function () {
if (!got) throw new Error('上一步没解析出地址,跳过');
var testUrl = '';
if (got.kind === 'adaptive') {
var k;
for (k = 0; k < got.formats.length; k++) {
if (got.formats[k].url) { testUrl = got.formats[k].url; break; }
}
} else if (got.levels && got.levels.length) {
testUrl = got.levels[0].url;
}
if (!testUrl) throw new Error('没有可探的地址');
var r = await fm.req(testUrl, {
method: 'GET',
headers: { 'User-Agent': got.ua, Range: 'bytes=0-1' },
timeout: 12
});
if (!r || (!r.ok && r.status !== 206)) throw new Error('HTTP ' + (r ? r.status : 0));
});
}
/* ---------- 设置 ---------- */
async function loadPrefs() {
var raw = await store.get('prefs');
if (raw) {
try {
var p = JSON.parse(raw);
if (p.hl) prefs.hl = p.hl;
if (p.gl) prefs.gl = p.gl;
if (p.ua) prefs.ua = p.ua;
if (p.quality) prefs.quality = p.quality;
if (p.wall) prefs.wall = p.wall;
if (p.engine) prefs.engine = p.engine;
if (p.inv) prefs.inv = p.inv;
if (p.dashmode) prefs.dashmode = p.dashmode;
if (p.hlsmode) prefs.hlsmode = p.hlsmode;
if (p.seg) prefs.seg = p.seg;
if (p.client) prefs.client = p.client;
} catch (e) {}
}
setVal('hl', prefs.hl);
setVal('gl', prefs.gl);
setVal('ua', prefs.ua);
setVal('quality', prefs.quality);
setVal('wall', prefs.wall);
setVal('engine', prefs.engine);
setVal('hlsmode', prefs.hlsmode);
setVal('seg', prefs.seg);
setVal('client', prefs.client);
setVal('dashmode', prefs.dashmode);
setVal('inv', prefs.inv);
}
async function savePrefs() {
prefs.hl = getVal('hl').trim() || 'zh-CN';
prefs.gl = (getVal('gl').trim() || 'US').toUpperCase();
prefs.ua = getVal('ua').trim() || DEF_UA;
prefs.quality = getVal('quality') || 'best';
prefs.wall = getVal('wall') || 'none';
prefs.engine = getVal('engine') || 'dash';
prefs.hlsmode = getVal('hlsmode') || 'variant';
prefs.seg = getVal('seg') || 'gw';
prefs.client = getVal('client') || 'auto';
prefs.dashmode = getVal('dashmode') || 'pin';
prefs.inv = getVal('inv').trim().replace(/\/+$/, '');
await store.set('prefs', JSON.stringify(prefs));
toast('已保存');
closeSheet();
load(true);
}
function copyText(str) {
try {
var ta = document.createElement('textarea');
ta.value = str;
ta.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy'); // WebHome 非安全上下文,clipboard API 多半不可用
document.body.removeChild(ta);
toast('已复制');
} catch (e) { toast('复制失败'); }
}
/* ---------- TV 焦点:简易方向导航 ---------- */
function focusables() {
var all = document.querySelectorAll('button, input, [tabindex]');
var out = [], i, el, r;
for (i = 0; i < all.length; i++) {
el = all[i];
if (el.disabled) continue;
if (openName && !el.closest('.sheet.open')) continue;
if (!openName && el.closest('.sheet')) continue;
r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
out.push({ el: el, r: r });
}
return out;
}
function nearest(dir) {
var act = document.activeElement;
if (!act) return null;
var ar = act.getBoundingClientRect();
var cands = focusables(), best = null, bestScore = Infinity, i, c, dx, dy, ok, score;
for (i = 0; i < cands.length; i++) {
c = cands[i];
if (c.el === act) continue;
dx = (c.r.left + c.r.width / 2) - (ar.left + ar.width / 2);
dy = (c.r.top + c.r.height / 2) - (ar.top + ar.height / 2);
ok = false;
if (dir === 'left') ok = dx < -4 && Math.abs(dy) < Math.abs(dx) * 1.6;
if (dir === 'right') ok = dx > 4 && Math.abs(dy) < Math.abs(dx) * 1.6;
if (dir === 'up') ok = dy < -4;
if (dir === 'down') ok = dy > 4;
if (!ok) continue;
score = (dir === 'up' || dir === 'down') ? Math.abs(dy) + Math.abs(dx) * 1.5 : Math.abs(dx) + Math.abs(dy) * 1.5;
if (score < bestScore) { bestScore = score; best = c.el; }
}
return best;
}
document.addEventListener('keydown', function (e) {
var key = e.key || '';
var code = e.keyCode || 0;
var dir = '';
if (key === 'ArrowLeft' || code === 37) dir = 'left';
if (key === 'ArrowUp' || code === 38) dir = 'up';
if (key === 'ArrowRight' || code === 39) dir = 'right';
if (key === 'ArrowDown' || code === 40) dir = 'down';
if ((key === 'Escape' || code === 27) && openName) { e.preventDefault(); closeSheet(); return; }
if (!dir) return;
if (document.activeElement && document.activeElement.tagName === 'INPUT' && (dir === 'left' || dir === 'right')) return;
var next = nearest(dir);
if (next) {
e.preventDefault();
next.focus();
if (next.scrollIntoView) { try { next.scrollIntoView({ block: 'nearest' }); } catch (err) { next.scrollIntoView(false); } }
}
});
/* ---------- 二级页:频道 / 播放列表 ----------
复用同一个列表容器和 load() 管道,只是换 kind。
进入时把 tabs 换成标题栏,返回走 History,和浮层一个套路。 */
function enterSub(kind, arg, name, desc, playUrl) {
subStack.push({ kind: kind, arg: arg, name: name, desc: desc, playUrl: playUrl || '' });
applySub();
history.pushState({ sub: subStack.length }, '', '#sub' + subStack.length);
view.kind = kind;
view.arg = arg;
view.tab = '';
// 列表加载完就开始后台预热:等你挑好视频点下去,缓存早热了,
// 推送时不用再干等。离开频道会立刻停(warmGen 换代)。
load(true).then(function () { idleWarm(); });
}
function applySub() {
var cur = subStack[subStack.length - 1];
if (!cur) {
$('subbar').className = 'subbar';
elTabs.className = 'tabs';
return;
}
$('subbar').className = 'subbar on';
elTabs.className = 'tabs off';
$('subName').textContent = cur.name || '';
$('subDesc').textContent = cur.desc || '';
$('subPlay').className = 'sub-play';
$('subPlay').textContent = '推送全部';
}
function exitSub(silent) {
warmGen++; // 离开频道,正在跑的预热立刻作废
subStack.pop();
applySub();
var cur = subStack[subStack.length - 1];
if (cur) {
view.kind = cur.kind;
view.arg = cur.arg;
load(true);
} else {
// 回到普通 tab
var t = TABS[0], i;
for (i = 0; i < TABS.length; i++) if (TABS[i].id === lastTab) t = TABS[i];
view.tab = t.id;
view.kind = t.type;
view.arg = t.arg;
renderTabs();
load(true);
}
if (!silent && history.state && history.state.sub) history.back();
}
var lastTab = 'rec'; // 进二级页之前停在哪个 tab
/* ---------- 下拉刷新 + 橡皮筋 ----------
不用 overscroll-behavior:旧内核不认,而且它只能"禁止"回弹,做不出手感。
自己接 touch 事件:位移做阻尼(越拉越沉),松手用 cubic-bezier 弹回。
只在滚到顶部时接管,其余时候完全不干预原生滚动。 */
var PULL_MAX = 110; // 最多能拉多远
var PULL_FIRE = 68; // 拉过这条线松手才刷新
var SWIPE_FIRE = 56; // 横向拉过这条线松手才切类别
var LOCK_SLOP = 6; // 走出这个距离就判方向(太大会让第一次滑动被吞掉)
var pullY = 0, swipeX = 0;
var startX = 0, startY = 0;
var axis = ''; // '' 未定 | 'y' 下拉刷新 | 'x' 切类别 | 'no' 放弃
var touching = false;
var switching = false; // 类别切换动画进行中
var elWrap = $('wrap'), elPullSpin = $('pullSpin');
/* ---- 下拉刷新 ---- */
function setPull(y) {
pullY = y;
elWrap.style.transform = y ? ('translateY(' + y + 'px)') : '';
elPullSpin.style.opacity = String(Math.min(y / PULL_FIRE, 1));
elPullSpin.style.transform = 'translateY(' + (y * 0.6 - 40) + 'px) rotate(' + (y * 3) + 'deg)';
elPullSpin.className = 'pull-spin' + (y >= PULL_FIRE ? ' ready' : '');
}
function endPull() {
elWrap.className = 'wrap snap';
elWrap.style.transform = '';
elPullSpin.className = 'pull-spin';
elPullSpin.style.opacity = '0';
elPullSpin.style.transform = 'translateY(-40px)';
pullY = 0;
setTimeout(function () { elWrap.className = 'wrap'; }, 460);
}
function releasePull(refresh) {
elWrap.className = 'wrap snap';
if (!refresh) { endPull(); return; }
elWrap.style.transform = 'translateY(56px)';
elPullSpin.className = 'pull-spin spin';
elPullSpin.style.opacity = '1';
elPullSpin.style.transform = 'translateY(-6px)';
load(true).then(endPull, endPull);
}
/* ---- 左右切类别 ---- */
function tabIndex() {
var i;
for (i = 0; i < TABS.length; i++) if (TABS[i].id === view.tab) return i;
return -1;
}
function setSwipe(x) {
swipeX = x;
elWrap.style.transform = 'translateX(' + x + 'px)';
elWrap.style.opacity = String(1 - Math.min(Math.abs(x) / 400, 0.35));
}
function clearSwipe() {
elWrap.className = 'wrap snap';
elWrap.style.transform = '';
elWrap.style.opacity = '';
swipeX = 0;
setTimeout(function () { elWrap.className = 'wrap'; }, 440);
}
/* dir: -1 上一个类别(手指右滑),+1 下一个类别(手指左滑) */
var switchTimer = 0, settleTimer = 0;
function gotoTab(dir) {
var idx = tabIndex();
if (idx === -1) idx = 0; // 搜索结果页:从头开始
var next = idx + dir;
if (next < 0 || next >= TABS.length) { clearSwipe(); return; }
// 连滑时把上一段动画的收尾定时器清掉,否则它会把新一段的 class 擦掉
clearTimeout(switchTimer);
clearTimeout(settleTimer);
switching = true;
var t = TABS[next];
view.tab = t.id;
view.kind = t.type;
view.arg = t.arg;
renderTabs();
scrollTabIntoView();
// 旧内容先滑出,新内容从另一侧滑入
elWrap.className = 'wrap swipe';
elWrap.style.transform = 'translateX(' + (dir > 0 ? -60 : 60) + 'px)';
elWrap.style.opacity = '0';
switchTimer = setTimeout(function () {
elWrap.style.transform = '';
elWrap.style.opacity = '';
elWrap.className = 'wrap ' + (dir > 0 ? 'in-right' : 'in-left');
load(true);
settleTimer = setTimeout(function () {
elWrap.className = 'wrap';
switching = false;
}, 360);
}, 140);
}
/* 选中的 tab 滚进可视区,不然滑到后面几个类别时看不见高亮 */
function scrollTabIntoView() {
var on = elTabs.querySelector('.tab.on');
if (!on) return;
var left = on.offsetLeft - (elTabs.clientWidth - on.offsetWidth) / 2;
if (elTabs.scrollTo) elTabs.scrollTo({ left: left, behavior: 'smooth' });
else elTabs.scrollLeft = left;
}
/* ---- 一个 touch 管道,先锁方向再分派 ----
下拉刷新和左右切页共用手指,必须先判定方向再接管,
否则斜着划会两个一起动,手感很脏。 */
function initGestures() {
document.addEventListener('touchstart', function (e) {
if (openName || e.touches.length > 1) { axis = 'no'; return; }
if (hasBodyClass('searching')) { axis = 'no'; return; }
// 这些区域自己要横向滚动,手势层不能抢:
// 抢了就得 preventDefault,原生滚动直接没了,tabs 就滑不动了
var t = e.target;
if (t && t.closest && t.closest('.tabs, .sug, .subbar, .sheet')) { axis = 'no'; return; }
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
axis = '';
touching = true;
elWrap.className = 'wrap'; // 去掉过渡,跟手
}, { passive: true });
document.addEventListener('touchmove', function (e) {
if (!touching || axis === 'no') return;
var dx = e.touches[0].clientX - startX;
var dy = e.touches[0].clientY - startY;
// 方向未定:走够 6px 就锁。
// 之前要求横向优势 1.2 倍且 12px 才锁,手指带点垂直分量就被判成"纵向",
// 整段手势直接作废 —— 表现出来就是"总要滑两次"。
// 现在横向只要略占优(>1.05)就锁 x;判不出来的先不锁,让手指继续走。
if (!axis) {
var adx = Math.abs(dx), ady = Math.abs(dy);
if (adx < LOCK_SLOP && ady < LOCK_SLOP) return;
if (adx > ady * 1.05) {
axis = subStack.length ? 'no' : 'x'; // 二级页没有 tab 可切,横滑不接管
} else if (dy > 0 && window.pageYOffset <= 0 && ady > adx * 1.5) {
axis = 'y'; // 顶部明确下拉才是刷新
} else if (ady > adx * 1.5) {
axis = 'no'; // 明确的纵向滚动,交还原生
return;
} else {
return; // 还看不出来,再等等,别急着作废
}
}
if (axis === 'x') {
// 阻尼:到边界了就拉不动,暗示"没有下一个了"
var idx = tabIndex();
var atEdge = (dx > 0 && idx <= 0) || (dx < 0 && idx >= TABS.length - 1);
setSwipe(atEdge ? dx * 0.28 : dx * 0.85);
if (e.cancelable) e.preventDefault();
return;
}
if (axis === 'y') {
if (dy <= 0 || window.pageYOffset > 0) { if (pullY) setPull(0); axis = 'no'; return; }
// 指数阻尼:越拉越沉,这才是橡皮筋的手感
setPull(PULL_MAX * (1 - Math.exp(-dy / (PULL_MAX * 1.1))));
if (e.cancelable) e.preventDefault();
}
}, { passive: false });
function done() {
if (!touching) return;
touching = false;
if (axis === 'x') {
if (Math.abs(swipeX) >= SWIPE_FIRE) gotoTab(swipeX < 0 ? 1 : -1);
else clearSwipe();
} else if (axis === 'y') {
releasePull(pullY >= PULL_FIRE);
}
axis = '';
}
document.addEventListener('touchend', done);
document.addEventListener('touchcancel', done);
}
/* ---------- tabs ---------- */
function renderTabs() {
elTabs.replaceChildren();
TABS.forEach(function (t) {
var b = document.createElement('button');
b.className = 'tab' + (t.id === view.tab ? ' on' : '');
b.textContent = t.name;
b.addEventListener('click', function () {
view.tab = t.id;
view.kind = t.type;
view.arg = t.arg;
lastTab = t.id;
renderTabs();
scrollTabIntoView();
load(true);
});
elTabs.appendChild(b);
});
}
/* ---------- 搜索历史 ---------- */
var history_ = [];
async function loadHistory() {
try {
var raw = await store.get('history');
history_ = raw ? JSON.parse(raw) : [];
} catch (e) { history_ = []; }
}
async function pushHistory(q) {
q = String(q).trim();
if (!q) return;
history_ = history_.filter(function (x) { return x !== q; });
history_.unshift(q);
if (history_.length > 12) history_ = history_.slice(0, 12);
try { await store.set('history', JSON.stringify(history_)); } catch (e) {}
}
async function delHistory(q) {
history_ = history_.filter(function (x) { return x !== q; });
try { await store.set('history', JSON.stringify(history_)); } catch (e) {}
}
/* ---------- 搜索联想 ----------
用 YouTube 的 suggest 接口。它返回的是 JSONPwindow.google.ac.h([...])),
不是 JSON,所以按文本取回来自己抠数组出来。走 fm.req,照样过代理。 */
var ICON_SEARCH = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" ' +
'stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle>' +
'<path d="M16.5 16.5 L21 21"></path></svg>';
var ICON_HIST = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" ' +
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path><path d="M3 4v4h4"></path>' +
'<path d="M12 8v4l3 2"></path></svg>';
var elSug = $('sug');
var sugTimer = 0, sugSeq = 0, sugItems = [], sugIdx = -1;
async function fetchSuggest(q) {
var url = 'https://suggestqueries-clients6.youtube.com/complete/search' +
'?client=youtube&ds=yt&hl=' + encodeURIComponent(prefs.hl) +
'&gl=' + encodeURIComponent(prefs.gl) +
'&q=' + encodeURIComponent(q);
var txt = await req(url, {
headers: { 'User-Agent': prefs.ua, Referer: YT + '/' },
timeout: 8
});
var str = String(txt);
var a = str.indexOf('('), b = str.lastIndexOf(')');
if (a === -1 || b === -1) return [];
var arr;
try { arr = JSON.parse(str.slice(a + 1, b)); } catch (e) { return []; }
if (!arr || !arr[1]) return [];
var out = [], i;
for (i = 0; i < arr[1].length && out.length < 8; i++) {
if (arr[1][i] && arr[1][i][0]) out.push(String(arr[1][i][0]));
}
return out;
}
function closeSug() {
elSug.className = 'sug';
elSug.replaceChildren();
sugItems = [];
sugIdx = -1;
}
function renderSug(list, isHistory) {
if (!list.length) { closeSug(); return; }
sugItems = list;
sugIdx = -1;
elSug.replaceChildren();
list.forEach(function (w) {
var row = document.createElement('div');
row.className = 'sug-row';
var b = document.createElement('button');
b.type = 'button';
b.className = 'sug-hit';
// 字符 ⌕ / ↺ 几乎没有字体支持,渲染出来又小又歪,一律用 SVG
b.innerHTML = '<span class="k">' + (isHistory ? ICON_HIST : ICON_SEARCH) + '</span>';
b.appendChild(document.createTextNode(w));
b.addEventListener('click', function () {
$('q').value = w;
closeSug();
doSearch();
});
row.appendChild(b);
if (isHistory) {
var x = document.createElement('button');
x.type = 'button';
x.className = 'sug-del';
x.textContent = '×';
x.setAttribute('aria-label', '删除这条历史');
x.addEventListener('click', function (e) {
e.stopPropagation();
delHistory(w).then(function () {
if (history_.length) renderSug(history_, true);
else closeSug();
});
});
row.appendChild(x);
}
elSug.appendChild(row);
});
elSug.className = 'sug open';
}
/* 搜索框聚焦且为空 → 先给历史,而不是一片空白 */
function showHistory() {
if ($('q').value.trim()) return;
if (!history_.length) { closeSug(); return; }
renderSug(history_, true);
}
function onType() {
var q = $('q').value.trim();
clearTimeout(sugTimer);
if (!q) { showHistory(); return; }
sugTimer = setTimeout(async function () {
var seq = ++sugSeq;
try {
var list = await fetchSuggest(q);
if (seq === sugSeq && $('q').value.trim()) renderSug(list, false);
} catch (e) { closeSug(); }
}, 220); // 打字停 220ms 才发请求,别每个字符都打
}
/* 上下键在联想词里走,回车选中当前项 */
function sugKey(e) {
var key = e.key || '', code = e.keyCode || 0;
var isOpen = elSug.className.indexOf('open') !== -1;
if ((key === 'Escape' || code === 27) && isOpen) { closeSug(); return true; }
if (!isOpen || !sugItems.length) return false;
var nodes = elSug.querySelectorAll('.sug-row');
if (key === 'ArrowDown' || code === 40) {
sugIdx = (sugIdx + 1) % sugItems.length;
} else if (key === 'ArrowUp' || code === 38) {
sugIdx = (sugIdx <= 0 ? sugItems.length : sugIdx) - 1;
} else if ((key === 'Enter' || code === 13) && sugIdx >= 0) {
$('q').value = sugItems[sugIdx];
closeSug();
doSearch();
return true;
} else {
return false;
}
var i;
for (i = 0; i < nodes.length; i++) {
nodes[i].className = (i === sugIdx) ? 'on' : '';
}
e.preventDefault();
return true;
}
function doSearch() {
var q = $('q').value.trim();
if (!q) return;
closeSug();
pushHistory(q);
view.tab = '';
view.kind = 'search';
view.arg = q;
renderTabs();
load(true);
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
bodyClass('searching', false);
}
/* ---------- 启动 ---------- */
function bind() {
on('goBtn', 'click', doSearch);
on('q', 'input', onType);
on('q', 'focus', showHistory);
on('q', 'keydown', function (e) {
if (sugKey(e)) return;
if (e.key === 'Enter' || e.keyCode === 13) doSearch();
});
on('q', 'blur', function () { setTimeout(closeSug, 160); }); // 等点击先触发
on('q', 'focus', function () { bodyClass('searching', true); });
/* 点页面别处 → 收起联想、收起键盘、把焦点还回去。
用 touchstart 而不是 clickclick 要等到手指抬起才触发,
中间键盘还杵着,看起来像没反应。touchstart 一碰就收。 */
function dismissSearch(e) {
var t = e.target;
if (t && t.closest && t.closest('.search')) return; // 点在搜索区内不管
var q = $('q');
if (!q) return;
if (document.activeElement !== q && !hasBodyClass('searching')) return;
closeSug();
if (q.blur) q.blur();
bodyClass('searching', false);
}
document.addEventListener('touchstart', dismissSearch, { passive: true });
document.addEventListener('click', dismissSearch);
on('setBtn', 'click', function () { openSheet('settings'); });
on('netBtn', 'click', function () { openSheet('net'); runDiag(); });
on('setBack', 'click', function () { closeSheet(); });
on('netBack', 'click', function () { closeSheet(); });
on('runDiag', 'click', runDiag);
on('saveBtn', 'click', savePrefs);
on('resetBtn', 'click', function () {
setVal('hl', 'zh-CN'); setVal('gl', 'US'); setVal('ua', DEF_UA);
setVal('quality', 'best'); setVal('wall', 'none'); setVal('engine', 'hls'); setVal('hlsmode', 'variant'); setVal('seg', 'gw'); setVal('client', 'auto');
setVal('dashmode', 'pin'); setVal('inv', '');
});
on('subBack', 'click', function () { exitSub(); });
on('subPlay', 'click', function () {
var cur = subStack[subStack.length - 1];
if (!cur) { toast('不在频道/列表页'); return; }
var btn = $('subPlay');
btn.className = 'sub-play busy';
btn.textContent = '推送中…';
pushList(view.items, cur.name, view.items[0] && view.items[0].pic,
view.items[0] ? wallOf(view.items[0]) : '', null, null, true)
['catch'](function (e) { toast('推送失败:' + e.message); })
['then'](function () {
btn.className = 'sub-play';
btn.textContent = '推送全部';
});
});
on('advToggle', 'click', function () {
var t = $('advToggle'), a = $('adv');
var open = a.className.indexOf('open') === -1;
a.className = open ? 'adv open' : 'adv';
t.className = open ? 'adv-toggle open' : 'adv-toggle';
t.setAttribute('aria-expanded', open ? 'true' : 'false');
});
var snip = $('proxySnippet');
if (snip) snip.textContent = PROXY_SNIPPET;
on('copyCfg', 'click', function () { copyText(PROXY_SNIPPET); });
}
async function boot() {
native = !!(window.fm && window.fm.req);
document.documentElement.className += native ? ' fm-native' : '';
if (window.fongmiClient && window.fongmiClient.isLeanback) {
document.documentElement.className += ' fm-tv';
}
try { await loadPrefs(); } catch (e) {}
try { await loadHistory(); } catch (e) {}
try { bind(); } catch (e) {}
try { renderTabs(); } catch (e) {}
try { autoPage(); } catch (e) {}
try { initGestures(); } catch (e) {}
if (native && fm.ui && fm.ui.setChrome) {
try {
fm.ui.setChrome({ mode: 'edge', statusBarStyle: 'light', navigationBarStyle: 'light', startup: true })['catch'](function () {});
} catch (e) {}
}
load(true);
}
// 兜底:脚本内任何未捕获异常都写到页面上,别再白屏
window.addEventListener('error', function (ev) {
if (elState && !elList.firstChild) {
showState('<strong>页面出错了</strong><div>' + String(ev.message || '').replace(/</g, '&lt;') + '</div>');
}
});
/* SDK 在 onPageFinished 后注入,必须等 fmsdk */
if (window.fm && window.fm.req) boot();
else {
var started = false;
window.addEventListener('fmsdk', function () {
if (!started) { started = true; boot(); }
});
// 电脑浏览器预览:等 1.2s 还没有 SDK 就按预览模式启动
setTimeout(function () {
if (!started) { started = true; boot(); }
}, 1200);
}
window.addEventListener('fmresume', function () {
if (view.tab === 'recent') renderRecent();
});
})();
</script></body></html>