Files
embyx/zh/index.html
T
juneix b3459a4d4f v1.1
2026-04-17 12:09:23 +08:00

2847 lines
157 KiB
HTML
Raw 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.
<!--
EmbyX - 短视频风格 Emby 播放器
© 2026 谢週五 (https://juneix.github.io)
著作权所有·设计与开发
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#000000">
<meta name="referrer" content="no-referrer">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="icon.png">
<link rel="apple-touch-icon" href="icon.png">
<title>EmbyX</title>
<script src="https://cdn.tailwindcss.com" data-cfasync="false"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script data-cfasync="false">
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#52B54B', // Emby绿
secondary: '#EE3152' // 抖音红
},
fontFamily: {
sans: ['PingFang SC', 'Helvetica Neue', 'Arial', 'sans-serif']
}
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
/* 视频全屏样式 - 移除硬编码的 object-cover 给 JS 控制 */
.video-fullscreen {
@apply w-full h-full absolute inset-0 z-10;
}
/* 滑动容器 */
/* 优化 2: 一般情况使用 100dvh 避开移动端浏览器地址栏遮挡。如果是 PWA 模式 (standalone) 强制换回 100vh 以消除底部安全区留黑缝隙 */
.slide-container {
@apply relative w-screen overflow-hidden bg-black;
height: 100dvh;
}
@media (display-mode: standalone) {
.slide-container {
height: 100vh;
}
}
/* 单个视频卡片 */
.slide-item {
@apply absolute top-0 left-0 w-full h-full z-10 overflow-hidden;
}
/* 播放/暂停按钮动画 - 修复:提高 z-index 确保在视频之上 */
.play-pause-btn {
@apply absolute top-1/2 left-1/2 w-16 h-16 rounded-full flex items-center justify-center z-20 opacity-0 pointer-events-none transition-all duration-200 ease-out bg-black/30;
transform: translate(-50%, -50%) scale(1.5);
}
.play-pause-btn.paused {
@apply opacity-100;
transform: translate(-50%, -50%) scale(1.5);
}
.play-pause-btn i {
@apply text-2xl text-white;
}
/* 连播模式/清屏模式逻辑 */
.interface-hidden .bottom-info,
.interface-hidden .bottom-nav,
.interface-hidden .right-toolbar,
.interface-hidden #fullscreenBtn,
.interface-hidden #deleteBtn {
@apply opacity-0 pointer-events-none transition-opacity duration-300;
}
/* 格子视图下的特殊“清屏”:保留底部栏,隐藏播放器相关 UI */
.grid-active .bottom-info,
.grid-active .right-toolbar,
.grid-active #fullscreenBtn,
.grid-active #deleteBtn {
@apply opacity-0 pointer-events-none transition-opacity duration-300;
}
/* 遥控器与键盘焦点高亮 */
.focusable-item:focus {
@apply outline-none ring-2 ring-primary ring-offset-2 ring-offset-black z-20;
}
/* “我的”页面层级过渡 */
.profile-layer {
@apply fixed top-0 left-0 w-full h-full z-[60] translate-y-full transition-transform duration-300 ease-in-out;
}
.profile-layer.active {
@apply translate-y-0;
}
/* 进度条 */
.progress-line {
@apply h-full bg-white transition-[width] duration-100 ease-linear;
}
/* MD3 & Liquid Glass: 底部导航反馈 */
.nav-btn {
@apply relative flex flex-col items-center justify-center w-1/4 h-full cursor-pointer transition-colors duration-300 z-10;
-webkit-tap-highlight-color: transparent;
}
.nav-btn::before {
content: '';
@apply absolute top-1/2 left-1/2 w-[3.5rem] h-8 rounded-full bg-primary/30 opacity-0 pointer-events-none transition-all duration-500 ease-out z-[-1];
transform: translate(-50%, -50%) scale(0.5);
backdrop-filter: blur(8px);
}
.nav-btn.nav-clicked::before {
@apply opacity-100;
transform: translate(-50%, -50%) scale(1);
}
.nav-btn.nav-clicked {
color: theme('colors.primary') !important;
}
.nav-btn.nav-clicked svg {
filter: drop-shadow(0 0 6px rgba(82, 181, 75, 0.6));
}
}
</style>
</head>
<body class="bg-black text-white overflow-hidden font-sans select-none overscroll-none">
<div id="app" class="slide-container">
<div id="videoContainer" class="relative w-full h-full">
</div>
<div id="clickToPlayOverlay"
class="absolute inset-0 z-50 flex items-center justify-center bg-black/40 cursor-pointer"
style="display:none;">
<div class="text-center">
<div class="w-20 h-20 bg-black/50 rounded-full flex items-center justify-center animate-pulse mx-auto">
<i data-lucide="play" class="text-white text-3xl"></i>
</div>
<p class="text-white text-lg mt-4 font-medium">点击开始播放</p>
</div>
</div>
<div
class="right-toolbar absolute right-4 bottom-40 flex flex-col items-center space-y-6 z-30 transition-opacity duration-300">
<div class="flex flex-col items-center cursor-pointer active:scale-90 transition-transform"
id="favoriteBtn">
<div class="w-12 h-12 flex items-center justify-center">
<i data-lucide="heart" class="stroke-white w-6 h-6 drop-shadow-md"></i>
</div>
</div>
<div class="flex flex-col items-center cursor-pointer active:scale-90 transition-transform" id="scaleBtn">
<div class="w-12 h-12 flex items-center justify-center">
<i data-lucide="maximize-2" class="stroke-white w-6 h-6 drop-shadow-md"></i>
</div>
</div>
<div class="flex flex-col items-center cursor-pointer active:scale-90 transition-transform" id="muteBtn">
<div class="w-12 h-12 flex items-center justify-center">
<i data-lucide="volume-2" class="stroke-white w-6 h-6 drop-shadow-md"></i>
</div>
</div>
</div>
<!-- 左上角管理删除按钮,微调顶部距离,例如 1rem (16px) 或 10px -->
<div class="absolute top-[calc(10px+env(safe-area-inset-top))] left-4 z-30 transition-opacity duration-300"
id="deleteBtn">
<div
class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full">
<i data-lucide="trash-2" class="stroke-white w-5 h-5 drop-shadow-md"></i>
</div>
</div>
<!-- 右上角全屏按钮,微调顶部距离,例如 1rem (16px) 或 10px -->
<div class="absolute top-[calc(10px+env(safe-area-inset-top))] right-4 z-30 transition-opacity duration-300"
id="fullscreenBtn">
<div
class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full">
<i data-lucide="maximize" class="stroke-white w-5 h-5 drop-shadow-md"></i>
</div>
</div>
<div id="mediaInfoModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/20 hidden backdrop-blur-sm transition-opacity duration-300">
<div
class="bg-black/50 rounded-2xl p-6 w-[85%] max-w-sm max-h-[80vh] overflow-y-auto border border-gray-700/50 shadow-2xl backdrop-blur-sm">
<h3 class="text-white text-lg font-bold mb-4 text-center">
流媒体信息
</h3>
<div id="mediaInfoContent" class="text-sm text-gray-300">
<div class="flex justify-center py-4"><i data-lucide="loader-2"
class="w-6 h-6 animate-spin text-gray-500"></i></div>
</div>
<button id="closeMediaInfoBtn"
class="w-full mt-6 bg-gray-800/80 border border-gray-700/50 text-gray-200 py-2.5 rounded-lg font-medium hover:bg-gray-700 active:scale-95 transition-all">关闭</button>
</div>
</div>
<div id="deleteConfirmModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 hidden backdrop-blur-sm transition-opacity duration-300">
<div
class="bg-black/50 rounded-2xl p-6 w-[85%] max-w-sm border border-gray-700/50 shadow-2xl backdrop-blur-sm text-center">
<div
class="w-14 h-14 bg-secondary/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-secondary/20">
<i data-lucide="trash-2" class="text-secondary w-7 h-7"></i>
</div>
<h3 class="text-white text-lg font-bold mb-2">删除媒体</h3>
<p class="text-secondary text-xs font-semibold px-4 mb-4 leading-relaxed">⚠️ 警告:将从 Emby 永久删除源文件!</p>
<div class="bg-gray-800/40 rounded-lg p-3 mb-6 border border-gray-700/30">
<p id="deleteFileName" class="text-gray-300 text-xs break-all opacity-80"></p>
</div>
<div class="flex space-x-3">
<button id="cancelDeleteBtn"
class="flex-1 bg-gray-800/80 border border-gray-700/50 text-gray-300 py-2.5 rounded-lg font-medium active:scale-95 transition-all">😍女菩萨留步</button>
<button id="confirmDeleteBtn"
class="flex-1 bg-secondary text-white py-2.5 rounded-lg font-bold active:opacity-90 shadow-lg shadow-secondary/20 transition-all">🚬妖孽走开</button>
</div>
</div>
</div>
<div
class="bottom-info absolute bottom-20 left-4 right-4 z-20 transition-opacity duration-300 pointer-events-none">
<div id="videoInfoArea"
class="mb-3 pointer-events-auto w-[75%] pr-4 cursor-pointer active:opacity-75 transition-opacity">
<h3 id="videoTitle" class="text-white text-lg font-medium mb-1 drop-shadow-md truncate">正在初始化...</h3>
<p id="videoDescription" class="text-gray-200 text-sm line-clamp-2 drop-shadow-md opacity-90">
请点击右下角“我的”进行服务器配置</p>
</div>
<div class="relative h-1 bg-white/30 cursor-pointer pointer-events-auto mb-1" id="progressBarContainer">
<div id="progressLine" class="progress-line w-0"></div>
</div>
<div class="flex justify-between text-xs text-gray-300 drop-shadow">
<span id="currentTime">00:00</span>
<span id="totalTime">00:00</span>
</div>
</div>
<div
class="bottom-nav absolute bottom-0 left-0 right-0 bg-black/50 backdrop-blur-sm z-30 transition-opacity duration-300 pb-2 pt-1">
<div class="flex justify-around items-center h-12">
<div id="viewModeBtn" class="nav-btn text-gray-400">
<i data-lucide="layout-grid" class="stroke-current w-5 h-5"></i>
</div>
<div id="playModeBtn" class="nav-btn text-gray-400">
<i data-lucide="repeat" class="stroke-current w-5 h-5"></i>
</div>
<div id="libraryBtn" class="nav-btn text-gray-400">
<i data-lucide="folder-open" class="stroke-current w-5 h-5"></i>
</div>
<div id="myBtn" class="nav-btn text-gray-400">
<i data-lucide="user-round" class="stroke-current w-5 h-5"></i>
</div>
</div>
</div>
<div id="profilePage" class="profile-layer text-white">
<!-- 【透明度终极修正方案】 -->
<!-- 1. 下方专属背景层,仅在标题栏下方生效,彻底避开头部的双重背景叠加 -->
<div
class="absolute top-[calc(3.5rem+env(safe-area-inset-top))] bottom-0 left-0 w-full bg-black/50 backdrop-blur-sm pointer-events-none -z-10">
</div>
<!-- 2. 完全独立的悬浮标题栏。文字滑过此处下方时就会有毛玻璃透射感 -->
<div
class="absolute top-0 left-0 w-full h-[calc(3.5rem+env(safe-area-inset-top))] border-b border-gray-700/50 bg-black/50 backdrop-blur-sm z-10 pt-[env(safe-area-inset-top)]">
<div class="flex items-center justify-center h-14 px-4 relative">
<span class="font-bold text-lg">个人中心</span>
<!-- 右上角向下箭头,已将原来的 right-4 改为了 right-5 稍微左移。如感到偏右可改 right-5.5 / right-6 寻找舒适点 -->
<button id="closeProfileBtn"
class="absolute text-gray-400 right-5 w-10 h-10 flex items-center justify-center active:text-gray-300 focusable-item rounded-full">
<i data-lucide="chevron-down" class="w-6 h-6"></i>
</button>
</div>
</div>
<!-- 3. 无背景的全局内容滚动层。因为无背景,所以滑到上半部分遮挡时,就会给标题栏贡献底部的文字重叠区 -->
<div class="absolute inset-0 overflow-y-auto overflow-x-hidden z-0">
<div class="pt-[calc(3.5rem+env(safe-area-inset-top))] p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
<!-- mt-5 让下方的服务器配置模块整体远离顶部标题栏,留出更多呼吸感 -->
<div class="mb-5 mt-5">
<h2 class="text-primary font-bold text-sm mb-5 flex items-center">
<i data-lucide="server" class="w-4 h-4 mr-2"></i>服务器配置
</h2>
<div class="space-y-4 bg-gray-800/40 p-4 rounded-lg border border-gray-700/50">
<div>
<label class="text-gray-400 text-xs block mb-1.5">服务器地址</label>
<input type="text" id="configServer" placeholder="HTTP 访问请反代或自建 EmbyX"
class="w-full bg-gray-900 text-white text-sm rounded px-3 py-2.5 outline-none border border-gray-700 focus:border-primary transition-colors focusable-item">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-gray-400 text-xs block mb-1.5">用户名</label>
<input type="text" id="configUser" placeholder="Username"
class="w-full bg-gray-900 text-white text-sm rounded px-3 py-2.5 outline-none border border-gray-700 focus:border-primary transition-colors focusable-item">
</div>
<div>
<label class="text-gray-400 text-xs block mb-1.5">密码</label>
<input type="password" id="configPwd" placeholder="Password"
class="w-full bg-gray-900 text-white text-sm rounded px-3 py-2.5 outline-none border border-gray-700 focus:border-primary transition-colors focusable-item">
</div>
</div>
<div id="rowStatic" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configStatic').click();">
<label for="configStatic"
class="text-gray-300 text-sm cursor-pointer flex-1">直接播放(不转码)</label>
<input type="checkbox" id="configStatic"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none"
checked>
</div>
<div id="rowAutoplay" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configAutoplay').click();">
<label for="configAutoplay"
class="text-gray-300 text-sm cursor-pointer flex-1">自动连播</label>
<input type="checkbox" id="configAutoplay"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none"
checked>
</div>
<div id="rowDeleteMode" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configDeleteMode').click();">
<label for="configDeleteMode"
class="text-yellow-500/90 text-sm cursor-pointer flex-1 font-medium">允许删除媒体
⚠️</label>
<input type="checkbox" id="configDeleteMode"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none">
</div>
<div class="flex space-x-3 pt-2">
<button id="resetConfigBtn"
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white text-sm py-3 rounded-lg active:scale-95 transition-transform focusable-item">重置</button>
<button id="saveConfigBtn"
class="flex-1 bg-primary hover:bg-primary/90 text-white text-sm py-3 rounded-lg font-bold active:opacity-80 shadow-lg shadow-primary/20 focusable-item">保存</button>
</div>
</div>
</div>
<!-- 分割线:
如果上下距离视觉不一致,可以分别修改下面的 mt-5 (距上缘) 和 mb-5 (距下缘)。
常用的数值比例: 4(16px), 6(24px), 8(32px), 10(40px)
-->
<div class="border-t border-dashed border-gray-600 w-full mt-5 mb-5">
</div>
<div>
<h2 class="text-primary font-bold text-sm mb-5 flex items-center">
<i data-lucide="badge-info" class="w-4 h-4 mr-2"></i>关于项目
</h2>
<div
class="bg-gray-800/40 p-5 rounded-xl border border-gray-700/50 space-y-5 text-sm text-gray-300 leading-relaxed">
<div class="flex items-center">
<h3 class="text-white font-bold text-2xl tracking-tight">📱 EmbyX</h3>
<span
class="ml-2 px-2 py-0.5 bg-primary/20 text-primary text-xs font-bold rounded-full border border-primary/30">v1.1</span>
</div>
<div>
<p class="text-gray-300">👋 Hi~我叫<a href="https://juneix.github.io" target="_blank"
class="text-primary hover:underline">@谢週五</a>EmbyX 是一款专为 Emby / Jellyfin 打造的
Web 原生应用,完美复刻抖音·短视频沉浸式交互,让你的私人媒体库焕发全新的刷片体验。🎉</p>
</div>
<div
class="text-yellow-500/90 text-xs bg-yellow-500/10 p-3 rounded-lg border border-yellow-500/20">
<i data-lucide="shield-check" class="w-3 h-3 mr-1 inline"></i>
数据全程保存在本地,完全免费。
</div>
<div>
<h4 class="text-gray-300">🔮 播放性能</h4>
<ul class="list-disc list-inside space-y-1 text-gray-400 text-xs mt-2">
<li>原生 HTML5 播放器,新设备体验最佳</li>
<li>老设备需服务器转码,<del>不如放到**上回收(没打钱</del> 😂</li>
</ul>
<div class="bg-black/40 rounded-lg overflow-hidden border border-gray-700/50 mt-3">
<table class="w-full text-center text-[11px] leading-tight border-collapse">
<thead class="bg-gray-700/30 text-gray-400">
<tr>
<th class="px-2 py-1.5 border-r border-gray-700/50 font-medium">设备</th>
<th class="px-2 py-1.5 border-r border-gray-700/50 font-medium">HEVC 硬解
</th>
<th class="px-2 py-1.5 font-medium">AV1 硬解</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700/50 text-gray-400">
<tr>
<td class="px-2 py-1.5 border-r border-gray-700/50 text-primary">
苹果</td>
<td class="px-2 py-1.5 border-r border-gray-700/50">A9 (2015) / M1</td>
<td class="px-2 py-1.5">A17 Pro (2023) / M3</td>
</tr>
<tr>
<td class="px-2 py-1.5 border-r border-gray-700/50 text-primary">
安卓</td>
<td class="px-2 py-1.5 border-r border-gray-700/50">千元机 (2016)</td>
<td class="px-2 py-1.5">千元机 (2024)</td>
</tr>
<tr>
<td class="px-2 py-1.5 border-r border-gray-700/50 text-primary">
PC</td>
<td class="px-2 py-1.5 border-r border-gray-700/50">68 代酷睿·核显</td>
<td class="px-2 py-1.5">11 代酷睿·核显</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h4 class="text-gray-300">🧩 使用技巧</h4>
<ul class="list-disc list-inside space-y-1 text-gray-400 text-xs mt-2">
<li>入库更快:媒体库类型选「家庭视频」</li>
<li>加载更顺:每个媒体库 < 1000 个视频</li>
<li>管理更爽:使用多个媒体库和播放列表</li>
<li>PWA 应用:添加到主屏幕/作为应用安装 📲</li>
</ul>
</div>
<div>
<h4 class="text-gray-300">⌨️ 快捷键指南</h4>
<div class="bg-black/40 rounded-lg overflow-hidden border border-gray-700/50 mt-2">
<table class="w-full text-center text-[11px] leading-tight">
<thead class="bg-gray-700/30 text-gray-400">
<tr>
<th class="px-3 py-1.5 font-medium border-r border-gray-700/50">按键</th>
<th class="px-3 py-1.5 font-medium">功能</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700/50 text-gray-400">
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">W / S /
↑ / ↓</td>
<td class="px-3 py-1.5">上一个 / 下一个视频</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">A / D /
← / →</td>
<td class="px-3 py-1.5">快退 / 快进 15 秒</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">Space /
单击 OK</td>
<td class="px-3 py-1.5">暂停 / 播放</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">U / 双击
OK</td>
<td class="px-3 py-1.5">收藏视频</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">J / 菜单键
</td>
<td class="px-2 py-1.5">比例切换</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">M</td>
<td class="px-3 py-1.5">音量开关</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">I</td>
<td class="px-3 py-1.5">个人中心</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">E</td>
<td class="px-3 py-1.5">视图切换</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">R</td>
<td class="px-3 py-1.5">顺序 / 随机</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">F</td>
<td class="px-3 py-1.5">全屏切换</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">G</td>
<td class="px-3 py-1.5">选择媒体源</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">V</td>
<td class="px-3 py-1.5">流媒体详情</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h4 class="text-gray-300">🚀 交流与支持</h4>
<p class="text-gray-400 text-xs mt-2">💬 欢迎转发分享本项目,关注社媒动态、加群催更互动!</p>
<!-- 胶囊按钮组:社区 & 开源 -->
<div class="grid grid-cols-2 gap-2 mt-3">
<a href="https://space.bilibili.com/765334/" target="_blank"
class="flex items-center justify-center text-[#FB7299] bg-[#FB7299]/10 hover:bg-[#FB7299]/20 px-3 py-1.5 rounded-full transition-colors border border-[#FB7299]/20 text-[10px] whitespace-nowrap active:scale-95">
<img src="https://cdn.simpleicons.org/bilibili/FB7299"
class="w-3.5 h-3.5 mr-1.5" alt="Bilibili" /> B 站·教程</a>
<a href="https://xhslink.com/m/XSWtx7YRfI" target="_blank"
class="flex items-center justify-center text-[#FF2442] bg-[#FF2442]/10 hover:bg-[#FF2442]/20 px-3 py-1.5 rounded-full transition-colors border border-[#FF2442]/20 text-[10px] whitespace-nowrap active:scale-95">
<img src="https://cdn.simpleicons.org/xiaohongshu/FF2442"
class="w-3.5 h-3.5 mr-1.5" alt="Xiaohongshu" /> 小红书·攻略</a>
<a href="https://qm.qq.com/q/ZzOD5Qbhce" target="_blank"
class="flex items-center justify-center text-[#12B7F5] bg-[#12B7F5]/10 hover:bg-[#12B7F5]/20 px-3 py-1.5 rounded-full transition-colors border border-[#12B7F5]/20 text-[10px] whitespace-nowrap active:scale-95">
<img src="https://cdn.simpleicons.org/qq/12B7F5" class="w-3.5 h-3.5 mr-1.5"
alt="QQ" /> QQ 群·催更</a>
<a href="https://github.com/juneix/embyx" target="_blank"
class="flex items-center justify-center text-white bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-full transition-colors border border-white/20 text-[10px] whitespace-nowrap active:scale-95">
<img src="https://cdn.simpleicons.org/github/white" class="w-3.5 h-3.5 mr-1.5"
alt="GitHub" /> GitHub · 开源</a>
</div>
<!-- 打赏区 -->
<p class="text-gray-400 text-xs mt-4 mb-2">☕ 打赏鼓励,支持我开发更多有趣应用~</p>
<div class="flex justify-center space-x-3">
<a href="weixin://profile/gh_86f7c0a73738">
<div class="flex flex-col items-center">
<img src="wechat.webp" alt="微信" class="w-24 h-24 object-cover rounded">
<span class="text-[10px] text-gray-400 mt-1">微信</span>
</div>
</a>
<a
href="alipays://platformapi/startapp?saId=10000007&clientVersion=3.7.0.0718&qrcode=https%3A%2F%2Fqr.alipay.com%2Ffkx15248wilbz2ddz96og40">
<div class="flex flex-col items-center">
<img src="alipay.webp" alt="支付宝" class="w-24 h-24 object-cover rounded">
<span class="text-[10px] text-gray-400 mt-1">支付宝</span>
</div>
</a>
</div>
</div>
<div
class="pt-4 border-t border-gray-700/50 text-xs text-gray-500 text-center flex flex-col space-y-1">
<p>Made with ❤️ by <a href="https://juneix.github.io" target="_blank"
class="text-primary hover:underline">谢週五</a></p>
<p>© 2026 保留所有权利</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="libraryModal"
class="fixed inset-0 bg-black/20 z-50 flex items-center justify-center hidden backdrop-blur-sm transition-opacity duration-300">
<div
class="bg-black/50 rounded-xl p-5 w-[90%] max-w-lg max-h-[70vh] overflow-y-auto border border-gray-700/50 shadow-2xl backdrop-blur-sm">
<h3 class="text-white text-lg font-bold mb-4 text-center">选择媒体源</h3>
<div id="librariesList" class="grid grid-cols-2 gap-2"></div>
<button id="closeLibraryBtn"
class="w-full mt-4 bg-white/10 border border-white/10 text-gray-300 py-2.5 rounded text-sm hover:bg-white/20 font-medium active:scale-95 transition-all focusable-item">关闭</button>
</div>
</div>
<div id="toast"
class="fixed top-16 left-1/2 -translate-x-1/2 bg-black/50 px-4 py-2 rounded-2xl text-white text-sm pointer-events-none transition-opacity duration-300 opacity-0 z-[100] w-max max-w-[80vw] whitespace-normal text-center border border-white/10 backdrop-blur-sm">
提示信息
</div>
<div id="loading"
class="fixed inset-0 z-40 flex items-center justify-center bg-transparent pointer-events-none hidden">
<div class="w-10 h-10 border-4 border-gray-600 border-t-primary rounded-full animate-spin"></div>
</div>
</div>
<script>
/**
* EmbyX Core Logic
* 包含配置管理、播放器核心、UI 交互、手势控制
*/
class EmbyApp {
constructor() {
// 应用状态
this.state = {
videos: [],
currentIndex: 0,
isPlaying: false,
favorites: new Set(),
playMode: 'sequence',
viewMode: 'stream',
isAutoplay: true,
currentTab: 'home',
currentLibraryId: null,
isScaleFill: true,
isMuted: false,
lastReportTime: 0,
playSessionId: null,
originalVideos: [] // 随机模式下保存原始顺序列表
};
this.config = { server: '', user: '', token: '', userId: '', useStatic: true, autoplay: true, deleteMode: false };
// 计时器引用
this.csTimer = null;
this.dom = {}; // DOM 元素缓存
this.init();
}
// 检查当前是否有任何弹窗/面板处于打开状态
// 这种状态下不应触发自动清屏
isAnyModalOpen() {
const modals = ['mediaInfoModal', 'deleteConfirmModal', 'profilePage', 'libraryModal'];
return modals.some(id => {
const el = document.getElementById(id);
if (!el) return false;
// profilePage 使用 active 类名,其他使用 hidden 类名
if (id === 'profilePage') return el.classList.contains('active');
return !el.classList.contains('hidden');
});
}
// --- 初始化 ---
init() {
this.cacheDOM();
this.loadConfig();
this.loadFavorites();
this.bindEvents();
// 根据配置设置自动连播状态(只影响视频自动播放,不隐藏界面)
this.state.isAutoplay = this.config.autoplay !== false;
// 初始化视图模式按钮图标(默认竖版,显示gallery-vertical
const viewBtn = this.dom.viewModeBtn;
viewBtn.innerHTML = `<i data-lucide="gallery-vertical" class="stroke-current w-5 h-5"></i>`;
// 初始化播放模式按钮图标(根据自动连播配置)
const playBtn = this.dom.playModeBtn;
playBtn.innerHTML = `<i data-lucide="${this.state.isAutoplay ? 'repeat' : 'repeat-1'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
// 检查配置并启动
if (this.config.server && this.config.token) {
this.fetchVideos();
} else {
setTimeout(() => this.toggleModal('profilePage', true), 500);
}
}
cacheDOM() {
const ids = [
'videoContainer', 'clickToPlayOverlay', 'loading', 'toast',
'videoInfoArea', 'videoTitle', 'videoDescription', 'progressLine', 'currentTime', 'totalTime',
'playModeBtn', 'viewModeBtn', 'libraryBtn', 'myBtn',
'favoriteBtn', 'scaleBtn', 'muteBtn', 'fullscreenBtn', 'deleteBtn',
'mediaInfoModal', 'mediaInfoContent', 'closeMediaInfoBtn',
'deleteConfirmModal', 'confirmDeleteBtn', 'cancelDeleteBtn', 'deleteFileName',
'configServer', 'configUser', 'configPwd', 'configStatic', 'configAutoplay', 'configDeleteMode'
];
ids.forEach(id => this.dom[id] = document.getElementById(id));
this.dom.rightToolbar = document.querySelector('.right-toolbar');
}
// --- 配置与数据管理 ---
loadConfig() {
this.config.server = localStorage.getItem('emby_server') || '';
this.config.user = localStorage.getItem('emby_user') || '';
this.config.token = localStorage.getItem('emby_token') || '';
this.config.userId = localStorage.getItem('emby_uid') || '';
this.config.useStatic = localStorage.getItem('emby_static') !== 'false';
this.config.autoplay = localStorage.getItem('emby_autoplay') !== 'false';
this.config.deleteMode = localStorage.getItem('emby_delete_mode') === 'true';
// 填充 UI
if (this.dom.configServer) this.dom.configServer.value = this.config.server;
if (this.dom.configUser) this.dom.configUser.value = this.config.user;
if (this.dom.configPwd && this.config.token) {
// 显示与实际密码位数一致的掩码(密码长度存入 localStorage,不存密码本身)
const pwdLen = parseInt(localStorage.getItem('emby_pwd_len') || '0');
this.dom.configPwd.value = '*'.repeat(Math.max(1, pwdLen));
}
if (this.dom.configStatic) this.dom.configStatic.checked = this.config.useStatic;
if (this.dom.configAutoplay) this.dom.configAutoplay.checked = this.config.autoplay;
if (this.dom.configDeleteMode) this.dom.configDeleteMode.checked = this.config.deleteMode;
// Sync UI based on config
this.dom.deleteBtn.style.display = this.config.deleteMode ? 'flex' : 'none';
if (this.dom.playModeBtn) {
this.dom.playModeBtn.innerHTML = `<i data-lucide="${this.config.autoplay ? 'repeat' : 'repeat-1'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
}
}
// 核心修改点:AuthenticateByName 登录逻辑
async saveConfig() {
const server = this.dom.configServer.value.trim().replace(/\/$/, "");
const user = this.dom.configUser.value.trim();
const pwd = this.dom.configPwd.value.trim();
const isStatic = this.dom.configStatic.checked;
const autoplay = this.dom.configAutoplay.checked;
const deleteMode = this.dom.configDeleteMode.checked;
if (!server || !user) return this.showToast('请填写完整信息');
this.toggleLoading(true);
try {
// 如果是用旧 token 更新设置,跳过登录
if (pwd === '****' && this.config.token && this.config.server === server && this.config.user === user) {
localStorage.setItem('emby_static', isStatic);
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_delete_mode', deleteMode);
this.config.useStatic = isStatic;
this.config.autoplay = autoplay;
this.config.deleteMode = deleteMode;
this.state.isAutoplay = autoplay;
this.dom.deleteBtn.style.display = deleteMode ? 'flex' : 'none';
if (this.dom.playModeBtn) {
this.dom.playModeBtn.innerHTML = `<i data-lucide="${autoplay ? 'repeat' : 'repeat-1'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
}
this.showToast('✅ 配置已保存');
this.toggleModal('profilePage', false);
return; // 提前退出,不重新拉取视频
}
const res = await fetch(`${server}/emby/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
},
body: JSON.stringify({ Username: user, Pw: pwd })
});
// 先按状态码分流,再尝试解析 JSON(避免非 JSON 响应体导致 json() 报错)
if (res.status === 401) {
this.showToast('❌认证失败:用户名或密码错误');
return;
} else if (res.status === 404) {
this.showToast('❌连接失败:请检查端口号');
return;
} else if (!res.ok) {
this.showToast('❌连接失败 ' + res.status + ',请检查 Emby 日志');
return;
}
const data = await res.json();
if (data.AccessToken) {
// 登录成功...
localStorage.setItem('emby_server', server);
localStorage.setItem('emby_user', user);
localStorage.setItem('emby_token', data.AccessToken);
localStorage.setItem('emby_uid', data.SessionInfo.UserId);
localStorage.setItem('emby_static', isStatic);
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_delete_mode', deleteMode);
this.config = { server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, deleteMode };
// 将密码位数存入 localStorage,不存密码本身;空密码至少显示 1 个 *
const pwdLen = Math.max(1, pwd.length);
localStorage.setItem('emby_pwd_len', pwdLen);
this.dom.configPwd.value = '*'.repeat(pwdLen);
this.dom.deleteBtn.style.display = deleteMode ? 'flex' : 'none';
if (this.dom.playModeBtn) {
this.dom.playModeBtn.innerHTML = `<i data-lucide="${autoplay ? 'repeat' : 'repeat-1'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
}
this.showToast('✅ 配置已保存');
this.toggleModal('profilePage', false);
this.fetchVideos();
} else {
this.showToast('❌ 认证失败:用户名或密码错误');
}
} catch (e) {
console.error('saveConfig error:', e);
// 分辨常见错误类型
const msg = e?.message || '';
const isHttps = location.protocol === 'https:';
const targetHttp = server.startsWith('http:');
if (isHttps && targetHttp) {
// HTTPS 页面请求 HTTP Emby 会被浏览器拦截(Mixed Content
this.showToast('❌ 连接失败:请求被拦截 (HTTPS -> HTTP)');
} else if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ERR_CONNECTION')) {
this.showToast('❌ 无法连接服务器');
} else {
this.showToast('❌ 连接失败:' + (msg || '未知错误'));
}
} finally {
this.toggleLoading(false);
}
}
resetConfig() {
if (confirm('确定要重置所有配置吗?')) {
localStorage.clear();
location.reload();
}
}
async loadFavorites() {
const { server, token, userId } = this.config;
if (!server || !token || !userId) {
const saved = localStorage.getItem('emby_favorites');
if (saved) this.state.favorites = new Set(JSON.parse(saved));
return;
}
try {
const res = await fetch(`${server}/emby/Users/${userId}/Items?api_key=${token}&Recursive=true&Filters=IsFavorite&Fields=Id`);
if (res.ok) {
const data = await res.json();
if (data && data.Items) {
this.state.favorites = new Set(data.Items.map(i => String(i.Id)));
localStorage.setItem('emby_favorites', JSON.stringify([...this.state.favorites]));
this.updateUI();
}
}
} catch (e) {
console.error('加载远端收藏夹失败:', e);
const saved = localStorage.getItem('emby_favorites');
if (saved) this.state.favorites = new Set(JSON.parse(saved));
}
}
// --- 核心播放逻辑 ---
async fetchVideos(parentId = null, ids = null, isLoadMore = false, isRandom = false) {
this.toggleLoading(true);
try {
const { server, token, userId } = this.config;
if (!server || !token) {
this.showToast('请先配置服务器');
return;
}
if (!isLoadMore) {
this.state.startIndex = 0;
}
const libraryId = parentId || this.state.currentLibraryId;
// --- 分页参数 ---
// PAGE_SIZE: 每页视频数,建议 99(3 列整行)每次请求量
// MAX_GRID_VIDEOS: 格子视图展示上限,超出部分通过翻页访问
const PAGE_SIZE = 99; // 可调整,建议保持 3 的倍数
// const MAX_GRID_VIDEOS = 1000; // 硬性上限,目前由 API TotalRecordCount 控制,留作后期限速使用
let url = `${server}/emby/Users/${userId}/Items?api_key=${token}&Recursive=true&IncludeItemTypes=Movie,Episode,Video&Limit=${PAGE_SIZE}&StartIndex=${this.state.startIndex || 0}&Fields=Overview,Path,RunTimeTicks,MediaSources`;
if (ids) {
url += `&Ids=${ids}`;
} else if (isRandom) {
// 换一批模式:服务端随机取 99 个(适用于所有模式)
url += `&SortBy=Random`;
if (libraryId && libraryId !== 'favorites') url += `&ParentId=${libraryId}`;
if (libraryId === 'favorites') url += `&Filters=IsFavorite`;
} else if (libraryId === 'favorites') {
url += `&SortBy=DateCreated&SortOrder=Descending&Filters=IsFavorite`;
} else if (libraryId) {
// 指定媒体库:按添加时间倒序 + 分页
url += `&SortBy=DateCreated&SortOrder=Descending&ParentId=${libraryId}`;
} else {
// 全部视频(未选媒体库):默认按无添加时间倒序,点击换一批才随机
url += `&SortBy=DateCreated&SortOrder=Descending`;
}
const res = await fetch(url, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
}
});
if (!res.ok) {
throw new Error(`服务器错误: ${res.status}`);
}
let data = await res.json();
if (data && data.Items && data.Items.length > 0) {
this.state.videos = data.Items;
this.state.currentIndex = 0;
// 保存媒体库真实总数(API 返回,不受 Limit 影响)供分页显示
this.state.totalCount = data.TotalRecordCount || 0;
// 随机模式下加载完,直接原地洗牌(保持第一个视频在首位)
if (this.state.playMode === 'random') {
this.state.originalVideos = [...this.state.videos];
this._shuffleAroundCurrent();
}
if (this.state.viewMode === 'grid') {
this.renderGridView();
} else {
this.renderSlides();
this.loadVideo(0);
}
} else if (isLoadMore) {
this.showToast('没有更多视频了');
// 已到最后一页,不循环
this.state.startIndex = Math.max(0, (this.state.startIndex || 0));
this.toggleLoading(false);
return;
} else {
this.showToast('没有找到视频');
this.dom.videoContainer.innerHTML = '';
}
} catch (e) {
console.error('获取视频失败:', e);
this.showToast('无法连接服务器: ' + (e.message || '未知错误'));
} finally {
this.toggleLoading(false);
}
}
/**
* 核心修改点:渲染视频卡片结构 (三窗格复用 DOM)
*/
renderSlides() {
this.dom.videoContainer.innerHTML = '';
this.dom.videoContainer.className = 'relative w-full h-full transition-transform duration-300 ease-out';
// 清除格子视图残留的内联 transition,防止覆盖 class 里的过渡动画
this.dom.videoContainer.style.transition = '';
this.dom.videoContainer.style.transform = `translateY(-${this.state.currentIndex * 100}%)`;
// 只初始化 3 个物理槽位
for (let i = 0; i < 3; i++) {
const el = document.createElement('div');
el.className = 'slide-item';
el.id = `slide-${i}`;
el.style.display = 'none';
el.innerHTML = `
<div class="w-full h-full relative bg-black">
<!-- 封面缩略图: cover 模式下可见(视频加载前占位),contain 模式下隐藏(模糊背景处理背景) -->
<img id="thumb-${i}" class="absolute inset-0 w-full h-full object-cover z-0 ${this.state.isScaleFill ? '' : 'opacity-0'}" src="" alt="" />
<!-- 高斯模糊背景(contain 模式下可见,cover 模式下透明)。使用 scale-[1.15] 避免模糊边缘向内渐隐导致光晕/暗角特效 -->
<div id="poster-${i}" class="absolute inset-0 bg-cover bg-center scale-[1.15] blur-xl transition-opacity duration-300 z-0 ${this.state.isScaleFill ? 'opacity-0' : 'opacity-70'}"></div>
<div id="overlay-${i}" class="absolute inset-0 bg-black/40 transition-opacity duration-300 z-0 ${this.state.isScaleFill ? 'opacity-0' : 'opacity-100'}"></div>
<!-- 视频层:初始透明,于 canplay 时淡入覆盖封面 -->
<video id="video-p${i}" class="video-fullscreen ${this.state.isScaleFill ? 'object-cover' : 'object-contain'} opacity-0 transition-opacity duration-500 z-10" playsinline webkit-playsinline muted autoplay></video>
<!-- 遅罩层:置于视频之上以屏蔽长按啇出的系统原生下载菜单 -->
<div class="touch-layer absolute inset-0 z-20" style="-webkit-touch-callout: none;"></div>
<div id="playBtn-${i}" class="play-pause-btn">
<i data-lucide="play" class="stroke-white w-8 h-8"></i>
</div>
</div>
`;
this.dom.videoContainer.appendChild(el);
}
this.dom.slides = [
document.getElementById('slide-0'),
document.getElementById('slide-1'),
document.getElementById('slide-2')
];
lucide.createIcons();
this.updateUI();
}
renderGridView() {
this.dom.slides = null;
const container = this.dom.videoContainer;
// 重置页面滚动(防止 scrollIntoView 污染 html.scrollTop 导致标题栏错位)
document.documentElement.scrollTop = 0;
// 禁用过渡 + 强制回流,再清零 transform,防止 stream 模式的偏移残留
container.style.transition = 'none';
void container.offsetHeight;
container.style.transform = 'none';
container.innerHTML = '';
// 完全重置为绝对定位的、可滚动的原生网格层
container.className = 'absolute inset-0 z-10 bg-black/40 overflow-hidden flex flex-col';
// 背景海报模糊层
const currentVideo = this.state.videos[this.state.currentIndex];
const bgPosterStr = currentVideo ? this.getImageSrc(currentVideo) : '';
const bgLayer = document.createElement('div');
bgLayer.className = 'absolute inset-0 bg-cover bg-center opacity-70 scale-[1.15] blur-xl z-[-1] pointer-events-none';
if (bgPosterStr) {
const imgL = new Image();
imgL.crossOrigin = 'anonymous';
imgL.referrerPolicy = 'no-referrer';
imgL.src = bgPosterStr;
imgL.onerror = () => {
bgLayer.style.backgroundImage = `url('${this.getImageSrc(null)}')`;
};
bgLayer.style.backgroundImage = `url('${bgPosterStr}')`;
}
container.appendChild(bgLayer);
const gridWrapper = document.createElement('div');
const safeHeader = document.createElement('div');
let libraryName = '全部视频';
if (this.state.currentLibraryId === 'favorites') {
libraryName = '收藏夹';
} else if (this.state.currentLibraryId) {
const libIdStr = String(this.state.currentLibraryId);
// 尝试从视图/播放列表数据缓存中寻找库名
const cachedViewsStr = localStorage.getItem('emby_views_cache');
if (cachedViewsStr) {
try {
const cached = JSON.parse(cachedViewsStr);
let found = null;
if (cached.views && cached.views.Items) {
found = cached.views.Items.find(i => String(i.Id) === libIdStr);
}
if (!found && cached.playlists && cached.playlists.Items) {
found = cached.playlists.Items.find(i => String(i.Id) === libIdStr);
}
if (found) libraryName = found.Name;
} catch (e) { }
}
}
const PAGE_SIZE = 99; // 与 fetchVideos 中保持一致
const totalCount = this.state.totalCount || 0;
const startIndex = this.state.startIndex || 0;
const currentPage = Math.floor(startIndex / PAGE_SIZE) + 1;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const isSpecificLib = !!this.state.currentLibraryId; // 是否选了具体媒体库
const showPager = isSpecificLib && totalPages > 1;
// 总数显示:真实数字(≤999)或 999+
const countStr = totalCount > 999 ? '999+' : String(totalCount);
safeHeader.className = 'w-full flex items-center gap-2 bg-black/50 backdrop-blur-sm border-b border-gray-800/50 px-3 pb-3';
safeHeader.style.paddingTop = 'max(8px, env(safe-area-inset-top, 8px))';
// 全局统一布局: [媒体库名] [count ✨] (若有多页则右侧添加 [ x/n ❯])
safeHeader.innerHTML = `
<span class="text-sm font-bold text-gray-200">${libraryName}</span>
<span class="text-xs text-primary/70 tabular-nums flex items-center">
${countStr}
<span class="w-1.5"></span>
<button id="gridShuffleBtn" title="随机换一批" class="active:scale-90 transition-transform p-[2px] bg-white/10 rounded-full hover:bg-white/20">
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-white"></i>
</button>
</span>
<span class="flex-1"></span>
${showPager ? `
<button id="gridPrevBtn" class="px-1.5 py-0.5 rounded text-xs ${currentPage <= 1 ? 'text-gray-600 pointer-events-none' : 'text-gray-300 active:scale-90 hover:text-white transition-colors'}"></button>
<span class="text-xs text-gray-400 tabular-nums">${currentPage}<span class="text-gray-600">/</span>${totalPages}</span>
<button id="gridNextBtn" class="px-1.5 py-0.5 rounded text-xs ${currentPage >= totalPages ? 'text-gray-600 pointer-events-none' : 'text-gray-300 active:scale-90 hover:text-white transition-colors'}"></button>
` : ''}
`;
container.appendChild(safeHeader);
// 换一批按钮:所有模式都有,随机获取 99 个
const shuffleBtn = document.getElementById('gridShuffleBtn');
if (shuffleBtn) {
const curLibId = this.state.currentLibraryId;
const libArg = (!curLibId || curLibId === 'favorites') ? null : curLibId;
shuffleBtn.onclick = (e) => {
e.stopPropagation();
this.state.startIndex = 0;
this.fetchVideos(libArg, null, false, true); // isRandom=true
};
}
// 分页按钮:仅指定媒体库且多页时显示
if (showPager) {
const curLibId = this.state.currentLibraryId;
const libArg = curLibId === 'favorites' ? null : curLibId;
const prevBtn = document.getElementById('gridPrevBtn');
if (prevBtn && currentPage > 1) {
prevBtn.onclick = (e) => {
e.stopPropagation();
this.state.startIndex = Math.max(0, startIndex - PAGE_SIZE);
this.fetchVideos(libArg, null, true, false);
};
}
const nextBtn = document.getElementById('gridNextBtn');
if (nextBtn && currentPage < totalPages) {
nextBtn.onclick = (e) => {
e.stopPropagation();
this.state.startIndex = startIndex + PAGE_SIZE;
this.fetchVideos(libArg, null, true, false);
};
}
}
// 创建专门的内部滚动容器
const scrollArea = document.createElement('div');
scrollArea.className = 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden relative w-full overscroll-contain';
scrollArea.style.WebkitOverflowScrolling = 'touch';
container.appendChild(scrollArea);
// MOUSE DRAG SUPPORT FOR GRID
let isDown = false;
let startY;
let scrollTop;
scrollArea.addEventListener('mousedown', (e) => {
isDown = true;
scrollArea.style.cursor = 'grabbing';
startY = e.pageY - scrollArea.offsetTop;
scrollTop = scrollArea.scrollTop;
});
scrollArea.addEventListener('mouseleave', () => {
isDown = false;
scrollArea.style.cursor = '';
});
scrollArea.addEventListener('mouseup', () => {
isDown = false;
scrollArea.style.cursor = '';
});
scrollArea.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const y = e.pageY - scrollArea.offsetTop;
const walk = (y - startY) * 2;
scrollArea.scrollTop = scrollTop - walk;
});
gridWrapper.className = 'grid grid-cols-3 gap-1 content-start w-full relative z-10 px-1 pt-2';
scrollArea.appendChild(gridWrapper);
this.state.videos.forEach((video, index) => {
const el = document.createElement('div');
const isActive = index === this.state.currentIndex;
el.setAttribute('tabindex', '0');
el.setAttribute('data-index', index);
el.className = `focusable-item aspect-[3/4] relative cursor-pointer overflow-hidden rounded-sm active:scale-95 transition-transform ${isActive ? 'border-2 border-primary box-border' : 'bg-gray-800'}`;
el.onclick = () => {
this.state.currentIndex = index;
this.toggleViewMode();
};
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.referrerPolicy = 'no-referrer';
img.src = this.getImageSrc(video);
img.className = 'w-full h-full object-cover';
img.loading = 'lazy';
img.onerror = () => {
img.onerror = null;
// poster.webp 也不存在时,用内联 SVG 兜底,避免显示破损图标
img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Crect width="1" height="1" fill="%23333"%2F%3E%3C%2Fsvg%3E';
};
el.appendChild(img);
if (video.RunTimeTicks) {
const duration = Math.floor(video.RunTimeTicks / 10000000);
const mins = Math.floor(duration / 60);
const secs = duration % 60;
const timeBadge = document.createElement('div');
timeBadge.className = 'absolute bottom-1 right-1 bg-black/70 text-white text-[10px] px-1 py-0.5 rounded';
timeBadge.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
el.appendChild(timeBadge);
}
gridWrapper.appendChild(el);
});
// --- 彩蛋尾部文案:一言 API ---
// 修改说明:
// 基础 URL: https://v1.hitokoto.cn/
// 常用参数: ?c=a (动画) | ?c=b (漫画) | ?c=d (文学) | ?c=i (诗词)
// 不传参数则随机全类型
// 如不需要彩蛋,移除下方代码块即可
const eggFooter = document.createElement('div');
eggFooter.className = 'col-span-3 pt-8 pb-24 px-4 text-center text-gray-600 text-[11px] leading-relaxed select-none';
eggFooter.innerHTML = '<span class="opacity-40">——</span>';
gridWrapper.appendChild(eggFooter);
// 异步获取一言并填入尾部
fetch('https://v1.hitokoto.cn/?encode=json&charset=utf-8')
.then(r => r.json())
.then(data => {
const text = data.hitokoto || '';
// from_who 为作者,from 为来源作品
const source = [data.from_who, data.from].filter(Boolean).join(' · ');
eggFooter.innerHTML = `
<p class="text-gray-300">“${text}”</p>
${source ? `<p class="mt-1 text-gray-500 italic">- ${source} -</p>` : ''}
`;
})
.catch(() => {
// 网络失败就静默不显示
eggFooter.innerHTML = '';
});
// 定位到当前高亮元素
const focusVideo = this.state.videos[this.state.currentIndex];
if (focusVideo) {
this.dom.videoTitle.textContent = focusVideo.Name || '未知视频';
this.dom.videoDescription.textContent = focusVideo.Overview || '没有简介...';
}
// 滚动到当前高亮的卡片(直接操作 scrollArea,避免 scrollIntoView 污染 html.scrollTop
requestAnimationFrame(() => {
const activeEl = gridWrapper.children[this.state.currentIndex];
if (activeEl && scrollArea) {
const elTop = activeEl.offsetTop;
const elHeight = activeEl.offsetHeight;
const areaHeight = scrollArea.clientHeight;
scrollArea.scrollTop = elTop - (areaHeight / 2) + (elHeight / 2);
}
});
lucide.createIcons();
}
// 缓冲渲染逻辑
renderBuffer() {
if (this.state.viewMode !== 'stream' || !this.dom.slides) return;
this.dom.videoContainer.style.transform = `translateY(-${this.state.currentIndex * 100}%)`;
const offsets = [-1, 0, 1];
offsets.forEach((offset) => {
const videoIdx = this.state.currentIndex + offset;
if (videoIdx < 0 || videoIdx >= this.state.videos.length) return;
const slideIdx = (videoIdx % 3 + 3) % 3;
const slideEl = this.dom.slides[slideIdx];
const videoData = this.state.videos[videoIdx];
slideEl.style.transform = `translateY(${videoIdx * 100}%)`;
slideEl.style.display = 'block';
if (slideEl.dataset.id !== videoData.Id) {
slideEl.dataset.id = videoData.Id;
const posterSrc = this.getImageSrc(videoData);
// 《修复点②》 封面图层:直接赋値,密集型封面直接服务于动画过度
const thumbEl = document.getElementById(`thumb-${slideIdx}`);
if (thumbEl) {
thumbEl.src = posterSrc;
thumbEl.onerror = () => { thumbEl.src = this.getImageSrc(null); };
}
// 模糊背景(contain 模式下用)
const posterEl = document.getElementById(`poster-${slideIdx}`);
if (posterEl) posterEl.style.backgroundImage = `url('${posterSrc}')`;
// 《修复点①》 视频重置为透明(封面图作为占位)
const videoEl = document.getElementById(`video-p${slideIdx}`);
videoEl.style.opacity = '0';
videoEl.onerror = null;
// 《修复点④》 预加载相邻视频:将实际播放地址赋给相邻 slot
const preloadSrc = this.getVideoSrc(videoData.Id, videoData);
if (videoEl.src !== preloadSrc) {
videoEl.src = preloadSrc;
videoEl.load();
}
// 【关键修复】防止声音重叠:
// 预加载的视频(offset != 0)必须强制静音,
// 只有当前视频(offset == 0)遵循全局静音状态。
videoEl.muted = (offset === 0) ? this.state.isMuted : true;
document.getElementById(`playBtn-${slideIdx}`).classList.remove('paused');
}
});
// 隐藏范围外的卡片(只清空 src,不调 load())
for (let i = 0; i < 3; i++) {
const activeIndices = offsets.map(o => (this.state.currentIndex + o));
const currentSlideVideoIdx = activeIndices.find(idx => ((idx % 3 + 3) % 3) === i);
if (currentSlideVideoIdx === undefined || currentSlideVideoIdx < 0 || currentSlideVideoIdx >= this.state.videos.length) {
this.dom.slides[i].style.display = 'none';
this.dom.slides[i].dataset.id = '';
const videoEl = document.getElementById(`video-p${i}`);
if (videoEl) {
videoEl.onerror = null;
videoEl.style.opacity = '0';
videoEl.removeAttribute('src');
}
}
}
this.updateUI();
}
loadVideo(index) {
if (this.state.viewMode !== 'stream') return;
this.state.playSessionId = 'ex_' + Date.now().toString(16); // 每次加载新视频生成唯一会话 ID
this.renderBuffer(); // 确保 DOM 就位
const videoData = this.state.videos[this.state.currentIndex];
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const videoEl = document.getElementById(`video-p${slideIdx}`);
if (!videoEl || !videoData) return;
// renderBuffer 已赋值 src,处理未匹配时的修正
const src = this.getVideoSrc(videoData.Id, videoData);
if (videoEl.src !== src) {
videoEl.src = src;
videoEl.load();
}
// 同步全局静音状态(精准更新 SVG)
videoEl.muted = this.state.isMuted;
const muteIcon = this.dom.muteBtn.querySelector('svg');
if (muteIcon) {
const iconName = this.state.isMuted ? 'volume-x' : 'volume-2';
const iconColor = this.state.isMuted ? 'text-secondary' : 'stroke-white';
this.dom.muteBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${iconName}" class="${iconColor} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons({ nameAttr: 'data-lucide', attrs: {}, nodes: [this.dom.muteBtn] });
}
// 《修复点③》 加载圈延迟 300ms:局域网正常情况下用户完全不会看到加载圈
videoEl.onwaiting = () => {
clearTimeout(this._waitingTimer);
this._waitingTimer = setTimeout(() => this.toggleLoading(true), 300);
};
videoEl.onplaying = () => {
clearTimeout(this._waitingTimer);
this.toggleLoading(false);
// 确保播放时图层可见,应对部分系统/浏览器事件次序差异
videoEl.style.opacity = '1';
this.reportPlayback('playing', videoEl);
};
// 《修复点①》 canplay 时视频淡入覆盖封面
videoEl.oncanplay = () => {
clearTimeout(this._waitingTimer);
this.toggleLoading(false);
videoEl.style.opacity = '1';
};
// 处理预加载已经就绪的情况(readyState >= 3 表示已可播放)
if (videoEl.readyState >= 3) {
clearTimeout(this._waitingTimer);
this.toggleLoading(false);
videoEl.style.opacity = '1';
}
videoEl.ontimeupdate = () => {
if (videoEl.duration) {
const pct = (videoEl.currentTime / videoEl.duration) * 100;
this.dom.progressLine.style.width = `${pct}%`;
this.dom.currentTime.textContent = this.formatTime(videoEl.currentTime);
this.dom.totalTime.textContent = this.formatTime(videoEl.duration);
this.reportPlayback('progress', videoEl);
}
};
videoEl.onpause = () => this.reportPlayback('progress', videoEl);
videoEl.onended = () => {
this.reportPlayback('stopped', videoEl);
if (this.state.viewMode === 'stream' && this.state.isAutoplay) {
this.nextVideo();
}
};
videoEl.onerror = () => {
console.warn('视频播放失败,处理降级重试逻辑');
this.toggleLoading(false);
this.retryWithAltMode(this.state.currentIndex, videoData);
};
videoEl.dataset.retryCount = '0';
}
playVideo(index) {
if (this.state.viewMode !== 'stream') return;
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
// Safari & Edge Autoplay Policy Bypass (解锁视频池)
if (!window._videosUnlocked) {
const unlock = () => {
window._videosUnlocked = true;
[0, 1, 2].forEach(i => {
const vi = document.getElementById(`video-p${i}`);
if (vi && i !== slideIdx) {
// 快速播放并暂停,获取浏览器授权令牌
const p = vi.play();
if (p !== undefined) p.then(() => vi.pause()).catch(() => { });
}
});
document.removeEventListener('touchstart', unlock, true);
document.removeEventListener('click', unlock, true);
};
document.addEventListener('touchstart', unlock, { once: true, capture: true });
document.addEventListener('click', unlock, { once: true, capture: true });
}
// 停止非当前视频
[0, 1, 2].forEach(i => {
const vi = document.getElementById(`video-p${i}`);
if (vi && i !== slideIdx) {
vi.pause();
vi.currentTime = 0;
// 优化:切换视频时,旧视频只需静默停止,不要显示暂停按钮,防止闪烁穿帮
const btn = document.getElementById(`playBtn-${i}`);
if (btn) btn.classList.remove('paused');
}
});
const v = document.getElementById(`video-p${slideIdx}`);
const btn = document.getElementById(`playBtn-${slideIdx}`);
if (v) {
if (!this.state.isMuted) v.muted = false;
const playPromise = v.play();
if (playPromise !== undefined) {
playPromise.then(() => {
this.state.isPlaying = true;
if (btn) btn.classList.remove('paused');
}).catch(error => {
console.warn('播放被阻止(可能缺失交互):', error);
this.state.isPlaying = false;
this.toggleLoading(false);
if (btn) btn.classList.add('paused');
// 降级:如果被浏览器强行阻止,弹出UI引导用户点击
if (error.name === 'NotAllowedError') {
this.showToast('浏览器限制自动播放,请点击屏幕开始');
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
}
}
});
}
}
}
retryWithAltMode(index, item) {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (!v) return;
const retryCount = parseInt(v.dataset.retryCount || '0');
if (this.config.useStatic) {
// 报错时立即请求文件详情获取库容器编码
const doShow = (fmt) => this.showToast(`原生播放器不支持此编码 (${fmt ? fmt.toUpperCase() : '未知'})`);
const { server, token, userId } = this.config;
fetch(`${server}/emby/Users/${userId}/Items/${item.Id}?api_key=${token}&Fields=MediaSources`)
.then(r => r.json())
.then(d => {
const src = d.MediaSources?.[0];
const vStream = src?.MediaStreams?.find(s => s.Type === 'Video');
const codec = vStream?.Codec || src?.Container || '';
doShow(codec);
})
.catch(() => doShow(''));
return;
}
// 如果取消了"直接播放"(允许转码),进行1次重试,使用转码流
if (retryCount >= 1) {
this.showToast('转码播放失败,请检查 Emby 设置');
return;
}
v.dataset.retryCount = String(retryCount + 1);
const { server, token } = this.config;
// 转码模式 Container=mp4
v.src = `${server}/emby/Videos/${item.Id}/stream?api_key=${token}&Container=mp4`;
v.load();
this.playVideo(index);
this.showToast('尝试转码播放中...');
}
togglePlay() {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
const btn = document.getElementById(`playBtn-${slideIdx}`);
if (!v) return;
if (v.paused) {
v.play();
this.state.isPlaying = true;
if (btn) btn.classList.remove('paused');
} else {
v.pause();
this.state.isPlaying = false;
if (btn) btn.classList.add('paused');
}
}
prevVideo() {
if (this.state.currentIndex === 0) {
return;
}
this.switchSlide(this.state.currentIndex - 1, 'down');
}
switchSlide(newIndex, direction) {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const curV = document.getElementById(`video-p${slideIdx}`);
if (curV) {
curV.pause();
this.reportPlayback('stopped', curV);
}
// 【动画修复】先同步预就位目标 slide,再同步触发 container 位移动画
// 这样浏览器能在同一帧内确认 slide 位置,动画由 GPU 合成层完成,不会闪现
if (this.dom.slides) {
const destSlideIdx = (newIndex % 3 + 3) % 3;
const destSlide = this.dom.slides[destSlideIdx];
if (destSlide) {
// 预就位:让 slide 在动画帧前就已经在正确位置
destSlide.style.transform = `translateY(${newIndex * 100}%)`;
destSlide.style.display = 'block';
}
}
this.state.currentIndex = newIndex;
// 立即触发 CSS 过渡动画(container 位移)——不等 rAF
if (this.dom.videoContainer) {
this.dom.videoContainer.style.transform = `translateY(-${newIndex * 100}%)`;
}
// 视频加载(重操作)推到下一帧,不阻塞动画首帧渲染
requestAnimationFrame(() => {
this.loadVideo(newIndex);
this.playVideo(newIndex);
});
}
reportPlayback(event, videoEl) {
if (!this.config.server || !this.config.token || this.state.videos.length === 0) return;
const now = Date.now();
// 进度汇报增加 5s 节流,避免频繁请求,playing 和 stopped 立即执行
if (event === 'progress' && now - (this.state.lastReportTime || 0) < 5000) return;
if (event === 'progress') this.state.lastReportTime = now;
const videoData = this.state.videos[this.state.currentIndex];
if (!videoData || !videoEl) return;
// Emby 识别修正:1. 映射标准事件名 2. 补全 MediaSourceId 3. 首次播放进行能力上报
const eventMap = { 'playing': 'TimeUpdate', 'progress': 'TimeUpdate', 'stopped': 'Stopped' };
const eventName = videoEl.paused ? 'Pause' : (eventMap[event] || 'TimeUpdate');
const mediaSourceId = videoData.MediaSources?.[0]?.Id || '';
if (event === 'playing') {
this.reportCapabilities();
}
const ticks = Math.floor(videoEl.currentTime * 10000000);
let endpoint = '/Sessions/Playing';
if (event === 'progress') endpoint = '/Sessions/Playing/Progress';
if (event === 'stopped') endpoint = '/Sessions/Playing/Stopped';
const payload = {
ItemId: videoData.Id,
PositionTicks: ticks,
IsPaused: videoEl.paused || event === 'stopped',
IsMuted: videoEl.muted,
VolumeLevel: Math.floor(videoEl.volume * 100),
PlayMethod: this.config.useStatic ? 'DirectPlay' : 'Transcode',
EventName: eventName,
CanSeek: true,
PlaySessionId: this.state.playSessionId,
QueueableMediaTypes: ["Video"]
};
if (mediaSourceId) payload.MediaSourceId = mediaSourceId;
fetch(`${this.config.server}/emby${endpoint}?api_key=${this.config.token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
},
body: JSON.stringify(payload)
}).catch(() => { });
}
reportCapabilities() {
const { server, token } = this.config;
if (!server || !token) return;
fetch(`${server}/emby/Sessions/Capabilities/Full?api_key=${token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
},
body: JSON.stringify({
PlayableMediaTypes: ["Video"],
SupportsMediaControl: true,
SupportsPersistentConnections: false
})
}).catch(() => { });
}
// --- 辅助功能 ---
getVideoSrc(id, mediaItem) {
const { server, token, useStatic } = this.config;
// 策略 B:取消【直接播放】,全权委托 Emby 服务器自动降级
// Direct Play > Direct Stream > Transcode,无需前端干预
if (!useStatic) {
return `${server}/emby/Videos/${id}/stream.mp4?api_key=${token}&VideoCodec=h264,hevc,vp9&AudioCodec=aac,mp3`;
}
// 策略 A:勾选【直接播放】,默认原画直连,对 iOS 进行转封装兼容
const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (isIOS && mediaItem) {
const mediaSourceId = mediaItem.MediaSources?.[0]?.Id || '';
if (mediaSourceId) {
// iOS专属 HLS 流水线
// 修复:Emby API 强制要求 PlaySessionId,且必须指定 SegmentContainer=mp4 支持 fMP4 HEVC 切片
// 补充了 VideoBitrate 避免 Emby 计算带限报错
const playSessionId = 'ios_hls_' + id + '_' + Date.now();
const hlsProps = [
`api_key=${token}`,
`MediaSourceId=${mediaSourceId}`,
`DeviceId=EmbyX_Web_iOS`,
`PlaySessionId=${playSessionId}`,
`VideoCodec=hevc,h264`,
`AudioCodec=aac,mp3,ac3`,
`VideoBitrate=140000000`,
`AudioBitrate=320000`,
`TranscodingMaxAudioChannels=2`,
`SegmentContainer=mp4,ts`,
`MinSegments=1`,
`BreakOnNonKeyFrames=True`,
`EnableAutoStreamCopy=true`,
`AllowVideoStreamCopy=true`,
`AllowAudioStreamCopy=true`
].join('&');
return `${server}/emby/Videos/${id}/master.m3u8?${hlsProps}`;
}
}
// 默认:原画直连(安卓全格式 / PC 端 / MediaSources 缺失时兜底)
return `${server}/emby/Videos/${id}/stream?api_key=${token}&Static=true`;
}
getImageSrc(video) {
const { server, token } = this.config;
// 优先加载同目录的定制封面图
const fallbackUrl = './poster.webp';
if (typeof video === 'object' && video !== null) {
if (video.ImageTags && video.ImageTags.Primary) {
return `${server}/emby/Items/${video.Id}/Images/Primary?api_key=${token}`;
}
return fallbackUrl;
}
// 此时传入的是纯 ID 字符串(旧逻辑残留处理)
if (!video) return fallbackUrl;
return `${server}/emby/Items/${video}/Images/Primary?api_key=${token}`;
}
async showLibraries() {
this.toggleLoading(true);
const list = document.getElementById('librariesList');
list.innerHTML = '';
try {
const userId = this.config.userId;
const playlistsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Items?Recursive=true&IncludeItemTypes=Playlist&api_key=${this.config.token}`, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
}
});
const playlistsData = await playlistsRes.json();
const viewsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Views?api_key=${this.config.token}`, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="EmbyX-Device", Version="1.1"`
}
});
const viewsDataRaw = viewsRes.ok ? await viewsRes.json() : { Items: [] };
// 缓存完整的视图和列表数据供网格模式提取名称
localStorage.setItem('emby_views_cache', JSON.stringify({ playlists: playlistsData, views: viewsDataRaw }));
// 过滤掉基于特定收集方式的视图避免重叠
const filteredViews = viewsDataRaw.Items ? viewsDataRaw.Items.filter(item => item.CollectionType !== 'playlists' && item.CollectionType !== 'boxsets') : [];
const favDiv = document.createElement('div');
const isFavSelected = this.state.currentLibraryId === 'favorites';
favDiv.setAttribute('tabindex', '0');
favDiv.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isFavSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
favDiv.innerHTML = `<i data-lucide="folder-heart" class="w-4 h-4"></i><span class="flex-1 truncate">收藏夹</span>${isFavSelected ? '<i data-lucide="check" class="w-3.5 h-3.5"></i>' : ''}`;
favDiv.onclick = () => {
this.state.currentLibraryId = 'favorites';
this.state.currentTab = 'favorites';
if (this.state.favorites.size === 0) {
this.showToast('暂无收藏');
return;
}
this.state.currentLibraryType = 'favorites';
this.fetchVideos();
this.toggleModal('libraryModal', false);
};
list.appendChild(favDiv);
if (playlistsData && playlistsData.Items && playlistsData.Items.length > 0) {
const headerPl = document.createElement('div');
headerPl.className = 'col-span-2 text-xs text-gray-500 mt-2 mb-1 pl-1';
headerPl.textContent = '播放列表';
list.appendChild(headerPl);
playlistsData.Items.forEach(pl => {
const div = document.createElement('div');
const isSelected = pl.Id === this.state.currentLibraryId;
div.setAttribute('tabindex', '0');
div.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
div.innerHTML = `<i data-lucide="list-video" class="w-4 h-4 flex-shrink-0"></i><span class="flex-1 truncate">${pl.Name}</span>${isSelected ? '<i data-lucide="check" class="w-3.5 h-3.5 flex-shrink-0"></i>' : ''}`;
div.onclick = () => {
this.state.currentLibraryId = pl.Id;
this.state.currentLibraryType = 'playlist';
this.state.currentTab = 'home';
this.updateTabHighlight();
this.fetchVideos(pl.Id);
this.toggleModal('libraryModal', false);
};
list.appendChild(div);
});
}
if (filteredViews && filteredViews.length > 0) {
const headerLib = document.createElement('div');
headerLib.className = 'col-span-2 text-xs text-gray-500 mt-2 mb-1 pl-1';
headerLib.textContent = '媒体库';
list.appendChild(headerLib);
filteredViews.forEach(lib => {
const div = document.createElement('div');
const isSelected = lib.Id === this.state.currentLibraryId;
div.setAttribute('tabindex', '0');
div.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
div.innerHTML = `<i data-lucide="film" class="w-4 h-4 flex-shrink-0"></i><span class="flex-1 truncate">${lib.Name}</span>${isSelected ? '<i data-lucide="check" class="w-3.5 h-3.5 flex-shrink-0"></i>' : ''}`;
div.onclick = () => {
this.state.currentLibraryId = lib.Id;
this.state.currentLibraryType = 'library';
this.state.currentTab = 'home';
this.updateTabHighlight();
this.fetchVideos(lib.Id);
this.toggleModal('libraryModal', false);
};
list.appendChild(div);
});
}
lucide.createIcons();
this.toggleModal('libraryModal', true);
} catch (e) {
console.error('获取媒体库失败:', e);
this.showToast('获取媒体库失败');
const favDiv = document.createElement('div');
favDiv.className = 'p-3 rounded mb-2 cursor-pointer text-sm flex items-center gap-3 bg-gray-800 text-white';
favDiv.innerHTML = `<i data-lucide="folder-heart" class="w-5 h-5"></i><span class="flex-1">收藏夹</span>`;
favDiv.onclick = () => {
if (this.state.favorites.size === 0) {
this.showToast('暂无收藏');
return;
}
this.state.currentLibraryId = 'favorites';
this.state.currentLibraryType = 'favorites';
this.fetchVideos();
this.toggleModal('libraryModal', false);
};
list.appendChild(favDiv);
lucide.createIcons();
this.toggleModal('libraryModal', true);
} finally {
this.toggleLoading(false);
}
}
async toggleFavorite() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
const id = String(item.Id);
// 以 Emby API 的 UserData.IsFavorite 为单一数据源
if (!item.UserData) item.UserData = {};
const isFav = item.UserData.IsFavorite === true;
const { server, token, userId } = this.config;
// 乐观更新:先修改内存和 UI
item.UserData.IsFavorite = !isFav;
if (!isFav) {
this.state.favorites.add(id);
this.showToast('❤️ 已收藏');
} else {
this.state.favorites.delete(id);
this.showToast('取消收藏');
}
this.updateUI();
// 远端同步 Emby API
if (server && token && userId) {
try {
const method = isFav ? 'DELETE' : 'POST';
const res = await fetch(`${server}/emby/Users/${userId}/FavoriteItems/${id}?api_key=${token}`, { method });
if (!res.ok) {
// 远端失败,回滚内存状态
item.UserData.IsFavorite = isFav;
if (isFav) this.state.favorites.add(id); else this.state.favorites.delete(id);
this.updateUI();
this.showToast('操作失败,请重试');
}
} catch (e) {
console.error('收藏同步失败:', e);
}
}
}
// --- UI 更新与交互 ---
updateUI() {
const item = this.state.videos[this.state.currentIndex];
if (item) {
this.dom.videoTitle.textContent = item.Name;
this.dom.videoDescription.textContent = item.Overview || '暂无简介';
const favBtn = document.getElementById('favoriteBtn');
// 以 Emby API 返回的 UserData.IsFavorite 为单一数据源
const isFav = item.UserData?.IsFavorite === true;
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${isFav ? 'folder-heart' : 'heart'}" class="${isFav ? 'text-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
// 定向更新:只扫描 favBtn 内部,不全局扫描 DOM
lucide.createIcons({ nameAttr: 'data-lucide', nodes: [favBtn] });
}
}
updateTabHighlight() {
[this.dom.myBtn, this.dom.libraryBtn, this.dom.playModeBtn, this.dom.viewModeBtn].forEach(btn => {
if (btn) {
btn.classList.add('text-gray-400');
btn.classList.remove('text-white');
}
});
}
toggleAutoplay() {
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
} else {
app.classList.add('interface-hidden');
}
}
toggleDeleteMode() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
this.dom.deleteFileName.textContent = '确认删除 ' + (item.Name || '此视频') + ' 吗?';
this.toggleModal('deleteConfirmModal', true);
lucide.createIcons();
}
executeDelete() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, {
method: 'DELETE'
}).then(() => {
this.showToast('✅ 已删除');
this.state.videos.splice(this.state.currentIndex, 1);
this.toggleModal('deleteConfirmModal', false);
if (this.state.viewMode === 'grid') {
this.renderGridView();
} else {
// 如果删的是最后一个,回到上一个
if (this.state.currentIndex >= this.state.videos.length) {
this.state.currentIndex = Math.max(0, this.state.videos.length - 1);
}
this.renderSlides();
}
}).catch(() => {
this.showToast('❌ 删除失败');
this.toggleModal('deleteConfirmModal', false);
});
}
showInterfaceTemp() {
const app = document.getElementById('app');
if (this.csTimer) clearTimeout(this.csTimer);
// 只有在流模式且处于全屏状态,且界面当前处于显示状态,
// 且当前没有任何弹窗遮罩时,才开启自动隐藏计时。
if (this.state.viewMode === 'grid') return;
if (this.isAnyModalOpen()) return;
if ((document.fullscreenElement || app.classList.contains('ios-pwa-fullscreen')) && !app.classList.contains('interface-hidden')) {
// 全局自动清屏倒计时:默认五秒 (5000ms)
// 注释:如果你想修改清屏等待时长,请修改下方的 5000 数值
this.csTimer = setTimeout(() => {
app.classList.add('interface-hidden');
}, 5000);
}
}
// 随机模式:原地洗牌 videos 数组,保持 currentIndex 不变
// 这样 +1 下一个就是随机视频,且三槽预缓冲完全生效
_shuffleAroundCurrent() {
const idx = this.state.currentIndex;
const cur = this.state.videos[idx];
// 整体洗牌
for (let i = this.state.videos.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.state.videos[i], this.state.videos[j]] = [this.state.videos[j], this.state.videos[i]];
}
// 将当前视频放回其在数组中的原位置
const newPos = this.state.videos.findIndex(v => v.Id === cur.Id);
[this.state.videos[newPos], this.state.videos[idx]] = [this.state.videos[idx], this.state.videos[newPos]];
}
togglePlayMode() {
const isSeq = this.state.playMode === 'sequence';
this.state.playMode = isSeq ? 'random' : 'sequence';
const btn = this.dom.playModeBtn;
btn.innerHTML = `<i data-lucide="${isSeq ? 'shuffle' : 'repeat'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
if (isSeq) {
// 切换随机:保存原始列表,就地洗牌
// currentIndex/物理 slot 不变,当前视频完全不受干扰
this.state.originalVideos = [...this.state.videos];
this._shuffleAroundCurrent();
// 刷新相邻插槽(当前插槽内容不变,两侧插槽更新为随机顺序)
if (this.dom.slides) this.renderBuffer();
this.showToast('🔀 随机播放(下一个)');
} else {
// 切回顺序:恢复原始列表,将当前视频对换到 currentIndex 位置
// 确保物理 slot 不变、currentIndex 不变,当前播放零干扰
const curId = this.state.videos[this.state.currentIndex]?.Id;
this.state.videos = [...this.state.originalVideos];
this.state.originalVideos = [];
if (curId) {
const origPos = this.state.videos.findIndex(v => String(v.Id) === String(curId));
if (origPos >= 0 && origPos !== this.state.currentIndex) {
[this.state.videos[origPos], this.state.videos[this.state.currentIndex]] =
[this.state.videos[this.state.currentIndex], this.state.videos[origPos]];
}
}
// 刷新相邻插槽
if (this.dom.slides) this.renderBuffer();
this.showToast('🔁 顺序播放(下一个)');
}
}
// 删除旧的 _buildShuffledQueuenextVideo 恢复纯顺序逻辑
nextVideo() {
const nextIndex = this.state.currentIndex + 1;
if (nextIndex >= this.state.videos.length) {
this.showToast('已经是最后一个视频了');
return;
}
this.switchSlide(nextIndex, 'up');
}
toggleViewMode() {
const isStream = this.state.viewMode === 'stream';
this.state.viewMode = isStream ? 'grid' : 'stream';
const btn = this.dom.viewModeBtn;
btn.innerHTML = `<i data-lucide="${this.state.viewMode === 'stream' ? 'gallery-vertical' : 'layout-grid'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
if (this.state.viewMode === 'grid') {
const app = document.getElementById('app');
if (app) {
app.classList.remove('interface-hidden');
app.classList.add('grid-active'); // 特殊清屏状态:只留底部栏
if (this.csTimer) clearTimeout(this.csTimer);
}
this.dom.videoContainer.style.touchAction = 'auto';
this.renderGridView();
} else {
const app = document.getElementById('app');
if (app) app.classList.remove('grid-active');
this.dom.videoContainer.style.touchAction = 'pan-y';
this.renderSlides();
this.loadVideo(this.state.currentIndex);
this.playVideo(this.state.currentIndex);
// 切回流模式后重新开启自动清屏计时
this.showInterfaceTemp();
}
}
toggleScaleMode() {
this.state.isScaleFill = !this.state.isScaleFill;
const videos = document.querySelectorAll('.video-fullscreen');
videos.forEach((v, index) => {
v.classList.remove('object-cover', 'object-contain');
v.classList.add(this.state.isScaleFill ? 'object-cover' : 'object-contain');
const thumb = document.getElementById(`thumb-${index}`);
const poster = document.getElementById(`poster-${index}`);
const overlay = document.getElementById(`overlay-${index}`);
if (this.state.isScaleFill) {
// cover 模式:显示封面缩略图,隐藏模糊背景
if (thumb) thumb.style.opacity = '1';
if (poster) poster.style.opacity = '0';
if (overlay) overlay.style.opacity = '0';
} else {
// contain 模式:隐藏封面缩略图,显示模糊背景
if (thumb) thumb.style.opacity = '0';
if (poster) poster.style.opacity = '0.7';
if (overlay) overlay.style.opacity = '1';
}
});
const scaleBtn = this.dom.scaleBtn;
scaleBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${this.state.isScaleFill ? 'maximize-2' : 'minimize-2'}" class="stroke-white w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons();
}
async showMediaInfo() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
this.dom.mediaInfoModal.classList.remove('hidden');
// 打开详情面板也是交互,且属于遮罩状态,需重置/停止计时
this.showInterfaceTemp();
this.dom.mediaInfoContent.innerHTML = `<div class="flex justify-center py-4"><i data-lucide="loader-2" class="w-6 h-6 animate-spin text-gray-500"></i></div>`;
lucide.createIcons();
try {
const res = await fetch(`${this.config.server}/emby/Users/${this.config.userId}/Items/${item.Id}?api_key=${this.config.token}&Fields=MediaSources`);
if (!res.ok) throw new Error('API Error');
const data = await res.json();
if (data && data.MediaSources && data.MediaSources.length > 0) {
const src = data.MediaSources[0];
const c = src.Container || '未知';
const vStream = src.MediaStreams?.find(s => s.Type === 'Video');
const w = vStream?.Width || '?';
const h = vStream?.Height || '?';
const fps = vStream?.AverageFrameRate ? Math.round(parseFloat(vStream.AverageFrameRate)) : '?';
const vCodec = vStream?.Codec || '未知';
const aCodec = src.MediaStreams?.find(s => s.Type === 'Audio')?.Codec || '未知';
const bitrate = src.Bitrate ? (src.Bitrate / 1000000).toFixed(2) + ' Mbps' : '未知';
const fileSizeStr = src.Size ? (src.Size > 1024 * 1024 * 1024 ? (src.Size / (1024 * 1024 * 1024)).toFixed(1) + ' GB' : (src.Size / (1024 * 1024)).toFixed(1) + ' MB') : '未知';
const transcodeState = this.config.useStatic ? '直接播放' : '自动转码';
// 布局说明:py-2 控制行高(间距),items-center 确保文字垂直居中
this.dom.mediaInfoContent.innerHTML = `
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">分辨率</span>
<span class="col-span-2 text-gray-200 font-mono">${w}x${h}@${fps}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">封装格式</span>
<span class="col-span-2 text-gray-200">${c.toUpperCase()}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">视频编码</span>
<span class="col-span-2 text-gray-200 font-mono">${vCodec.toUpperCase()}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">音频编码</span>
<span class="col-span-2 text-gray-200 font-mono">${aCodec.toUpperCase()}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">总码率</span>
<span class="col-span-2 text-gray-200 font-mono">${bitrate}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">文件体积</span>
<span class="col-span-2 text-gray-200 font-mono">${fileSizeStr}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center">
<span class="text-gray-500 text-right">播放策略</span>
<span class="col-span-2 text-gray-200">${transcodeState}</span>
</div>
`;
} else {
this.dom.mediaInfoContent.innerHTML = `<div class="text-center text-red-400 py-4">无法获取媒体信息</div>`;
}
} catch (e) {
this.dom.mediaInfoContent.innerHTML = `<div class="text-center text-red-400 py-4">获取数据失败</div>`;
}
}
// --- 事件绑定 ---
bindEvents() {
this.dom.clickToPlayOverlay.onclick = () => {
this.dom.clickToPlayOverlay.style.display = 'none';
this.playVideo(this.state.currentIndex);
};
this.dom.favoriteBtn.onclick = (e) => {
e.stopPropagation();
this.toggleFavorite();
const favBtn = document.getElementById('favoriteBtn');
const isFav = this.state.favorites.has(String(this.state.videos[this.state.currentIndex]?.Id));
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${isFav ? 'folder-heart' : 'heart'}" class="${isFav ? 'text-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons();
this.showInterfaceTemp();
};
this.dom.scaleBtn.onclick = (e) => {
e.stopPropagation();
this.toggleScaleMode();
this.showInterfaceTemp();
};
this.dom.videoInfoArea.onclick = (e) => {
e.stopPropagation();
const app = document.getElementById('app');
// 清屏状态或格子视图均不触发流媒体详情
if (app.classList.contains('interface-hidden') || app.classList.contains('grid-active')) return;
this.showMediaInfo();
};
this.dom.closeMediaInfoBtn.onclick = (e) => {
e.stopPropagation();
this.dom.mediaInfoModal.classList.add('hidden');
this.showInterfaceTemp();
};
this.dom.mediaInfoModal.onclick = (e) => {
if (e.target === e.currentTarget) {
this.dom.mediaInfoModal.classList.add('hidden');
this.showInterfaceTemp();
}
};
this.dom.rightToolbar.addEventListener('click', (e) => {
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
}
});
this.dom.muteBtn.onclick = (e) => {
e.stopPropagation();
this.state.isMuted = !this.state.isMuted;
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) {
v.muted = this.state.isMuted;
}
const muteBtn = this.dom.muteBtn;
muteBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${this.state.isMuted ? 'volume-x' : 'volume-2'}" class="${this.state.isMuted ? 'text-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons();
};
this.dom.fullscreenBtn.onclick = (e) => {
e.stopPropagation();
const app = document.getElementById('app');
const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches;
// 方案一:向 iOS 妥协,采用“沉浸式融合”伪全屏
// 若在 iOS 且已加主屏幕(PWA),通过手动移除 UI 来模拟系统全屏
if (isIOS && isStandalone) {
if (!app.classList.contains('ios-pwa-fullscreen')) {
app.classList.add('ios-pwa-fullscreen');
this.showInterfaceTemp();
const fullscreenBtn = this.dom.fullscreenBtn;
fullscreenBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full"><i data-lucide="minimize" class="stroke-white w-5 h-5 drop-shadow-md"></i></div>`;
lucide.createIcons();
} else {
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
} else {
app.classList.remove('ios-pwa-fullscreen');
app.classList.remove('interface-hidden');
if (this.csTimer) clearTimeout(this.csTimer);
const fullscreenBtn = this.dom.fullscreenBtn;
fullscreenBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full"><i data-lucide="maximize" class="stroke-white w-5 h-5 drop-shadow-md"></i></div>`;
lucide.createIcons();
}
}
return;
}
// 正常的 安卓/PC 系统调用原生全屏
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
this.showInterfaceTemp();
}).catch(() => { });
} else {
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
} else {
document.exitFullscreen();
app.classList.remove('interface-hidden');
}
}
};
document.addEventListener('fullscreenchange', () => {
const isFull = !!document.fullscreenElement;
const fullscreenBtn = this.dom.fullscreenBtn;
fullscreenBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full"><i data-lucide="${isFull ? 'minimize' : 'maximize'}" class="stroke-white w-5 h-5 drop-shadow-md"></i></div>`;
lucide.createIcons();
if (!isFull) {
const app = document.getElementById('app');
app.classList.remove('interface-hidden');
if (this.csTimer) clearTimeout(this.csTimer);
}
});
this.dom.deleteBtn.onclick = (e) => {
e.stopPropagation();
this.toggleDeleteMode();
};
this.dom.viewModeBtn = document.getElementById('viewModeBtn');
this.dom.viewModeBtn.onclick = () => this.toggleViewMode();
this.dom.playModeBtn.onclick = () => this.togglePlayMode();
this.dom.libraryBtn.onclick = () => this.showLibraries();
this.dom.myBtn.onclick = () => this.toggleModal('profilePage', true);
// Material Design 3 液体波纹点击反馈
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', function () {
if (this.navTimer) clearTimeout(this.navTimer);
this.classList.remove('nav-clicked');
// 强制重绘,重启CSS动画
void this.offsetWidth;
this.classList.add('nav-clicked');
this.navTimer = setTimeout(() => {
this.classList.remove('nav-clicked');
}, 1000); // 1秒后过渡消失
});
});
document.getElementById('closeLibraryBtn').onclick = () => this.toggleModal('libraryModal', false);
const libraryModal = document.getElementById('libraryModal');
libraryModal.onclick = (e) => {
if (e.target === e.currentTarget) {
this.toggleModal('libraryModal', false);
}
};
document.getElementById('closeProfileBtn').onclick = () => this.toggleModal('profilePage', false);
document.getElementById('saveConfigBtn').onclick = () => this.saveConfig();
document.getElementById('resetConfigBtn').onclick = () => this.resetConfig();
this.dom.confirmDeleteBtn.onclick = () => this.executeDelete();
this.dom.cancelDeleteBtn.onclick = () => this.toggleModal('deleteConfirmModal', false);
this.dom.deleteConfirmModal.onclick = (e) => {
if (e.target === e.currentTarget) {
this.toggleModal('deleteConfirmModal', false);
}
};
document.getElementById('progressBarContainer').onclick = (e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v && v.duration) v.currentTime = ((e.clientX - rect.left) / rect.width) * v.duration;
};
// 统一的复杂手势拦截挂载 (长按, 拖拽, 双击)
const container = document.getElementById('app');
this.bindGestures(container);
// 键盘与遥控器适配
this.bindKeyboard();
}
bindKeyboard() {
let okClickCount = 0;
let okClickTimer = null;
document.addEventListener('keydown', (e) => {
const profileActive = document.getElementById('profilePage').classList.contains('active');
const libraryActive = !document.getElementById('libraryModal').classList.contains('hidden');
// 如果正在输入(且不是在处理遥控器方向键时),不触发全局快捷键
// 但我们要允许在输入框内通过上下键跳转焦点
const isInput = ['input', 'textarea'].includes(document.activeElement.tagName.toLowerCase());
const key = e.key;
const keyCode = e.keyCode;
const lowKey = key.toLowerCase(); // 提前声明,全局可用
// --- 个人中心 / 弹窗 / 网格列表内的空间导航 (Remote Control / Keyboard Focus) ---
if (profileActive || libraryActive || document.getElementById('app').classList.contains('grid-active')) {
const items = Array.from(document.querySelectorAll(
profileActive ? '#profilePage .focusable-item' :
libraryActive ? '#libraryModal .focusable-item' :
'#videoContainer .focusable-item'
));
let currentIndex = items.indexOf(document.activeElement);
if (!isInput && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight' ||
lowKey === 'w' || lowKey === 'a' || lowKey === 's' || lowKey === 'd' ||
[38, 40, 37, 39].includes(keyCode))) {
e.preventDefault();
if (currentIndex === -1) {
let startItem = items[0];
if (document.getElementById('app').classList.contains('grid-active')) {
startItem = items.find(it => it.dataset.index == this.state.currentIndex) || items[0];
}
startItem?.focus();
} else {
const isVideoGrid = document.getElementById('app').classList.contains('grid-active') && !profileActive && !libraryActive;
if (isVideoGrid) {
// 视频网格:3 列规则偏移
let nextIdx = currentIndex;
if (key === 'ArrowDown' || lowKey === 's' || keyCode === 40) nextIdx += 3;
else if (key === 'ArrowUp' || lowKey === 'w' || keyCode === 38) nextIdx -= 3;
else if (key === 'ArrowRight' || lowKey === 'd' || keyCode === 39) nextIdx += 1;
else if (key === 'ArrowLeft' || lowKey === 'a' || keyCode === 37) nextIdx -= 1;
if (nextIdx >= 0 && nextIdx < items.length) {
items[nextIdx].focus();
items[nextIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else if (libraryActive) {
// 媒体库弹窗:基于屏幕坐标的空间导航
// 正确处理带分区标题的不连续网格,无论多少列都精准匹配视觉方向
const cur = items[currentIndex];
const curR = cur.getBoundingClientRect();
const curCx = curR.left + curR.width / 2;
const curCy = curR.top + curR.height / 2;
let dir;
if (key === 'ArrowDown' || lowKey === 's' || keyCode === 40) dir = 'down';
else if (key === 'ArrowUp' || lowKey === 'w' || keyCode === 38) dir = 'up';
else if (key === 'ArrowRight' || lowKey === 'd' || keyCode === 39) dir = 'right';
else if (key === 'ArrowLeft' || lowKey === 'a' || keyCode === 37) dir = 'left';
let best = null, bestScore = Infinity;
items.forEach(item => {
if (item === cur) return;
const r = item.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = cx - curCx, dy = cy - curCy;
// 仅保留目标方向的元素(留 2px 容差)
if (dir === 'down' && dy <= 2) return;
if (dir === 'up' && dy >= -2) return;
if (dir === 'right' && dx <= 2) return;
if (dir === 'left' && dx >= -2) return;
// 评分:主轴距离优先,副轴偏移加权惩罚
const score = (dir === 'down' || dir === 'up')
? Math.abs(dy) + Math.abs(dx) * 3
: Math.abs(dx) + Math.abs(dy) * 3;
if (score < bestScore) { bestScore = score; best = item; }
});
if (best) {
best.focus();
best.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} else {
// 个人中心:线性跳转
if (key === 'ArrowDown' || lowKey === 's' || key === 'ArrowRight' || lowKey === 'd' || [40, 39].includes(keyCode)) {
currentIndex = (currentIndex + 1) % items.length;
} else {
currentIndex = (currentIndex - 1 + items.length) % items.length;
}
items[currentIndex].focus();
}
}
return;
}
// OK / Enter 在焦点元素上触发点击
if (key === 'Enter' || keyCode === 13) {
if (document.activeElement && document.activeElement.classList.contains('focusable-item')) {
// 如果是输入框,回车不拦截,允许系统处理(弹出键盘等)
if (document.activeElement.tagName.toLowerCase() === 'input' && document.activeElement.type !== 'checkbox') {
return;
}
e.preventDefault();
document.activeElement.click();
return;
}
}
}
if (isInput) return;
// 1. 上下方向键 / WS:切换视频 (Keyboard & Remote)
if (key === 'ArrowUp' || lowKey === 'w' || keyCode === 38) {
e.preventDefault();
this.prevVideo();
} else if (key === 'ArrowDown' || lowKey === 's' || keyCode === 40) {
e.preventDefault();
this.nextVideo();
}
// 2. 左右方向键 / AD:快进快退 15 秒 (Keyboard & Remote)
else if (key === 'ArrowLeft' || lowKey === 'a' || keyCode === 37) {
e.preventDefault();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) v.currentTime = Math.max(0, v.currentTime - 15);
this.showInterfaceTemp();
} else if (key === 'ArrowRight' || lowKey === 'd' || keyCode === 39) {
e.preventDefault();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v && v.duration) v.currentTime = Math.min(v.duration, v.currentTime + 15);
this.showInterfaceTemp();
}
// 3. 确认逻辑:空格键 (Keyboard) & OK键 (Remote)
else if (key === ' ' || key === 'Enter' || keyCode === 13) {
e.preventDefault();
if (key === ' ') {
// 键盘空格:暂停/播放
this.togglePlay();
} else {
// 遥控器 OK 键: 单击暂停/播放,双击收藏
okClickCount++;
if (okClickCount === 1) {
okClickTimer = setTimeout(() => {
this.togglePlay();
okClickCount = 0;
}, 300);
} else if (okClickCount === 2) {
clearTimeout(okClickTimer);
okClickCount = 0;
// 在屏幕中心弹出收藏动画与逻辑
this.handleDoubleTap(window.innerWidth / 2, window.innerHeight / 2);
}
}
}
// 4. 菜单键/缩放键 (j / Menu)
else if (key.toLowerCase() === 'j' || keyCode === 18 || keyCode === 93 || keyCode === 118) {
if (key.toLowerCase() === 'j' || [18, 93, 118].includes(keyCode)) {
e.preventDefault();
this.toggleScaleMode();
this.showInterfaceTemp();
}
}
// 5. 新增快捷键适配 (R, E, F, G, V, I, U, M)
else {
if (lowKey === 'r') {
this.dom.playModeBtn.click(); // 触发视觉反馈
} else if (lowKey === 'f') {
this.dom.fullscreenBtn.click();
} else if (lowKey === 'e') {
this.dom.viewModeBtn.click(); // 触发视觉反馈
} else if (lowKey === 'g') {
const modal = document.getElementById('libraryModal');
if (modal && !modal.classList.contains('hidden')) {
this.toggleModal('libraryModal', false);
} else {
this.showLibraries();
this.dom.libraryBtn.classList.remove('nav-clicked');
void this.dom.libraryBtn.offsetWidth;
this.dom.libraryBtn.classList.add('nav-clicked');
setTimeout(() => this.dom.libraryBtn.classList.remove('nav-clicked'), 1000);
}
} else if (lowKey === 'i') {
const modal = document.getElementById('profilePage');
if (modal && modal.classList.contains('active')) {
this.toggleModal('profilePage', false);
} else {
this.toggleModal('profilePage', true);
this.dom.myBtn.classList.remove('nav-clicked');
void this.dom.myBtn.offsetWidth;
this.dom.myBtn.classList.add('nav-clicked');
setTimeout(() => this.dom.myBtn.classList.remove('nav-clicked'), 1000);
}
} else if (lowKey === 'v') {
// 格子视图模式下屏蔽流媒体详情快捷键
const isGridActive = document.getElementById('app').classList.contains('grid-active');
if (!isGridActive) {
if (!this.dom.mediaInfoModal.classList.contains('hidden')) {
this.toggleModal('mediaInfoModal', false);
} else {
this.showMediaInfo();
}
}
} else if (lowKey === 'u') {
this.handleDoubleTap(window.innerWidth / 2, window.innerHeight / 2);
} else if (lowKey === 'm') {
this.dom.muteBtn.click();
}
}
});
}
bindGestures(container) {
let state = 'IDLE'; // IDLE, SPEED, DRAG
let x0 = 0, y0 = 0;
let t0 = 0;
let longPressTimer = null;
let clickTimer = null;
let clickCount = 0;
let targetTime = 0;
let originalTime = 0;
let gestureActive = false; // 防止右侧工具栏误触活动手势状态机
// CSS 锁死: touch-action: none 禁止浏览器抢占所有方向的手势
// 不用 pan-y,否则 iOS 会在垂直 swipe 时触发 touchcancel,导致切换视频失效
container.style.touchAction = 'none';
container.style.userSelect = 'none';
container.style.webkitUserSelect = 'none';
const speedToast = document.createElement('div');
speedToast.className = 'absolute top-[calc(env(safe-area-inset-top)+20px)] left-1/2 -translate-x-1/2 bg-black/50 backdrop-blur-sm text-white px-4 py-2 rounded-full text-sm font-bold opacity-0 transition-opacity z-50 pointer-events-none flex items-center gap-2';
speedToast.innerHTML = '<i data-lucide="fast-forward" class="w-4 h-4 text-green-400"></i><span>2 倍速播放中</span>';
document.getElementById('app').appendChild(speedToast);
lucide.createIcons();
const startHandler = (e) => {
if (this.state.viewMode !== 'stream') { gestureActive = false; return; }
// 黑名单:点击这些 UI 元素时不触发手势(播放/暂停/滑动)
const excluded = [
'.bottom-nav', // 底部导航栏
'.bottom-info', // 底部进度条+标题区域
'.right-toolbar', // 右侧收藏/缩放/静音
'#fullscreenBtn', // 右上角全屏按钮
'#deleteBtn', // 左上角删除按钮
'#libraryModal', // 媒体源选择弹窗
'#mediaInfoModal',// 流媒体信息弹窗
'#deleteConfirmModal', // 删除确认弹窗
'.profile-layer', // 个人中心全屏面板
];
if (excluded.some(sel => e.target.closest(sel))) {
gestureActive = false;
return;
}
gestureActive = true;
state = 'IDLE';
// 注释:用户触摸屏幕开始手势时不再重置计时器,
// 只有在 endHandler 中确认为有效交互(点击/进度拖拽)才重置。
// 这样可以避免“上下滑动切换视频”也会不间断刷新清屏计时。
const t = e.touches ? e.touches[0] : e;
x0 = t.clientX;
y0 = t.clientY;
t0 = Date.now();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) originalTime = v.currentTime;
clearTimeout(longPressTimer);
longPressTimer = setTimeout(() => {
if (state === 'IDLE') {
state = 'SPEED';
if (v) v.playbackRate = 2.0;
speedToast.classList.remove('opacity-0');
}
}, 300);
};
const moveHandler = (e) => {
if (!gestureActive || this.state.viewMode !== 'stream') return;
const t = e.touches ? e.touches[0] : e;
const diffX = t.clientX - x0;
const diffY = t.clientY - y0;
// 如果横向滑动大于纵向滑动,拦截系统返回手势
if (Math.abs(diffX) > Math.abs(diffY)) {
if (e.cancelable) e.preventDefault();
}
if (Math.abs(diffX) > 15 || Math.abs(diffY) > 15) {
clearTimeout(longPressTimer);
if (state === 'SPEED') {
speedToast.classList.add('opacity-0');
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) v.playbackRate = 1.0;
}
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 15) {
state = 'DRAG';
}
}
if (state === 'DRAG') {
if (e.cancelable) e.preventDefault();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v && v.duration) {
const ratio = diffX / (window.innerWidth / 2);
targetTime = originalTime + (ratio * v.duration);
targetTime = Math.max(0, Math.min(targetTime, v.duration));
// 仅更新UI预览,不修改 currentTime 防止卡顿
const progressEl = document.getElementById('progressLine');
if (progressEl) progressEl.style.width = `${(targetTime / v.duration) * 100}%`;
}
}
};
const endHandler = (e) => {
if (!gestureActive || this.state.viewMode !== 'stream') return;
gestureActive = false;
clearTimeout(longPressTimer);
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (state === 'SPEED') {
if (v) v.playbackRate = 1.0;
speedToast.classList.add('opacity-0');
state = 'IDLE';
// 结束倍速播放属于有效交互,重置/开始清屏计时
this.showInterfaceTemp();
return;
}
if (state === 'DRAG') {
if (v) v.currentTime = targetTime;
state = 'IDLE';
// 结束进度拖拽属于有效交互,重置/开始清屏计时
this.showInterfaceTemp();
return;
}
state = 'IDLE';
const t = e.changedTouches ? e.changedTouches[0] : e;
const diffX = t.clientX - x0;
const diffY = t.clientY - y0;
const time = Date.now() - t0;
if (Math.abs(diffY) > 50 && Math.abs(diffY) > Math.abs(diffX)) {
diffY < 0 ? this.nextVideo() : this.prevVideo();
return;
}
if (Math.abs(diffX) < 10 && Math.abs(diffY) < 10 && time < 300) {
clickCount++;
if (clickCount === 1) {
clickTimer = setTimeout(() => {
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
} else {
this.togglePlay();
}
clickCount = 0;
}, 250);
} else if (clickCount === 2) {
clearTimeout(clickTimer);
clickCount = 0;
this.handleDoubleTap(t.clientX, t.clientY);
}
}
};
// 屏蔽选中和长按菜单
container.style.userSelect = 'none';
container.style.webkitUserSelect = 'none';
// 将事件绑定转移到触控遮罩层 (.touch-layer) 或容器,并通过事件拦截进一步确保右键菜单不弹出
container.addEventListener('contextmenu', (e) => {
// 如果是在视频区域触发的长按,禁止默认菜单
if (e.target.closest('.touch-layer') || e.target.tagName.toLowerCase() === 'video') {
e.preventDefault();
}
});
// 直接绑定到 container,让 startHandler 内部的排除逻辑自行处理
container.addEventListener('touchstart', startHandler, { passive: true });
container.addEventListener('touchmove', moveHandler, { passive: false });
// 记录最后一次 touchend 的时间,用于过滤手机浏览器合成的鼠标事件
let lastTouchEnd = 0;
container.addEventListener('touchend', (e) => {
lastTouchEnd = Date.now();
endHandler(e);
});
container.addEventListener('touchcancel', () => { gestureActive = false; state = 'IDLE'; clearTimeout(longPressTimer); speedToast.classList.add('opacity-0'); });
// 鼠标适配层 (PC 端)。如果距上次 touchend < 500ms,则是手机浏览器合成事件,直接忽略
let isMouseDown = false;
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (Date.now() - lastTouchEnd < 500) return; // 屏蔽手机合成鼠标事件
isMouseDown = true;
e.touches = [{ clientX: e.clientX, clientY: e.clientY }];
startHandler(e);
});
container.addEventListener('mousemove', (e) => {
if (!isMouseDown) return;
e.touches = [{ clientX: e.clientX, clientY: e.clientY }];
moveHandler(e);
});
const mouseEndHandler = (e) => {
if (!isMouseDown) return;
isMouseDown = false;
e.changedTouches = [{ clientX: e.clientX, clientY: e.clientY }];
endHandler(e);
};
container.addEventListener('mouseup', mouseEndHandler);
container.addEventListener('mouseleave', mouseEndHandler);
}
// 新增: 双击点赞动画与收藏逻辑
async handleDoubleTap(x, y) {
// 1. 点赞动画 DOM 生成
const heart = document.createElement('div');
heart.innerHTML = '<i data-lucide="heart" class="fill-red-500 text-red-500 w-16 h-16 drop-shadow-xl"></i>';
heart.className = 'absolute pointer-events-none z-50 transform pointer-events-none transition-all duration-700 ease-out';
// 随机偏转角度
const rotation = Math.floor(Math.random() * 40) - 20;
heart.style.left = `${x - 32}px`;
heart.style.top = `${y - 32}px`;
heart.style.transform = `scale(0.5) rotate(${rotation}deg)`;
heart.style.opacity = '0';
document.getElementById('app').appendChild(heart);
lucide.createIcons({ root: heart });
// 动画序列
requestAnimationFrame(() => {
heart.style.transform = `scale(1.2) rotate(${rotation}deg)`;
heart.style.opacity = '1';
setTimeout(() => {
heart.style.transform = `scale(1) rotate(${rotation}deg) translateY(-100px)`;
heart.style.opacity = '0';
setTimeout(() => heart.remove(), 700);
}, 200);
});
// 2. 自动标记收藏(如未收藏则添加)
const currentVideo = this.state.videos[this.state.currentIndex];
if (currentVideo && !currentVideo.UserData?.IsFavorite) {
try {
const { server, token, userId } = this.config;
const res = await fetch(`${server}/emby/Users/${userId}/FavoriteItems/${currentVideo.Id}?api_key=${token}`, { method: 'POST' });
if (res.ok) {
if (!currentVideo.UserData) currentVideo.UserData = {};
currentVideo.UserData.IsFavorite = true;
// 同步到本地 Set
this.state.favorites.add(String(currentVideo.Id));
// 高亮右侧收藏图标
const favBtn = document.getElementById('favoriteBtn');
if (favBtn) {
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="folder-heart" class="text-secondary w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons({ root: favBtn });
}
this.showToast('❤️ 已收藏');
}
} catch (e) { console.error('Double tap favorite failed:', e); }
}
}
// --- 通用辅助 ---
toggleModal(id, show) {
const el = document.getElementById(id);
if (id === 'profilePage') {
if (show) { el.classList.add('active'); this.state.currentTab = 'my'; }
else { el.classList.remove('active'); }
if (show) this.updateTabHighlight();
} else {
if (show) el.classList.remove('hidden'); else el.classList.add('hidden');
}
// 遥控器优化:打开时自动聚焦到已选中项,如没有已选中则聚焦到第一个
if (show) {
setTimeout(() => {
const allItems = Array.from(el.querySelectorAll('.focusable-item'));
// 优先已选中项(带主题色背景),避免从第一项闪现的过渡
const selected = allItems.find(i => i.classList.contains('bg-primary/20'));
const target = selected || allItems[0];
if (target) target.focus();
}, 300);
} else {
if (document.activeElement && document.activeElement.closest(`#${id}`)) {
document.activeElement.blur();
}
}
// 核心逻辑:无论打开还是关闭弹窗,都调用 showInterfaceTemp
this.showInterfaceTemp();
}
toggleLoading(show) {
const el = this.dom.loading;
if (show) {
el.classList.remove('hidden');
// 加载中强制隐藏当前播放按钮,避免状态冲突导致的闪烁
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const btn = document.getElementById(`playBtn-${slideIdx}`);
if (btn) btn.classList.remove('paused');
} else {
el.classList.add('hidden');
}
}
showToast(msg, duration = 3000) {
// 清除上一个计时器,防止快速连发 Toast 时提前消失
// duration: 显示时长(ms),默认 3000。重要提示可传更大值,如 5000
if (this._toastTimer) clearTimeout(this._toastTimer);
const t = this.dom.toast;
t.textContent = msg;
t.classList.remove('opacity-0');
this._toastTimer = setTimeout(() => t.classList.add('opacity-0'), duration);
}
formatTime(s) {
if (!s) return '00:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
}
window.onload = () => {
lucide.createIcons();
window.app = new EmbyApp();
// 注册 Service Worker 以满足安卓 PWA 安装要求
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js')
.then(reg => console.log('SW Registered', reg))
.catch(err => console.log('SW Failed', err));
}
};
</script>
</body>
</html>
<!--
End of EmbyX.
探索更多:谢週五の藏经阁 (https://5nav.eu.org)
Keep Coding, Keep Fun.
-->