Files
embyx/index.html
T
2026-02-27 22:11:01 +08:00

1614 lines
82 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.
<!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>