1614 lines
82 KiB
HTML
1614 lines
82 KiB
HTML
<!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: '#25f4ee', // 抖音蓝
|
||
dark: '#161823',
|
||
light: '#f8f9fa'
|
||
},
|
||
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: h-screen 改为 h-[100dvh] 适配移动端动态高度 */
|
||
.slide-container {
|
||
@apply relative w-screen h-[100dvh] overflow-hidden bg-black;
|
||
}
|
||
|
||
/* 单个视频卡片 */
|
||
.slide-item {
|
||
@apply absolute top-0 left-0 w-full h-full z-10;
|
||
}
|
||
|
||
/* 播放/暂停按钮动画 - 修复:提高 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 {
|
||
@apply opacity-0 pointer-events-none transition-opacity duration-300;
|
||
}
|
||
|
||
/* “我的”页面层级过渡 */
|
||
.profile-layer {
|
||
@apply fixed top-0 left-0 w-full h-full bg-black/60 backdrop-blur-md z-[60] translate-y-full transition-transform duration-300 ease-in-out overflow-y-auto pb-[env(safe-area-inset-bottom)];
|
||
}
|
||
.profile-layer.active {
|
||
@apply translate-y-0;
|
||
}
|
||
|
||
/* 进度条 */
|
||
.progress-line {
|
||
@apply h-full bg-white transition-[width] duration-100 ease-linear;
|
||
}
|
||
}
|
||
</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">
|
||
<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-32 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>
|
||
|
||
<div class="absolute top-4 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>
|
||
|
||
<div class="absolute top-4 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/60 rounded-2xl p-6 w-[85%] max-w-sm max-h-[80vh] overflow-y-auto border border-gray-700/50 shadow-2xl backdrop-blur-md">
|
||
<h3 class="text-white text-lg font-bold mb-4 text-center">
|
||
流媒体信息
|
||
</h3>
|
||
<div id="mediaInfoContent" class="space-y-3 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
|
||
class="bottom-info absolute bottom-16 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-[env(safe-area-inset-bottom)]">
|
||
<div class="flex justify-around items-center h-16">
|
||
<div id="viewModeBtn"
|
||
class="flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer">
|
||
<i data-lucide="layout-grid" class="stroke-current w-5 h-5"></i>
|
||
</div>
|
||
<div id="playModeBtn"
|
||
class="flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer">
|
||
<i data-lucide="repeat" class="stroke-current w-5 h-5"></i>
|
||
</div>
|
||
<div id="libraryBtn"
|
||
class="flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer">
|
||
<i data-lucide="folder-open" class="stroke-current w-5 h-5"></i>
|
||
</div>
|
||
<div id="myBtn" class="flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer">
|
||
<i data-lucide="user-round" class="stroke-current w-5 h-5"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="profilePage" class="profile-layer text-white">
|
||
<div
|
||
class="border-b border-gray-700/50 bg-black/40 backdrop-blur-md sticky top-0 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>
|
||
<button id="closeProfileBtn"
|
||
class="absolute text-gray-400 right-4 w-10 h-10 flex items-center justify-center active:text-gray-300">
|
||
<i data-lucide="chevron-down" class="w-6 h-6"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-4">
|
||
<div class="mb-6">
|
||
<h2 class="text-primary font-bold text-sm mb-4 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://10.1.1.3:8096"
|
||
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">
|
||
</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">
|
||
</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">
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center space-x-2 py-1">
|
||
<input type="checkbox" id="configStatic"
|
||
class="rounded text-primary focus:ring-0 bg-gray-700 border-none" checked>
|
||
<label for="configStatic" class="text-gray-400 text-xs">直接播放(不转码)</label>
|
||
</div>
|
||
<div class="flex items-center space-x-2 py-1">
|
||
<input type="checkbox" id="configAutoplay"
|
||
class="rounded text-primary focus:ring-0 bg-gray-700 border-none" checked>
|
||
<label for="configAutoplay" class="text-gray-400 text-xs">自动连播</label>
|
||
</div>
|
||
<div class="flex items-center space-x-2 py-1">
|
||
<input type="checkbox" id="configDeleteMode"
|
||
class="rounded text-primary focus:ring-0 bg-gray-700 border-none">
|
||
<label for="configDeleteMode" class="text-yellow-500 text-xs">管理员模式(⚠️可删除原文件)</label>
|
||
</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">重置</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">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="h-px bg-gray-800 w-full my-6"></div>
|
||
|
||
<div>
|
||
<h2 class="text-primary font-bold text-sm mb-4 flex items-center">
|
||
<i data-lucide="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">💡 简介:这是一个技术小白借助 AI 代码工具和 Emby 官方 API 制作的 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="alert-triangle" 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">
|
||
<li>苹果 Safari 浏览器【分享-添加到主屏幕】,即可实现 PWA 全屏应用。</li>
|
||
<li>安卓 Chrome/Edge 浏览器,请直接使用全屏按钮。</li>
|
||
<li>如需自动化、批量下载视频,推荐搭配 <code
|
||
class="bg-gray-700 px-1 rounded text-xs text-gray-200">yt-dlp</code>、<code
|
||
class="bg-gray-700 px-1 rounded text-xs text-gray-200">gallery-dl</code>、<code
|
||
class="bg-gray-700 px-1 rounded text-xs text-gray-200">Douyin_TikTok_Download_API</code>
|
||
等开源项目。</li>
|
||
<li>本人另有独家适配的苹果、安卓快捷指令,可一键下载视频到 NAS,然后实现 Emby 自动入库。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="pt-4 border-t border-gray-700/50 text-xs text-gray-500 flex flex-col space-y-1">
|
||
<p>👨🏻💻 数字名片:<a href="https://juneix.github.io" target="_blank"
|
||
class="text-primary hover:underline">@谢週五</a></p>
|
||
<p>🏠 更多内容:<a href="https://5nav.eu.org" target="_blank"
|
||
class="text-primary hover:underline">谢週五の藏经阁</a></p>
|
||
</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/60 rounded-xl p-5 w-[90%] max-w-lg max-h-[70vh] overflow-y-auto border border-gray-700/50 shadow-2xl backdrop-blur-md">
|
||
<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">关闭</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"
|
||
class="fixed top-16 left-1/2 -translate-x-1/2 bg-black/80 px-4 py-2 rounded-full text-white text-sm pointer-events-none transition-opacity duration-300 opacity-0 z-[100] whitespace-nowrap 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', // sequence | random | favorites
|
||
viewMode: 'stream', // stream | grid
|
||
isAutoplay: true, // 默认开启自动连播
|
||
isClearScreen: false,
|
||
currentTab: 'home', // home | favorites | my
|
||
currentLibraryId: null,
|
||
isScaleFill: true, // 视频缩放状态
|
||
isMuted: false // 全局静音状态
|
||
};
|
||
|
||
// 配置信息修改点:添加 user, token, userId
|
||
this.config = { server: '', user: '', token: '', userId: '', useStatic: true, autoplay: true, deleteMode: false };
|
||
|
||
// 触摸状态缓存
|
||
this.touch = { startY: 0, startTime: 0 };
|
||
|
||
// 计时器引用
|
||
this.csTimer = null;
|
||
|
||
this.dom = {}; // DOM 元素缓存
|
||
this.init();
|
||
}
|
||
|
||
// --- 初始化 ---
|
||
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',
|
||
'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) {
|
||
this.dom.configPwd.value = '****';
|
||
}
|
||
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 })
|
||
});
|
||
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 };
|
||
|
||
// 更新密码框为掩码
|
||
this.dom.configPwd.value = '****';
|
||
|
||
// 更新删除按钮显示状态
|
||
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) {
|
||
this.showToast('无法连接服务器');
|
||
} 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) {
|
||
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;
|
||
|
||
let url = `${server}/emby/Users/${userId}/Items?api_key=${token}&Recursive=true&IncludeItemTypes=Video&Limit=100&StartIndex=${this.state.startIndex || 0}&Fields=Overview,Path,RunTimeTicks`;
|
||
|
||
if (ids) {
|
||
url += `&Ids=${ids}`;
|
||
} else if (libraryId === 'favorites') {
|
||
url += `&SortBy=DateCreated&SortOrder=Descending&Filters=IsFavorite`;
|
||
} else {
|
||
url += `&SortBy=DateCreated&SortOrder=Descending`;
|
||
if (libraryId) {
|
||
url += `&ParentId=${libraryId}`;
|
||
}
|
||
}
|
||
|
||
const res = await fetch(url);
|
||
if (!res.ok) {
|
||
throw new Error(`服务器错误: ${res.status}`);
|
||
}
|
||
let data = await res.json();
|
||
|
||
if (data && data.Items && data.Items.length > 0) {
|
||
if (this.state.playMode === 'random') {
|
||
data.Items = data.Items.sort(() => Math.random() - 0.5);
|
||
}
|
||
this.state.videos = data.Items;
|
||
this.state.currentIndex = 0;
|
||
|
||
if (this.state.viewMode === 'grid') {
|
||
this.renderGridView();
|
||
} else {
|
||
this.renderSlides();
|
||
this.loadVideo(0);
|
||
}
|
||
} else if (isLoadMore) {
|
||
this.showToast('已循环回到头部');
|
||
this.state.startIndex = 0;
|
||
this.fetchVideos(parentId, ids, false);
|
||
} 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';
|
||
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">
|
||
<!-- 高斯模糊背景支持原始比例视频 -->
|
||
<div id="poster-${i}" class="absolute inset-0 bg-cover bg-center opacity-70 scale-110 blur-xl saturate-150 transition-opacity duration-300 ${this.state.isScaleFill ? 'hidden' : ''}"></div>
|
||
<div class="absolute inset-0 bg-black/40 ${this.state.isScaleFill ? 'hidden' : ''}"></div>
|
||
|
||
<video id="video-p${i}" class="video-fullscreen ${this.state.isScaleFill ? 'object-cover' : 'object-contain'} hidden" playsinline webkit-playsinline></video>
|
||
<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;
|
||
container.innerHTML = '';
|
||
// 完全重置为绝对定位的、可滚动的原生网格层
|
||
container.className = 'absolute inset-0 z-10 bg-black/40 overflow-hidden flex flex-col';
|
||
container.style.transform = 'none';
|
||
|
||
// 背景海报模糊层
|
||
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-110 blur-xl saturate-150 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) { }
|
||
}
|
||
}
|
||
|
||
let countStr = this.state.videos.length === 100 ? '99+' : this.state.videos.length;
|
||
safeHeader.className = 'w-full pt-[calc(env(safe-area-inset-top)+8px)] bg-black/40 backdrop-blur-md pb-3 px-3 border-b border-gray-800/50 flex items-center gap-2';
|
||
safeHeader.innerHTML = `<span class="text-sm font-bold text-gray-200">${libraryName}</span><span class="text-xs text-primary/90 font-medium flex items-center gap-1">${countStr}${this.state.videos.length >= 100 ? `<button id="gridRefreshBtn" class="active:scale-90 transition-transform p-[2px] bg-white/10 rounded-full"><i data-lucide="sparkles" class="w-3.5 h-3.5 text-secondary"></i></button>` : ''}</span>
|
||
`;
|
||
container.appendChild(safeHeader);
|
||
|
||
if (this.state.videos.length >= 100) {
|
||
const refBtn = document.getElementById('gridRefreshBtn');
|
||
if (refBtn) {
|
||
refBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.state.startIndex = (this.state.startIndex || 0) + 100;
|
||
this.fetchVideos(null, null, true);
|
||
};
|
||
}
|
||
}
|
||
|
||
// 创建专门的内部滚动容器
|
||
const scrollArea = document.createElement('div');
|
||
scrollArea.className = 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden relative w-full pb-32 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.className = `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;
|
||
img.src = this.getImageSrc(null);
|
||
};
|
||
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);
|
||
});
|
||
|
||
// 取消原来的清空逻辑,恢复底层信息显示以便能看到网格状态或当前焦点视频
|
||
const focusVideo = this.state.videos[this.state.currentIndex];
|
||
if (focusVideo) {
|
||
this.dom.videoTitle.textContent = focusVideo.Name || '未知视频';
|
||
this.dom.videoDescription.textContent = focusVideo.Overview || '没有简介...';
|
||
}
|
||
|
||
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 posterEl = document.getElementById(`poster-${slideIdx}`);
|
||
posterEl.style.backgroundImage = `url('${posterSrc}')`;
|
||
|
||
const imgLoader = new Image();
|
||
imgLoader.crossOrigin = 'anonymous';
|
||
imgLoader.referrerPolicy = 'no-referrer';
|
||
imgLoader.src = posterSrc;
|
||
imgLoader.onerror = () => {
|
||
posterEl.style.backgroundImage = `url('${this.getImageSrc(null)}')`;
|
||
};
|
||
|
||
const videoEl = document.getElementById(`video-p${slideIdx}`);
|
||
videoEl.classList.add('hidden');
|
||
videoEl.onerror = null; // 防止清空src触发旧的onerror重试机制
|
||
videoEl.removeAttribute('src'); // 使用 removeAttribute 代替 src='' 更加健壮
|
||
videoEl.load();
|
||
document.getElementById(`playBtn-${slideIdx}`).classList.remove('paused');
|
||
}
|
||
});
|
||
|
||
// 隐藏范围外的卡片
|
||
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.removeAttribute('src');
|
||
videoEl.load();
|
||
}
|
||
}
|
||
}
|
||
this.updateUI();
|
||
}
|
||
|
||
/**
|
||
* 核心修改点:适应三窗格的加载逻辑
|
||
*/
|
||
loadVideo(index) {
|
||
if (this.state.viewMode !== 'stream') return;
|
||
|
||
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;
|
||
|
||
const src = this.getVideoSrc(videoData.Id);
|
||
if (videoEl.src !== src) {
|
||
videoEl.src = src;
|
||
videoEl.load();
|
||
}
|
||
|
||
// 同步全局静音状态
|
||
videoEl.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="stroke-white w-6 h-6 drop-shadow-md"></i></div>`;
|
||
lucide.createIcons();
|
||
|
||
videoEl.classList.remove('hidden');
|
||
|
||
// 添加缓冲提示状态
|
||
videoEl.onwaiting = () => this.toggleLoading(true);
|
||
videoEl.onplaying = () => this.toggleLoading(false);
|
||
videoEl.oncanplay = () => this.toggleLoading(false);
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
videoEl.onended = () => {
|
||
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;
|
||
const v = document.getElementById(`video-p${slideIdx}`);
|
||
const btn = document.getElementById(`playBtn-${slideIdx}`);
|
||
if (v) {
|
||
const playPromise = v.play();
|
||
if (playPromise !== undefined) {
|
||
playPromise.then(() => {
|
||
this.state.isPlaying = true;
|
||
if (btn) btn.classList.remove('paused');
|
||
}).catch(error => {
|
||
console.error('播放失败:', error);
|
||
this.state.isPlaying = false;
|
||
this.toggleLoading(false);
|
||
if (btn) btn.classList.add('paused');
|
||
if (error.name === 'NotAllowedError') {
|
||
this.showToast('需要用户交互才能播放');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// 如果强制开启了不转码,则直接失败报错
|
||
let formatInfo = "未知";
|
||
if (item.MediaSources && item.MediaSources.length > 0) {
|
||
try {
|
||
formatInfo = item.MediaSources[0].MediaStreams.find(s => s.Type === 'Video')?.Codec || "未知";
|
||
} catch (e) { }
|
||
}
|
||
this.showToast(`当前设备不支持此格式 (${formatInfo})`);
|
||
return;
|
||
}
|
||
|
||
// 如果取消了"不转码"(允许转码),这里我们进行1次重试,使用转码流
|
||
if (retryCount >= 1) {
|
||
this.showToast('转码播放仍失败,格式不支持');
|
||
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');
|
||
}
|
||
}
|
||
|
||
// --- 导航与手势 ---
|
||
|
||
nextVideo() {
|
||
let nextIndex;
|
||
if (this.state.playMode === 'random') {
|
||
nextIndex = Math.floor(Math.random() * this.state.videos.length);
|
||
} else {
|
||
nextIndex = this.state.currentIndex + 1;
|
||
}
|
||
|
||
if (nextIndex >= this.state.videos.length) {
|
||
this.showToast('已经是最后一个视频了');
|
||
return;
|
||
}
|
||
this.switchSlide(nextIndex, 'up');
|
||
}
|
||
|
||
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.state.currentIndex = newIndex;
|
||
this.loadVideo(newIndex);
|
||
this.playVideo(newIndex);
|
||
}
|
||
|
||
// --- 辅助功能 ---
|
||
|
||
getVideoSrc(id) {
|
||
const { server, token, useStatic } = this.config;
|
||
// 不管配置如何,第一次请求永远优先尝试 Static 原画直连
|
||
return `${server}/emby/Videos/${id}/stream?api_key=${token}&Static=true`;
|
||
}
|
||
|
||
getImageSrc(video) {
|
||
const { server, token } = this.config;
|
||
const fallbackUrl = 'https://img.5nav.eu.org/file/AgACAgUAAxkDAAMCaaGfIOLBPKq4KFJNqL2_suU1LDQAAqgOaxv_dBFV0MGoMD85wu0BAAMCAAN5AAM6BA.jpg';
|
||
|
||
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}`);
|
||
const playlistsData = await playlistsRes.json();
|
||
|
||
const viewsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Views?api_key=${this.config.token}`);
|
||
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.className = `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.className = `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.className = `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);
|
||
const isFav = this.state.favorites.has(id);
|
||
const { server, token, userId } = this.config;
|
||
|
||
// 乐观更新
|
||
if (isFav) {
|
||
this.state.favorites.delete(id);
|
||
this.showToast('已取消收藏');
|
||
} else {
|
||
this.state.favorites.add(id);
|
||
this.showToast('收藏成功');
|
||
}
|
||
localStorage.setItem('emby_favorites', JSON.stringify([...this.state.favorites]));
|
||
this.updateUI();
|
||
|
||
// 远端同步
|
||
if (server && token && userId) {
|
||
try {
|
||
const method = isFav ? 'DELETE' : 'POST';
|
||
await fetch(`${server}/emby/Users/${userId}/FavoriteItems/${id}?api_key=${token}`, { method });
|
||
} 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');
|
||
const isFav = this.state.favorites.has(String(item.Id));
|
||
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${isFav ? 'folder-heart' : 'heart'}" class="stroke-white w-6 h-6 drop-shadow-md"></i></div>`;
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
updateTabHighlight() {
|
||
this.dom.myBtn.className = 'flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer';
|
||
this.dom.libraryBtn.className = 'flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer';
|
||
this.dom.playModeBtn.className = 'flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer';
|
||
this.dom.viewModeBtn.className = 'flex flex-col items-center justify-center w-1/4 text-gray-400 cursor-pointer';
|
||
|
||
if (this.state.currentTab === 'my') {
|
||
this.dom.myBtn.className = 'flex flex-col items-center justify-center w-1/4 text-white cursor-pointer';
|
||
} else if (this.state.currentTab === 'library') {
|
||
this.dom.libraryBtn.className = 'flex flex-col items-center justify-center w-1/4 text-white cursor-pointer';
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
const firstConfirm = confirm('确定要删除此视频吗?');
|
||
if (!firstConfirm) return;
|
||
|
||
const finalConfirm = confirm('⚠️ 警告:这将删除媒体库中的原文件!\n\n确认删除 ' + (item.Name || '此视频') + ' 吗?');
|
||
if (!finalConfirm) 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);
|
||
if (this.state.viewMode === 'grid') {
|
||
this.renderGridView();
|
||
} else {
|
||
this.renderSlides();
|
||
}
|
||
}).catch(() => {
|
||
this.showToast('删除失败');
|
||
});
|
||
}
|
||
|
||
showInterfaceTemp() {
|
||
const app = document.getElementById('app');
|
||
if (this.csTimer) clearTimeout(this.csTimer);
|
||
if (document.fullscreenElement) {
|
||
this.csTimer = setTimeout(() => {
|
||
app.classList.add('interface-hidden');
|
||
}, 3000);
|
||
}
|
||
}
|
||
|
||
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();
|
||
this.showToast(isSeq ? '已切换随机播放' : '已切换顺序播放');
|
||
}
|
||
|
||
toggleViewMode() {
|
||
if (this.state.currentLibraryType === 'favorites' || this.state.currentLibraryType === 'playlist') {
|
||
this.showToast('当前媒体源不支持切换视图');
|
||
return;
|
||
}
|
||
|
||
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');
|
||
this.renderGridView();
|
||
} else {
|
||
this.renderSlides();
|
||
this.loadVideo(this.state.currentIndex);
|
||
this.playVideo(this.state.currentIndex);
|
||
}
|
||
}
|
||
|
||
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 poster = document.getElementById(`poster-${index}`);
|
||
const overlay = v.parentElement.querySelector('.bg-black\\/40');
|
||
if (this.state.isScaleFill) {
|
||
if (poster) poster.classList.add('hidden');
|
||
if (overlay) overlay.classList.add('hidden');
|
||
} else {
|
||
if (poster) poster.classList.remove('hidden');
|
||
if (overlay) overlay.classList.remove('hidden');
|
||
}
|
||
});
|
||
|
||
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.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 w = src.MediaStreams?.find(s => s.Type === 'Video')?.Width || '?';
|
||
const h = src.MediaStreams?.find(s => s.Type === 'Video')?.Height || '?';
|
||
const vCodec = src.MediaStreams?.find(s => s.Type === 'Video')?.Codec || '未知';
|
||
const aCodec = src.MediaStreams?.find(s => s.Type === 'Audio')?.Codec || '未知';
|
||
const bitrate = src.Bitrate ? (src.Bitrate / 1000000).toFixed(2) + ' Mbps' : '未知';
|
||
const transcodeState = this.config.useStatic ? '直接播放 (强制)' : '自动转码/直推';
|
||
|
||
this.dom.mediaInfoContent.innerHTML = `
|
||
<div class="grid grid-cols-3 gap-2 py-2 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} × ${h}</span>
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-2 py-2 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 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 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 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">
|
||
<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="stroke-white w-6 h-6 drop-shadow-md"></i></div>`;
|
||
lucide.createIcons();
|
||
};
|
||
|
||
this.dom.scaleBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.toggleScaleMode();
|
||
};
|
||
|
||
this.dom.videoInfoArea.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.showMediaInfo();
|
||
};
|
||
|
||
this.dom.closeMediaInfoBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.dom.mediaInfoModal.classList.add('hidden');
|
||
};
|
||
|
||
this.dom.mediaInfoModal.onclick = (e) => {
|
||
if (e.target === e.currentTarget) {
|
||
this.dom.mediaInfoModal.classList.add('hidden');
|
||
}
|
||
};
|
||
|
||
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="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');
|
||
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);
|
||
|
||
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();
|
||
|
||
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 = this.dom.videoContainer;
|
||
container.addEventListener('touchstart', (e) => {
|
||
if (this.state.viewMode !== 'stream') return;
|
||
this.touch.startY = e.touches[0].clientY;
|
||
this.touch.startTime = Date.now();
|
||
}, { passive: true });
|
||
|
||
container.addEventListener('touchend', (e) => {
|
||
if (this.state.viewMode !== 'stream') return;
|
||
const deltaY = e.changedTouches[0].clientY - this.touch.startY;
|
||
const time = Date.now() - this.touch.startTime;
|
||
|
||
if (Math.abs(deltaY) < 10 && time < 200) {
|
||
const app = document.getElementById('app');
|
||
if (app.classList.contains('interface-hidden')) {
|
||
app.classList.remove('interface-hidden');
|
||
this.showInterfaceTemp();
|
||
return;
|
||
}
|
||
if (e.target.closest('.slide-item')) {
|
||
this.togglePlay();
|
||
}
|
||
} else if (Math.abs(deltaY) > 50) {
|
||
deltaY < 0 ? this.nextVideo() : this.prevVideo();
|
||
}
|
||
});
|
||
|
||
// PC 鼠标滑动支持
|
||
let isDragging = false;
|
||
container.addEventListener('mousedown', (e) => {
|
||
if (this.state.viewMode !== 'stream') return;
|
||
isDragging = true;
|
||
this.touch.startY = e.clientY;
|
||
this.touch.startTime = Date.now();
|
||
});
|
||
container.addEventListener('mousemove', (e) => {
|
||
if (this.state.viewMode !== 'stream') return;
|
||
if (isDragging) e.preventDefault();
|
||
});
|
||
container.addEventListener('mouseleave', () => {
|
||
isDragging = false;
|
||
});
|
||
container.addEventListener('mouseup', (e) => {
|
||
if (this.state.viewMode !== 'stream') return;
|
||
if (!isDragging) return;
|
||
isDragging = false;
|
||
const deltaY = e.clientY - this.touch.startY;
|
||
const time = Date.now() - this.touch.startTime;
|
||
|
||
if (Math.abs(deltaY) < 10 && time < 200) {
|
||
const app = document.getElementById('app');
|
||
if (app.classList.contains('interface-hidden')) {
|
||
app.classList.remove('interface-hidden');
|
||
this.showInterfaceTemp();
|
||
return;
|
||
}
|
||
if (e.target.closest('.slide-item')) {
|
||
this.togglePlay();
|
||
}
|
||
} else if (Math.abs(deltaY) > 50) {
|
||
deltaY < 0 ? this.nextVideo() : this.prevVideo();
|
||
}
|
||
});
|
||
|
||
container.addEventListener('click', (e) => {
|
||
if (this.state.viewMode !== 'stream') return;
|
||
if (!('ontouchstart' in window)) {
|
||
const app = document.getElementById('app');
|
||
if (app.classList.contains('interface-hidden')) {
|
||
app.classList.remove('interface-hidden');
|
||
this.showInterfaceTemp();
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- 通用辅助 ---
|
||
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');
|
||
}
|
||
}
|
||
|
||
toggleLoading(show) {
|
||
const el = this.dom.loading;
|
||
if (show) el.classList.remove('hidden'); else el.classList.add('hidden');
|
||
}
|
||
|
||
showToast(msg) {
|
||
const t = this.dom.toast;
|
||
t.textContent = msg;
|
||
t.classList.remove('opacity-0');
|
||
setTimeout(() => t.classList.add('opacity-0'), 2000);
|
||
}
|
||
|
||
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();
|
||
};
|
||
</script>
|
||
</body>
|
||
|
||
</html> |