Files
embyx/en/index.html
T
juneix 8a093ddae6 v1.1
2026-04-21 17:47:28 +08:00

2937 lines
157 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
EmbyX - Short Video Player for Emby
Juneix (https://github.com/juneix)
© 2026 All Rights Reserved
-->
<!DOCTYPE html>
<html lang="en">
<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="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',
secondary: '#EE3152'
},
fontFamily: {
sans: ['PingFang SC', 'Helvetica Neue', 'Arial', 'sans-serif']
}
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.video-fullscreen {
@apply w-full h-full absolute inset-0 z-10;
}
.slide-container {
@apply relative w-screen overflow-hidden bg-black;
height: 100dvh;
}
@media (display-mode: standalone) {
.slide-container {
height: 100vh;
}
}
.slide-item {
@apply absolute top-0 left-0 w-full h-full z-10 overflow-hidden;
}
.play-pause-btn {
@apply absolute top-1/2 left-1/2 w-16 h-16 rounded-full flex items-center justify-center z-20 opacity-0 pointer-events-none transition-all duration-200 ease-out bg-black/30;
transform: translate(-50%, -50%) scale(1.5);
}
.play-pause-btn.paused {
@apply opacity-100;
transform: translate(-50%, -50%) scale(1.5);
}
.play-pause-btn i {
@apply text-2xl text-white;
}
.interface-hidden .bottom-info,
.interface-hidden .bottom-nav,
.interface-hidden .right-toolbar,
.interface-hidden #fullscreenBtn,
.interface-hidden #deleteBtn {
@apply opacity-0 pointer-events-none transition-opacity duration-300;
}
.grid-active .bottom-info,
.grid-active .right-toolbar,
.grid-active #fullscreenBtn,
.grid-active #deleteBtn {
@apply opacity-0 pointer-events-none transition-opacity duration-300;
}
.focusable-item:focus {
@apply outline-none ring-2 ring-primary ring-offset-2 ring-offset-black z-20;
}
.profile-layer {
@apply fixed top-0 left-0 w-full h-full z-[60] translate-y-full transition-transform duration-300 ease-in-out;
}
.profile-layer.active {
@apply translate-y-0;
}
.progress-line {
@apply h-full bg-white transition-[width] duration-[250ms] ease-linear;
}
.nav-btn {
@apply relative flex flex-col items-center justify-center w-1/4 h-full cursor-pointer transition-colors duration-300 z-10;
-webkit-tap-highlight-color: transparent;
}
.nav-btn::before {
content: '';
@apply absolute top-1/2 left-1/2 w-[3.5rem] h-8 rounded-full bg-primary/30 opacity-0 pointer-events-none transition-all duration-500 ease-out z-[-1];
transform: translate(-50%, -50%) scale(0.5);
backdrop-filter: blur(8px);
}
.nav-btn.nav-clicked::before {
@apply opacity-100;
transform: translate(-50%, -50%) scale(1);
}
.nav-btn.nav-clicked {
color: theme('colors.primary') !important;
}
.nav-btn.nav-clicked svg {
filter: drop-shadow(0 0 6px rgba(82, 181, 75, 0.6));
}
/* Hide ugly default play button in some Android WebViews */
video::-webkit-media-controls-start-playback-button {
display: none !important;
-webkit-appearance: none;
}
video::-webkit-media-controls {
display: none !important;
}
}
</style>
</head>
<body class="bg-black text-white overflow-hidden font-sans select-none overscroll-none">
<div id="app" class="slide-container">
<div id="videoContainer" class="relative w-full h-full">
</div>
<div id="clickToPlayOverlay"
class="absolute inset-0 z-50 flex items-center justify-center bg-black/40 cursor-pointer"
style="display:none;">
<div class="text-center">
<div class="w-20 h-20 bg-black/50 rounded-full flex items-center justify-center animate-pulse mx-auto">
<i data-lucide="play" class="text-white text-3xl"></i>
</div>
<p class="text-white text-lg mt-4 font-medium">Tap to Play</p>
</div>
</div>
<div
class="right-toolbar absolute right-4 bottom-40 flex flex-col items-center space-y-6 z-30 transition-opacity duration-300">
<div class="flex flex-col items-center cursor-pointer active:scale-90 transition-transform"
id="favoriteBtn">
<div class="w-12 h-12 flex items-center justify-center">
<i data-lucide="heart" class="stroke-white w-6 h-6 drop-shadow-md"></i>
</div>
</div>
<div class="flex flex-col items-center cursor-pointer active:scale-90 transition-transform" id="scaleBtn">
<div class="w-12 h-12 flex items-center justify-center">
<i data-lucide="maximize-2" class="stroke-white w-6 h-6 drop-shadow-md"></i>
</div>
</div>
<div class="flex flex-col items-center cursor-pointer active:scale-90 transition-transform" id="muteBtn">
<div class="w-12 h-12 flex items-center justify-center">
<i data-lucide="volume-2" class="stroke-white w-6 h-6 drop-shadow-md"></i>
</div>
</div>
</div>
<div class="absolute top-[calc(10px+env(safe-area-inset-top))] left-4 z-30 transition-opacity duration-300"
id="deleteBtn">
<div
class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full">
<i data-lucide="trash-2" class="stroke-white w-5 h-5 drop-shadow-md"></i>
</div>
</div>
<div class="absolute top-[calc(10px+env(safe-area-inset-top))] right-4 z-30 transition-opacity duration-300"
id="fullscreenBtn">
<div
class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full">
<i data-lucide="maximize" class="stroke-white w-5 h-5 drop-shadow-md"></i>
</div>
</div>
<div id="mediaInfoModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/20 hidden backdrop-blur-sm transition-opacity duration-300">
<div
class="bg-black/50 rounded-2xl p-6 w-[85%] max-w-sm max-h-[80vh] overflow-y-auto border border-gray-700/50 shadow-2xl backdrop-blur-sm">
<h3 class="text-white text-lg font-bold mb-4 text-center">
Media Info
</h3>
<div id="mediaInfoContent" class="text-sm text-gray-300">
<div class="flex justify-center py-4"><i data-lucide="loader-2"
class="w-6 h-6 animate-spin text-gray-500"></i></div>
</div>
<button id="closeMediaInfoBtn"
class="w-full mt-6 bg-gray-800/80 border border-gray-700/50 text-gray-200 py-2.5 rounded-lg font-medium hover:bg-gray-700 active:scale-95 transition-all">Close</button>
</div>
</div>
<div id="deleteConfirmModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 hidden backdrop-blur-sm transition-opacity duration-300">
<div
class="bg-black/50 rounded-2xl p-6 w-[85%] max-w-sm border border-gray-700/50 shadow-2xl backdrop-blur-sm text-center">
<div
class="w-14 h-14 bg-secondary/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-secondary/20">
<i data-lucide="trash-2" class="text-secondary w-7 h-7"></i>
</div>
<h3 class="text-white text-lg font-bold mb-2">Delete Media</h3>
<p class="text-secondary text-xs font-semibold px-4 mb-4 leading-relaxed">⚠️ Warning: This will
permanently delete the source file from Emby!</p>
<div class="bg-gray-800/40 rounded-lg p-3 mb-6 border border-gray-700/30">
<p id="deleteFileName" class="text-gray-300 text-xs break-all opacity-80"></p>
</div>
<div class="flex space-x-3">
<button id="cancelDeleteBtn"
class="flex-1 bg-gray-800/80 border border-gray-700/50 text-gray-300 py-2.5 rounded-lg font-medium active:scale-95 transition-all">Cancel</button>
<button id="confirmDeleteBtn"
class="flex-1 bg-secondary text-white py-2.5 rounded-lg font-bold active:opacity-90 shadow-lg shadow-secondary/20 transition-all">Delete</button>
</div>
</div>
</div>
<div
class="bottom-info absolute bottom-20 left-4 right-4 z-20 transition-opacity duration-300 pointer-events-none">
<div id="videoInfoArea"
class="mb-3 pointer-events-auto w-[75%] pr-4 cursor-pointer active:opacity-75 transition-opacity">
<h3 id="videoTitle" class="text-white text-lg font-medium mb-1 drop-shadow-md truncate">Initializing...
</h3>
<p id="videoDescription" class="text-gray-200 text-sm line-clamp-2 drop-shadow-md opacity-90">
Click the 'Profile' button at bottom right to configure server.</p>
</div>
<div class="relative h-1 bg-white/30 cursor-pointer pointer-events-auto mb-1" id="progressBarContainer">
<div id="progressLine" class="progress-line w-0"></div>
</div>
<div class="flex justify-between text-xs text-gray-300 drop-shadow">
<span id="currentTime">00:00</span>
<span id="totalTime">00:00</span>
</div>
</div>
<div
class="bottom-nav absolute bottom-0 left-0 right-0 bg-black/50 backdrop-blur-sm z-30 transition-opacity duration-300 pb-2 pt-1">
<div class="flex justify-around items-center h-12">
<div id="viewModeBtn" class="nav-btn text-gray-400">
<i data-lucide="layout-grid" class="stroke-current w-5 h-5"></i>
</div>
<div id="playModeBtn" class="nav-btn text-gray-400">
<i data-lucide="repeat" class="stroke-current w-5 h-5"></i>
</div>
<div id="libraryBtn" class="nav-btn text-gray-400">
<i data-lucide="folder-open" class="stroke-current w-5 h-5"></i>
</div>
<div id="myBtn" class="nav-btn text-gray-400">
<i data-lucide="user-round" class="stroke-current w-5 h-5"></i>
</div>
</div>
</div>
<div id="profilePage" class="profile-layer text-white">
<div
class="absolute top-[calc(3.5rem+env(safe-area-inset-top))] bottom-0 left-0 w-full bg-black/50 backdrop-blur-sm pointer-events-none -z-10">
</div>
<div
class="absolute top-0 left-0 w-full h-[calc(3.5rem+env(safe-area-inset-top))] border-b border-gray-700/50 bg-black/50 backdrop-blur-sm z-10 pt-[env(safe-area-inset-top)]">
<div class="flex items-center justify-center h-14 px-4 relative">
<span class="font-bold text-lg">Profile</span>
<button id="closeProfileBtn"
class="absolute text-gray-400 right-5 w-10 h-10 flex items-center justify-center active:text-gray-300 focusable-item rounded-full">
<i data-lucide="chevron-down" class="w-6 h-6"></i>
</button>
</div>
</div>
<div class="absolute inset-0 overflow-y-auto overflow-x-hidden z-0">
<div class="pt-[calc(3.5rem+env(safe-area-inset-top))] p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
<div class="mb-5 mt-5">
<h2 class="text-primary font-bold text-sm mb-5 flex items-center">
<i data-lucide="server" class="w-4 h-4 mr-2"></i>Server Config
</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">Server URL</label>
<input type="text" id="configServer" placeholder="Auto-completion of IP or domain name"
class="w-full bg-gray-900 text-white text-sm rounded px-3 py-2.5 outline-none border border-gray-700 focus:border-primary transition-colors focusable-item">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-gray-400 text-xs block mb-1.5">Username</label>
<input type="text" id="configUser" placeholder="Username"
class="w-full bg-gray-900 text-white text-sm rounded px-3 py-2.5 outline-none border border-gray-700 focus:border-primary transition-colors focusable-item">
</div>
<div>
<label class="text-gray-400 text-xs block mb-1.5">Password</label>
<input type="password" id="configPwd" placeholder="Password"
class="w-full bg-gray-900 text-white text-sm rounded px-3 py-2.5 outline-none border border-gray-700 focus:border-primary transition-colors focusable-item">
</div>
</div>
<div id="rowStatic" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configStatic').click();">
<label for="configStatic" class="text-gray-300 text-sm cursor-pointer flex-1">Direct
Play (No Transcoding)</label>
<input type="checkbox" id="configStatic"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none"
checked>
</div>
<div id="rowAutoplay" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configAutoplay').click();">
<label for="configAutoplay" class="text-gray-300 text-sm cursor-pointer flex-1">Auto
Play Next</label>
<input type="checkbox" id="configAutoplay"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none"
checked>
</div>
<div id="rowShortDrama" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configShortDrama').click();">
<label for="configShortDrama" class="text-gray-300 text-sm cursor-pointer flex-1">Short
Drama Mode</label>
<input type="checkbox" id="configShortDrama"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none">
</div>
<div id="rowDeleteMode" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configDeleteMode').click();">
<label for="configDeleteMode"
class="text-yellow-500/90 text-sm cursor-pointer flex-1 font-medium">Enable Media
Deletion
⚠️</label>
<input type="checkbox" id="configDeleteMode"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none">
</div>
<div class="flex space-x-3 pt-2">
<button id="resetConfigBtn"
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white text-sm py-3 rounded-lg active:scale-95 transition-transform focusable-item">Reset</button>
<button id="saveConfigBtn"
class="flex-1 bg-primary hover:bg-primary/90 text-white text-sm py-3 rounded-lg font-bold active:opacity-80 shadow-lg shadow-primary/20 focusable-item">Save</button>
</div>
</div>
</div>
<!-- Divider -->
<div class="border-t border-dashed border-gray-600 w-full mt-5 mb-5">
</div>
<div>
<h2 class="text-primary font-bold text-sm mb-5 flex items-center">
<i data-lucide="badge-info" class="w-4 h-4 mr-2"></i>About
</h2>
<div
class="bg-gray-800/40 p-5 rounded-xl border border-gray-700/50 space-y-5 text-sm text-gray-300 leading-relaxed">
<div class="flex items-center">
<h3 class="text-white font-bold text-2xl tracking-tight">📱 EmbyX</h3>
<span
class="ml-2 px-2 py-0.5 bg-primary/20 text-primary text-xs font-bold rounded-full border border-primary/30">v1.1</span>
</div>
<div>
<p class="text-gray-300">👋 Hi, I am <a href="https://github.com/juneix" target="_blank"
class="text-primary hover:underline">@Juneix</a>. EmbyX is a TikTok-style web
player for Emby / Jellyfin. Experience your private media library in a whole new,
immersive way. 🎉</p>
</div>
<div
class="text-yellow-500/90 text-xs bg-yellow-500/10 p-3 rounded-lg border border-yellow-500/20">
<i data-lucide="shield-check" class="w-3 h-3 mr-1 inline"></i>
All data stays on your device. Free to use.
</div>
<div>
<h4 class="text-gray-300">🔮 Playback</h4>
<ul class="list-disc list-inside space-y-1 text-gray-400 text-xs mt-2">
<li>Native HTML5 player, best on modern devices.</li>
<li>Older devices can use server transcoding.</li>
</ul>
<div class="bg-black/40 rounded-lg overflow-hidden border border-gray-700/50 mt-3">
<table class="w-full text-center text-[11px] leading-tight border-collapse">
<thead class="bg-gray-700/30 text-gray-400">
<tr>
<th class="px-2 py-1.5 border-r border-gray-700/50 font-medium">Device
</th>
<th class="px-2 py-1.5 border-r border-gray-700/50 font-medium">HEVC
Decode
</th>
<th class="px-2 py-1.5 font-medium">AV1 Decode</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700/50 text-gray-400">
<tr>
<td class="px-2 py-1.5 border-r border-gray-700/50 text-primary">
Apple</td>
<td class="px-2 py-1.5 border-r border-gray-700/50">A9 (2015) / M1</td>
<td class="px-2 py-1.5">A17 Pro (2023) / M3</td>
</tr>
<tr>
<td class="px-2 py-1.5 border-r border-gray-700/50 text-primary">
Android</td>
<td class="px-2 py-1.5 border-r border-gray-700/50">Budget Phones (2016)
</td>
<td class="px-2 py-1.5">Budget Phones (2024)</td>
</tr>
<tr>
<td class="px-2 py-1.5 border-r border-gray-700/50 text-primary">
PC</td>
<td class="px-2 py-1.5 border-r border-gray-700/50">Intel 6th-8th Gen
iGPU</td>
<td class="px-2 py-1.5">Intel 11th Gen iGPU</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h4 class="text-gray-300">🧩 Tips & Tricks</h4>
<ul class="list-disc list-inside space-y-1 text-gray-400 text-xs mt-2">
<li>Faster Importing: Use "Home Video" library type.</li>
<li>Smoother Loading: Limit libraries to < 1,000 videos.</li>
<li>Easier Managing: Use multiple libraries and playlists.</li>
<li>PWA Support: Add to Home Screen or Install as App. 📲</li>
</ul>
</div>
<div>
<h4 class="text-gray-300">⌨️ Shortcuts</h4>
<div class="bg-black/40 rounded-lg overflow-hidden border border-gray-700/50 mt-2">
<table class="w-full text-center text-[11px] leading-tight">
<thead class="bg-gray-700/30 text-gray-400">
<tr>
<th class="px-3 py-1.5 font-medium border-r border-gray-700/50">Key</th>
<th class="px-3 py-1.5 font-medium">Function</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700/50 text-gray-400">
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">W / S /
↑ / ↓</td>
<td class="px-3 py-1.5">Prev / Next Video</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">A / D /
← / →</td>
<td class="px-3 py-1.5">Rewind / Forward 15s</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">Space /
Click OK</td>
<td class="px-3 py-1.5">Pause / Play</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">U /
Double Click
OK</td>
<td class="px-3 py-1.5">Favorite Video</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">J /
Menu Key
</td>
<td class="px-2 py-1.5">Toggle Aspect Ratio</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">M</td>
<td class="px-3 py-1.5">Toggle Mute</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">I</td>
<td class="px-3 py-1.5">Profile</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">E</td>
<td class="px-3 py-1.5">Toggle View</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">R</td>
<td class="px-3 py-1.5">Sequential / Random</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">F</td>
<td class="px-3 py-1.5">Toggle Fullscreen</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">G</td>
<td class="px-3 py-1.5">Select Library</td>
</tr>
<tr>
<td class="px-3 py-1.5 text-primary border-r border-gray-700/50">V</td>
<td class="px-3 py-1.5">Media Info</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h4 class="text-gray-300">🚀 Get Involved</h4>
<ul class="list-disc list-inside space-y-1 text-gray-400 text-xs mt-3">
<li><span class="text-gray-300 font-medium">Community:</span> Follow the latest
updates.</li>
<li><span class="text-gray-300 font-medium">Support:</span> Help me build more cool
stuff.</li>
</ul>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 mt-4">
<a href="https://x.com/juneix_tse" target="_blank"
class="flex items-center justify-center text-white bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-full transition-colors border border-white/20 text-[10px] whitespace-nowrap">
<img src="https://cdn.simpleicons.org/x/white" class="w-3.5 h-3.5 mr-1.5"
alt="X" /> Twitter • Follow</a>
<a href="https://t.me/juneix_en" target="_blank"
class="flex items-center justify-center text-[#26A5E4] bg-[#26A5E4]/10 hover:bg-[#26A5E4]/20 px-3 py-1.5 rounded-full transition-colors border border-[#26A5E4]/20 text-[10px] whitespace-nowrap">
<img src="https://cdn.simpleicons.org/telegram/26A5E4"
class="w-3.5 h-3.5 mr-1.5" alt="Telegram" /> Telegram • Channel</a>
<a href="https://ko-fi.com/juneixtse" target="_blank"
class="flex items-center justify-center text-[#FF6433] bg-[#FF5E5B]/10 hover:bg-[#FF5E5B]/20 px-3 py-1.5 rounded-full transition-colors border border-[#FF5E5B]/20 text-[10px] whitespace-nowrap">
<img src="https://cdn.simpleicons.org/kofi/FF6433" class="w-3.5 h-3.5 mr-1.5"
alt="Ko-fi" /> Ko-fi • Support</a>
<a href="https://github.com/juneix/embyx" target="_blank"
class="flex items-center justify-center text-white bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-full transition-colors border border-white/20 text-[10px] whitespace-nowrap active:scale-95">
<img src="https://cdn.simpleicons.org/github/white" class="w-3.5 h-3.5 mr-1.5"
alt="GitHub" /> GitHub • Source</a>
</div>
</div>
<div
class="pt-4 border-t border-gray-700/50 text-xs text-gray-500 text-center flex flex-col space-y-1">
<p>Made with ❤️ by <a href="https://juneix.github.io" target="_blank"
class="text-primary hover:underline">Juneix</a></p>
<p>© 2026 All Rights Reserved</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="libraryModal"
class="fixed inset-0 bg-black/20 z-50 flex items-center justify-center hidden backdrop-blur-sm transition-opacity duration-300">
<div
class="bg-black/50 rounded-xl p-5 w-[90%] max-w-lg max-h-[70vh] overflow-y-auto border border-gray-700/50 shadow-2xl backdrop-blur-sm">
<h3 class="text-white text-lg font-bold mb-4 text-center">Select Library</h3>
<div id="librariesList" class="grid grid-cols-2 gap-2"></div>
<button id="closeLibraryBtn"
class="w-full mt-4 bg-white/10 border border-white/10 text-gray-300 py-2.5 rounded text-sm hover:bg-white/20 font-medium active:scale-95 transition-all focusable-item">Close</button>
</div>
</div>
<div id="toast"
class="fixed top-16 left-1/2 -translate-x-1/2 bg-black/50 px-4 py-2 rounded-2xl text-white text-sm pointer-events-none transition-opacity duration-300 opacity-0 z-[100] w-max max-w-[80vw] whitespace-normal text-center border border-white/10 backdrop-blur-sm">
Message
</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>
/* Styles */
class EmbyApp {
constructor() {
this.state = {
videos: [],
currentIndex: 0,
isPlaying: false,
favorites: new Set(),
playMode: 'sequence',
viewMode: 'stream',
isAutoplay: true,
currentTab: 'home',
currentLibraryId: null,
isScaleFill: true,
isMuted: false,
lastReportTime: 0,
playSessionId: null,
originalVideos: []
};
this.config = { server: '', user: '', token: '', userId: '', useStatic: true, autoplay: true, shortDrama: false, deleteMode: false };
this.csTimer = null;
this.dom = {};
this.config.deviceName = this.getDeviceName();
this.init();
}
getDeviceName() {
const ua = navigator.userAgent;
if (/android/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
if (match && match[1]) {
const parts = match[1].split(';');
for (let p of parts) {
p = p.trim();
if (/^linux/i.test(p) || /^u$/i.test(p) || /^android/i.test(p) || /^[a-z]{2}-[a-z]{2}$/i.test(p) || /wv/.test(p)) continue;
const model = p.split('Build/')[0].trim();
if (model) return `Android (${model})`;
}
}
return "Android";
} else if (/ipad|macintosh/i.test(ua) && navigator.maxTouchPoints > 1) {
return "iPad";
} else if (/iphone/i.test(ua)) {
return "iPhone";
} else if (/macintosh/i.test(ua)) {
return "macOS";
} else if (/windows/i.test(ua)) {
return "Windows";
}
return "Web Browser";
}
isAnyModalOpen() {
const modals = ['mediaInfoModal', 'deleteConfirmModal', 'profilePage', 'libraryModal'];
return modals.some(id => {
const el = document.getElementById(id);
if (!el) return false;
if (id === 'profilePage') return el.classList.contains('active');
return !el.classList.contains('hidden');
});
}
init() {
this.cacheDOM();
this.loadConfig();
// Dynamic app version detection: extract from HTML (e.g. v1.1 -> 1.1)
const vEl = document.querySelector('.bg-primary\\/20.text-xs');
this.config.appVersion = vEl ? vEl.textContent.replace('v', '').trim() : '1.1';
this.loadFavorites();
this.bindEvents();
this.state.isAutoplay = this.config.autoplay !== false;
// Sync UI based on loaded stated
if (this.dom.muteBtn) {
const iconName = this.state.isMuted ? 'volume-x' : 'volume-2';
const iconColor = this.state.isMuted ? 'text-secondary' : 'stroke-white';
this.dom.muteBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${iconName}" class="${iconColor} w-6 h-6 drop-shadow-md"></i></div>`;
}
if (this.dom.scaleBtn) {
this.dom.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>`;
}
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;
// If random mode is saved, show shuffle icon. Otherwise show repeat/repeat-1 based on isAutoplay
const isSeq = this.state.playMode === 'sequence';
const playIcon = isSeq ? (this.state.isAutoplay ? 'repeat' : 'repeat-1') : 'shuffle';
playBtn.innerHTML = `<i data-lucide="${playIcon}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
if (this.config.server && this.config.token) {
this.fetchVideos(this.state.currentLibraryId, null, false, this.state.playMode === 'random');
} else {
setTimeout(() => this.toggleModal('profilePage', true), 500);
}
// ─── Branding Egg ───
console.log(
"%c EmbyX %c Designed by Juneix %c",
"background:#3b82f6;color:#fff;padding:2px 6px;border-radius:4px 0 0 4px;font-weight:bold;",
"background:#1e293b;color:#fff;padding:2px 6px;border-radius:0 4px 4px 0;",
"background:transparent"
);
}
cacheDOM() {
const ids = [
'videoContainer', 'clickToPlayOverlay', 'loading', 'toast',
'videoInfoArea', 'videoTitle', 'videoDescription', 'progressLine', 'currentTime', 'totalTime',
'playModeBtn', 'viewModeBtn', 'libraryBtn', 'myBtn',
'favoriteBtn', 'scaleBtn', 'muteBtn', 'fullscreenBtn', 'deleteBtn',
'mediaInfoModal', 'mediaInfoContent', 'closeMediaInfoBtn',
'deleteConfirmModal', 'confirmDeleteBtn', 'cancelDeleteBtn', 'deleteFileName',
'configServer', 'configUser', 'configPwd', 'configStatic', 'configAutoplay', 'configShortDrama', '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.shortDrama = localStorage.getItem('emby_shortdrama') === 'true';
this.config.deleteMode = localStorage.getItem('emby_delete_mode') === 'true';
// Unique Device ID for session management
if (!localStorage.getItem('emby_device_id')) {
const randomId = Math.random().toString(36).slice(2, 10);
localStorage.setItem('emby_device_id', `EmbyX-${randomId}`);
}
this.config.deviceId = localStorage.getItem('emby_device_id');
const savedScale = localStorage.getItem('emby_is_scale_fill');
if (savedScale !== null) this.state.isScaleFill = savedScale === 'true';
const savedMute = localStorage.getItem('emby_is_muted');
if (savedMute !== null) this.state.isMuted = savedMute === 'true';
const savedPlayMode = localStorage.getItem('emby_play_mode');
if (savedPlayMode !== null) this.state.playMode = savedPlayMode;
if (this.config.shortDrama) this.state.playMode = 'sequence';
const savedLibId = localStorage.getItem('emby_lib_id');
const savedLibType = localStorage.getItem('emby_lib_type');
if (savedLibId) {
this.state.currentLibraryId = savedLibId;
this.state.currentLibraryType = savedLibType || 'library';
}
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) {
const pwdLen = parseInt(localStorage.getItem('emby_pwd_len') || '0');
this.dom.configPwd.value = '*'.repeat(Math.max(1, pwdLen));
}
if (this.dom.configStatic) this.dom.configStatic.checked = this.config.useStatic;
if (this.dom.configAutoplay) this.dom.configAutoplay.checked = this.config.autoplay;
if (this.dom.configShortDrama) this.dom.configShortDrama.checked = this.config.shortDrama;
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();
}
}
async saveConfig() {
let server = this.dom.configServer.value.trim().replace(/\/$/, "");
const user = this.dom.configUser.value.trim();
const pwd = this.dom.configPwd.value.trim();
// Auto-fill protocol and port
if (server && !server.startsWith('http')) {
const host = server.split(':')[0];
const isIP = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(host) || host === 'localhost' || host === '127.0.0.1';
// If IP and no port specified, append default 8096
if (isIP && !server.includes(':')) {
server += ':8096';
}
server = (isIP ? 'http://' : 'https://') + server;
}
const isStatic = this.dom.configStatic.checked;
const autoplay = this.dom.configAutoplay.checked;
const shortDrama = this.dom.configShortDrama.checked;
const deleteMode = this.dom.configDeleteMode.checked;
if (!server || !user) return this.showToast('Please fill in all fields');
this.toggleLoading(true);
try {
if (/^\*+$/.test(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_shortdrama', shortDrama);
localStorage.setItem('emby_delete_mode', deleteMode);
// Clear data cache, keep core settings and reload
localStorage.removeItem('emby_views_cache');
localStorage.removeItem('emby_favorites');
location.reload();
return;
}
const res = await fetch(`${server}/emby/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({ Username: user, Pw: pwd })
});
if (res.status === 401) {
this.showToast('❌ Auth failed: Invalid Username or Password');
return;
} else if (res.status === 404) {
this.showToast('❌ Connection Failed: Check port');
return;
} else if (!res.ok) {
this.showToast('❌ Connection Failed ' + res.status + '. Check Emby logs.');
return;
}
const data = await res.json();
if (data.AccessToken) {
localStorage.setItem('emby_server', server);
localStorage.setItem('emby_user', user);
localStorage.setItem('emby_token', data.AccessToken);
localStorage.setItem('emby_uid', data.SessionInfo.UserId);
localStorage.setItem('emby_static', isStatic);
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_shortdrama', shortDrama);
localStorage.setItem('emby_delete_mode', deleteMode);
this.config = { ...this.config, server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, shortDrama, deleteMode };
const pwdLen = Math.max(1, pwd.length);
localStorage.setItem('emby_pwd_len', pwdLen);
this.dom.configPwd.value = '*'.repeat(pwdLen);
this.dom.deleteBtn.style.display = deleteMode ? 'flex' : 'none';
if (this.dom.playModeBtn) {
this.dom.playModeBtn.innerHTML = `<i data-lucide="${autoplay ? 'repeat' : 'repeat-1'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
}
this.showToast('✅ Settings saved');
this.toggleModal('profilePage', false);
this.fetchVideos();
} else {
this.showToast('❌ Auth failed: Invalid Username or Password');
}
} catch (e) {
console.error('saveConfig error:', e);
const msg = e?.message || '';
const isHttps = location.protocol === 'https:';
const targetHttp = server.startsWith('http:');
if (isHttps && targetHttp) {
this.showToast('❌ Blocked: Mixed Content (HTTPS -> HTTP)');
} else if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ERR_CONNECTION')) {
this.showToast('❌ Cannot connect to server');
} else {
this.showToast('❌ Connection Failed: ' + (msg || 'Unknown error'));
}
} finally {
this.toggleLoading(false);
}
}
resetConfig() {
if (confirm('Are you sure to reset all settings?')) {
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('Failed to load remote Favorites:', e);
const saved = localStorage.getItem('emby_favorites');
if (saved) this.state.favorites = new Set(JSON.parse(saved));
}
}
async fetchVideos(parentId = null, ids = null, isLoadMore = false, isRandom = false, isAppend = false) {
this.toggleLoading(!isAppend);
try {
const { server, token, userId } = this.config;
if (!server || !token) {
if (!isAppend) this.showToast('Please config server first');
return;
}
if (!isLoadMore && !isAppend) {
this.state.startIndex = 0;
}
const libraryId = parentId || this.state.currentLibraryId;
const PAGE_SIZE = 150;
// const MAX_GRID_VIDEOS = 1000;
let fetchStartIndex = this.state.startIndex || 0;
if (isAppend) {
fetchStartIndex += this.state.videos.length;
}
let url = `${server}/emby/Users/${userId}/Items?api_key=${token}&Recursive=true&IncludeItemTypes=Movie,Episode,Video,MusicVideo&Limit=${PAGE_SIZE}&StartIndex=${fetchStartIndex}&Fields=Overview,Path,RunTimeTicks,MediaSources`;
if (ids) {
url += `&Ids=${ids}`;
} else if (isRandom) {
url += `&SortBy=Random`;
if (libraryId && libraryId !== 'favorites') url += `&ParentId=${libraryId}`;
if (libraryId === 'favorites') url += `&Filters=IsFavorite`;
} else if (libraryId === 'favorites') {
url += `&SortBy=DateCreated&SortOrder=Descending&Filters=IsFavorite`;
} else if (libraryId) {
const sortStr = this.config.shortDrama ? 'SortBy=Path&SortOrder=Ascending' : 'SortBy=DateCreated&SortOrder=Descending';
url += `&${sortStr}&ParentId=${libraryId}`;
} else {
const sortStr = this.config.shortDrama ? 'SortBy=Path&SortOrder=Ascending' : 'SortBy=DateCreated&SortOrder=Descending';
url += `&${sortStr}`;
}
const res = await fetch(url, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
if (!res.ok) {
throw new Error(`Server Error: ${res.status}`);
}
let data = await res.json();
if (data && data.Items && data.Items.length > 0) {
if (isAppend) {
this.state.videos.push(...data.Items);
if (this.state.playMode === 'random' && this.state.originalVideos) {
this.state.originalVideos.push(...data.Items);
}
return;
} else {
this.state.videos = data.Items;
this.state.currentIndex = 0;
this.state.totalCount = data.TotalRecordCount || 0;
if (this.state.playMode === 'random') {
this.state.originalVideos = [...this.state.videos];
this._shuffleAroundCurrent();
}
if (this.state.viewMode === 'grid') {
this.renderGridView();
} else {
this.renderSlides();
this.loadVideo(0);
}
}
} else if (isLoadMore || isAppend) {
if (!isAppend) this.showToast('No more videos');
this.state.startIndex = Math.max(0, (this.state.startIndex || 0));
this.toggleLoading(false);
return;
} else {
this.showToast('No videos found');
this.dom.videoContainer.innerHTML = '';
}
} catch (e) {
console.error('Failed to fetch videos:', e);
this.showToast('Cannot connect to server: ' + (e.message || 'Unknown error'));
} finally {
this.toggleLoading(false);
}
}
renderSlides() {
this.dom.videoContainer.innerHTML = '';
this.dom.videoContainer.className = 'relative w-full h-full transition-transform duration-300 ease-out';
// Temporarily disable transition for instant snap
this.dom.videoContainer.style.transition = 'none';
this.dom.videoContainer.style.transform = `translateY(-${this.state.currentIndex * 100}%)`;
// Force browser reflow to apply the instant transform
void this.dom.videoContainer.offsetHeight;
// Restore transition for subsequent manual swipes
this.dom.videoContainer.style.transition = '';
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">
<img id="thumb-${i}" class="absolute inset-0 w-full h-full object-cover z-0 ${this.state.isScaleFill ? '' : 'opacity-0'}" src="" alt="" />
<div id="poster-${i}" class="absolute inset-0 bg-cover bg-center scale-[1.15] blur-xl transition-opacity duration-300 z-0 ${this.state.isScaleFill ? 'opacity-0' : 'opacity-70'}"></div>
<div id="overlay-${i}" class="absolute inset-0 bg-black/40 transition-opacity duration-300 z-0 ${this.state.isScaleFill ? 'opacity-0' : 'opacity-100'}"></div>
<video id="video-p${i}"
class="video-fullscreen ${this.state.isScaleFill ? 'object-cover' : 'object-contain'} opacity-0 transition-opacity duration-500 z-10"
playsinline webkit-playsinline muted autoplay
poster="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></video>
<div class="touch-layer absolute inset-0 z-20" style="-webkit-touch-callout: none;"></div>
<div id="playBtn-${i}" class="play-pause-btn">
<i data-lucide="play" class="stroke-white w-8 h-8"></i>
</div>
</div>
`;
this.dom.videoContainer.appendChild(el);
}
this.dom.slides = [
document.getElementById('slide-0'),
document.getElementById('slide-1'),
document.getElementById('slide-2')
];
lucide.createIcons();
this.updateUI();
}
renderGridView() {
this.dom.slides = null;
const container = this.dom.videoContainer;
// Reset page scroll (scrollIntoView can pollute html.scrollTop causing header misalignment)
document.documentElement.scrollTop = 0;
// Disable transition + force reflow before clearing transform, prevents stream offset flickering
container.style.transition = 'none';
void container.offsetHeight;
container.style.transform = 'none';
container.innerHTML = '';
container.className = 'absolute inset-0 z-10 bg-black/40 overflow-hidden flex flex-col';
const currentVideo = this.state.videos[this.state.currentIndex];
const bgPosterStr = currentVideo ? this.getImageSrc(currentVideo) : '';
const bgLayer = document.createElement('div');
bgLayer.className = 'absolute inset-0 bg-cover bg-center opacity-70 scale-[1.15] blur-xl z-[-1] pointer-events-none';
if (bgPosterStr) {
const imgL = new Image();
imgL.crossOrigin = 'anonymous';
imgL.referrerPolicy = 'no-referrer';
imgL.src = bgPosterStr;
imgL.onerror = () => {
bgLayer.style.backgroundImage = `url('${this.getImageSrc(null)}')`;
};
bgLayer.style.backgroundImage = `url('${bgPosterStr}')`;
}
container.appendChild(bgLayer);
const gridWrapper = document.createElement('div');
const safeHeader = document.createElement('div');
let libraryName = 'All Videos';
if (this.state.currentLibraryId === 'favorites') {
libraryName = 'Favorites';
} else if (this.state.currentLibraryId) {
const libIdStr = String(this.state.currentLibraryId);
const cachedViewsStr = localStorage.getItem('emby_views_cache');
if (cachedViewsStr) {
try {
const cached = JSON.parse(cachedViewsStr);
let found = null;
if (cached.views && cached.views.Items) {
found = cached.views.Items.find(i => String(i.Id) === libIdStr);
}
if (!found && cached.playlists && cached.playlists.Items) {
found = cached.playlists.Items.find(i => String(i.Id) === libIdStr);
}
if (found) libraryName = found.Name;
} catch (e) { }
}
}
const PAGE_SIZE = 150;
const totalCount = this.state.totalCount || 0;
const startIndex = this.state.startIndex || 0;
const currentPage = Math.floor(startIndex / PAGE_SIZE) + 1;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const isSpecificLib = !!this.state.currentLibraryId;
const showPager = isSpecificLib && totalPages > 1;
const countStr = totalCount > 999 ? '999+' : String(totalCount);
safeHeader.className = 'w-full flex items-center gap-2 bg-black/50 backdrop-blur-sm border-b border-gray-800/50 px-3 pb-3';
safeHeader.style.paddingTop = 'max(8px, env(safe-area-inset-top, 8px))';
safeHeader.innerHTML = `
<span class="text-sm font-bold text-gray-200">${libraryName}</span>
<span class="text-xs font-bold text-primary/70 tabular-nums flex items-center">
${countStr}
<span class="w-1.5"></span>
<button id="gridShuffleBtn" title="Shuffle Videos" class="active:scale-90 transition-transform p-[2px] bg-white/10 rounded-full hover:bg-white/20">
<i data-lucide="sparkles" class="w-3.5 h-3.5 text-white"></i>
</button>
</span>
<span class="flex-1"></span>
${showPager ? `
<button id="gridPrevBtn" class="px-1.5 py-0.5 rounded text-xs ${currentPage <= 1 ? 'text-gray-600 pointer-events-none' : 'text-gray-300 active:scale-90 hover:text-white transition-colors'}"></button>
<span class="text-xs font-bold text-white tabular-nums">${currentPage}<span class="px-0.5">/</span>${totalPages}</span>
<button id="gridNextBtn" class="px-1.5 py-0.5 rounded text-xs ${currentPage >= totalPages ? 'text-gray-600 pointer-events-none' : 'text-gray-300 active:scale-90 hover:text-white transition-colors'}"></button>
` : ''}
`;
container.appendChild(safeHeader);
const shuffleBtn = document.getElementById('gridShuffleBtn');
if (shuffleBtn) {
const curLibId = this.state.currentLibraryId;
const libArg = (!curLibId || curLibId === 'favorites') ? null : curLibId;
shuffleBtn.onclick = (e) => {
e.stopPropagation();
this.state.startIndex = 0;
this.fetchVideos(libArg, null, false, true); // isRandom=true
};
}
if (showPager) {
const curLibId = this.state.currentLibraryId;
const libArg = curLibId === 'favorites' ? null : curLibId;
const prevBtn = document.getElementById('gridPrevBtn');
if (prevBtn && currentPage > 1) {
prevBtn.onclick = (e) => {
e.stopPropagation();
this.state.startIndex = Math.max(0, startIndex - PAGE_SIZE);
this.fetchVideos(libArg, null, true, false);
};
}
const nextBtn = document.getElementById('gridNextBtn');
if (nextBtn && currentPage < totalPages) {
nextBtn.onclick = (e) => {
e.stopPropagation();
this.state.startIndex = startIndex + PAGE_SIZE;
this.fetchVideos(libArg, null, true, false);
};
}
}
const scrollArea = document.createElement('div');
scrollArea.className = 'flex-1 min-h-0 overflow-y-auto overflow-x-hidden relative w-full overscroll-contain';
scrollArea.style.WebkitOverflowScrolling = 'touch';
container.appendChild(scrollArea);
// MOUSE DRAG SUPPORT FOR GRID
let isDown = false;
let startY;
let scrollTop;
scrollArea.addEventListener('mousedown', (e) => {
isDown = true;
scrollArea.style.cursor = 'grabbing';
startY = e.pageY - scrollArea.offsetTop;
scrollTop = scrollArea.scrollTop;
});
scrollArea.addEventListener('mouseleave', () => {
isDown = false;
scrollArea.style.cursor = '';
});
scrollArea.addEventListener('mouseup', () => {
isDown = false;
scrollArea.style.cursor = '';
});
scrollArea.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const y = e.pageY - scrollArea.offsetTop;
const walk = (y - startY) * 2;
scrollArea.scrollTop = scrollTop - walk;
});
gridWrapper.className = 'grid grid-cols-3 gap-1 content-start w-full relative z-10 px-1 pt-2';
scrollArea.appendChild(gridWrapper);
this.state.videos.forEach((video, index) => {
const el = document.createElement('div');
const isActive = index === this.state.currentIndex;
el.setAttribute('tabindex', '0');
el.setAttribute('data-index', index);
el.className = `focusable-item aspect-[3/4] relative cursor-pointer overflow-hidden rounded-sm active:scale-95 transition-transform ${isActive ? 'border-2 border-primary box-border' : 'bg-gray-800'}`;
el.onclick = () => {
this.state.currentIndex = index;
this.toggleViewMode();
};
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.referrerPolicy = 'no-referrer';
img.src = this.getImageSrc(video);
img.className = 'w-full h-full object-cover';
img.loading = 'lazy';
img.onerror = () => {
img.onerror = null;
img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Crect width="1" height="1" fill="%23333"%2F%3E%3C%2Fsvg%3E';
};
el.appendChild(img);
if (video.RunTimeTicks) {
const duration = Math.floor(video.RunTimeTicks / 10000000);
const mins = Math.floor(duration / 60);
const secs = duration % 60;
const timeBadge = document.createElement('div');
timeBadge.className = 'absolute bottom-1 right-1 bg-black/70 text-white text-[10px] px-1 py-0.5 rounded';
timeBadge.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
el.appendChild(timeBadge);
}
gridWrapper.appendChild(el);
});
// Basic URL: https://v1.hitokoto.cn/
const eggFooter = document.createElement('div');
eggFooter.className = 'col-span-3 pt-8 pb-24 px-4 text-center text-gray-600 text-[11px] leading-relaxed select-none';
eggFooter.innerHTML = '<span class="opacity-40">——</span>';
gridWrapper.appendChild(eggFooter);
fetch('https://v1.hitokoto.cn/?encode=json&charset=utf-8')
.then(r => r.json())
.then(data => {
const text = data.hitokoto || '';
const source = [data.from_who, data.from].filter(Boolean).join(' · ');
eggFooter.innerHTML = `
<p class="text-gray-300">“${text}”</p>
${source ? `<p class="mt-1 text-gray-500 italic">- ${source} -</p>` : ''}
`;
})
.catch(() => {
eggFooter.innerHTML = '';
});
const focusVideo = this.state.videos[this.state.currentIndex];
if (focusVideo) {
this.dom.videoTitle.textContent = focusVideo.Name || 'Unknown Video';
this.dom.videoDescription.textContent = focusVideo.Overview || 'No Description...';
}
// Scroll to active card (direct scrollArea op, avoids scrollIntoView polluting html.scrollTop)
requestAnimationFrame(() => {
const activeEl = gridWrapper.children[this.state.currentIndex];
if (activeEl && scrollArea) {
const elTop = activeEl.offsetTop;
const elHeight = activeEl.offsetHeight;
const areaHeight = scrollArea.clientHeight;
scrollArea.scrollTop = elTop - (areaHeight / 2) + (elHeight / 2);
}
});
lucide.createIcons();
}
renderBuffer() {
if (this.state.viewMode !== 'stream' || !this.dom.slides) return;
this.dom.videoContainer.style.transform = `translateY(-${this.state.currentIndex * 100}%)`;
const offsets = [-1, 0, 1];
offsets.forEach((offset) => {
const videoIdx = this.state.currentIndex + offset;
if (videoIdx < 0 || videoIdx >= this.state.videos.length) return;
const slideIdx = (videoIdx % 3 + 3) % 3;
const slideEl = this.dom.slides[slideIdx];
const videoData = this.state.videos[videoIdx];
slideEl.style.transform = `translateY(${videoIdx * 100}%)`;
slideEl.style.display = 'block';
if (slideEl.dataset.id !== videoData.Id) {
slideEl.dataset.id = videoData.Id;
const posterSrc = this.getImageSrc(videoData);
const thumbEl = document.getElementById(`thumb-${slideIdx}`);
if (thumbEl) {
thumbEl.src = posterSrc;
thumbEl.onerror = () => { thumbEl.src = this.getImageSrc(null); };
}
const posterEl = document.getElementById(`poster-${slideIdx}`);
if (posterEl) posterEl.style.backgroundImage = `url('${posterSrc}')`;
const videoEl = document.getElementById(`video-p${slideIdx}`);
videoEl.style.opacity = '0';
videoEl.onerror = null;
const preloadSrc = this.getVideoSrc(videoData.Id, videoData);
if (videoEl.src !== preloadSrc) {
videoEl.src = preloadSrc;
videoEl.load();
}
videoEl.muted = (offset === 0) ? this.state.isMuted : true;
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.style.opacity = '0';
videoEl.removeAttribute('src');
}
}
}
this.updateUI();
}
loadVideo(index) {
if (this.state.viewMode !== 'stream') return;
this.state.playSessionId = 'ex_' + Date.now().toString(16);
this.renderBuffer();
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, videoData);
if (videoEl.src !== src) {
videoEl.src = src;
videoEl.load();
}
videoEl.muted = this.state.isMuted;
const muteIcon = this.dom.muteBtn.querySelector('svg');
if (muteIcon) {
const iconName = this.state.isMuted ? 'volume-x' : 'volume-2';
const iconColor = this.state.isMuted ? 'text-secondary' : 'stroke-white';
this.dom.muteBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${iconName}" class="${iconColor} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons({ nameAttr: 'data-lucide', attrs: {}, nodes: [this.dom.muteBtn] });
}
videoEl.onwaiting = () => {
clearTimeout(this._waitingTimer);
this._waitingTimer = setTimeout(() => this.toggleLoading(true), 300);
};
videoEl.onplaying = () => {
clearTimeout(this._waitingTimer);
this.toggleLoading(false);
videoEl.style.opacity = '1';
this.reportPlayback('playing', videoEl);
};
videoEl.oncanplay = () => {
clearTimeout(this._waitingTimer);
this.toggleLoading(false);
videoEl.style.opacity = '1';
};
if (videoEl.readyState >= 3) {
clearTimeout(this._waitingTimer);
this.toggleLoading(false);
videoEl.style.opacity = '1';
}
videoEl.ontimeupdate = () => {
if (videoEl.duration) {
const pct = (videoEl.currentTime / videoEl.duration) * 100;
this.dom.progressLine.style.width = `${pct}%`;
this.dom.currentTime.textContent = this.formatTime(videoEl.currentTime);
this.dom.totalTime.textContent = this.formatTime(videoEl.duration);
this.reportPlayback('progress', videoEl);
}
};
videoEl.onpause = () => this.reportPlayback('progress', videoEl);
videoEl.onended = () => {
this.reportPlayback('stopped', videoEl);
if (this.state.viewMode === 'stream' && this.state.isAutoplay) {
this.nextVideo();
}
};
videoEl.onerror = () => {
console.warn('Video playback failed, handling downgrade retry logic');
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;
if (!window._videosUnlocked) {
const unlock = () => {
window._videosUnlocked = true;
[0, 1, 2].forEach(i => {
const vi = document.getElementById(`video-p${i}`);
if (vi && i !== slideIdx) {
const p = vi.play();
if (p !== undefined) p.then(() => vi.pause()).catch(() => { });
}
});
document.removeEventListener('touchstart', unlock, true);
document.removeEventListener('click', unlock, true);
};
document.addEventListener('touchstart', unlock, { once: true, capture: true });
document.addEventListener('click', unlock, { once: true, capture: true });
}
[0, 1, 2].forEach(i => {
const vi = document.getElementById(`video-p${i}`);
if (vi && i !== slideIdx) {
vi.pause();
vi.currentTime = 0;
const btn = document.getElementById(`playBtn-${i}`);
if (btn) btn.classList.remove('paused');
}
});
const v = document.getElementById(`video-p${slideIdx}`);
const btn = document.getElementById(`playBtn-${slideIdx}`);
if (v) {
if (!this.state.isMuted) v.muted = false;
const playPromise = v.play();
if (playPromise !== undefined) {
playPromise.then(() => {
this.state.isPlaying = true;
if (btn) btn.classList.remove('paused');
}).catch(error => {
console.warn('Playback blocked (likely missing interaction):', error);
this.state.isPlaying = false;
this.toggleLoading(false);
if (btn) btn.classList.add('paused');
if (error.name === 'NotAllowedError') {
this.showToast('Browser restricted autoplay. Tap to play.');
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
}
}
});
}
}
}
retryWithAltMode(index, item) {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (!v) return;
const retryCount = parseInt(v.dataset.retryCount || '0');
const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
v.dataset.retryCount = String(retryCount + 1);
if (this.config.useStatic) {
// -- Direct Play fallback chain --
if (isIOS) {
// iOS fallback: Direct Play → Direct Stream → HLS Transcode
if (retryCount === 0) {
// Step 1: Direct Stream (remux only, fastest, no re-encode)
// Declare HEVC support to prevent Emby from transcoding during remux
const { server, token } = this.config;
const deviceId = this.config.deviceId || 'EmbyX-Device';
this.showToast('iOS Direct Stream...');
const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AudioCodec=aac,mp3,ac3&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
v.src = `${server}/emby/Videos/${item.Id}/stream.mp4?${params}`;
v.load();
this.playVideo(index);
} else if (retryCount === 1) {
// Step 2: HLS Transcode (ultimate fallback)
this.showToast('iOS Transcode...');
v.src = this._buildHlsSrc(item.Id, item);
v.load();
this.playVideo(index);
} else {
this.toggleLoading(false);
this.showToast('Playback failed. Check Emby settings.');
}
} else {
// Non-iOS fallback: Direct Play → Direct Stream
if (retryCount === 0) {
const { server, token } = this.config;
const deviceId = this.config.deviceId || 'EmbyX-Device';
this.showToast('Trying Direct Stream...');
const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
v.src = `${server}/emby/Videos/${item.Id}/stream.mp4?${params}`;
v.load();
this.playVideo(index);
} else {
this.toggleLoading(false);
this.showToast('Playback failed. Disable Direct Play for transcoding.');
}
}
} else {
// -- Transcode mode (HLS) failed, no further fallback --
this.toggleLoading(false);
this.showToast('Transcode failed. Check Emby settings.');
}
}
// -- Universal HLS URL builder (all platforms) --
// MediaSourceId and PlaySessionId are required by Emby transcode API; missing them causes 500
// To adjust bitrate cap, modify VideoBitrate below (unit: bps, default 20Mbps)
//
// [Smart Transcode Strategy]
// VideoCodec=h264 (declare only h264 support):
// → Source is h264? Allow stream copy (remux into HLS segments, no re-encode) → efficient
// → Source is hevc/av1? Emby detects incompatibility, transcodes to h264 → hardware accel
// AllowVideoStreamCopy=true:
// Allows h264 sources to remux directly; prevents wasteful h264→h264 re-encoding
// Note: EnableAutoStreamCopy is NOT added — it can bypass HLS segmentation entirely
_buildHlsSrc(id, mediaItem) {
const { server, token } = this.config;
const mediaSourceId = mediaItem?.MediaSources?.[0]?.Id || '';
const playSessionId = this.state.playSessionId || ('hls_' + id + '_' + Date.now());
const deviceId = this.config.deviceId || 'EmbyX-Device';
const params = [
`api_key=${token}`,
mediaSourceId ? `MediaSourceId=${mediaSourceId}` : '',
`DeviceId=${deviceId}`,
`PlaySessionId=${playSessionId}`,
`VideoCodec=h264`, // h264 only: hevc/av1/etc. forced to transcode
`AudioCodec=aac,mp3,ac3`,
`VideoBitrate=20000000`,
`AudioBitrate=320000`,
`TranscodingMaxAudioChannels=2`,
`SegmentContainer=ts`, // Use ts: fixes h264 source playback failure with mp4 HLS segments
`MinSegments=1`,
`BreakOnNonKeyFrames=True`,
`AllowVideoStreamCopy=true`, // h264 source: remux, no re-encode (saves GPU/CPU)
`AllowAudioStreamCopy=true` // aac audio: copy; other formats: transcode to aac
// EnableAutoStreamCopy NOT added — avoids Emby bypassing HLS segmentation
].filter(Boolean).join('&');
return `${server}/emby/Videos/${id}/master.m3u8?${params}`;
}
togglePlay() {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
const btn = document.getElementById(`playBtn-${slideIdx}`);
if (!v) return;
if (v.paused) {
v.play();
this.state.isPlaying = true;
if (btn) btn.classList.remove('paused');
} else {
v.pause();
this.state.isPlaying = false;
if (btn) btn.classList.add('paused');
}
}
prevVideo() {
if (this.state.currentIndex === 0) {
return;
}
this.switchSlide(this.state.currentIndex - 1, 'down');
}
switchSlide(newIndex, direction) {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const curV = document.getElementById(`video-p${slideIdx}`);
if (curV) {
curV.pause();
this.reportPlayback('stopped', curV);
}
if (this.dom.slides) {
const destSlideIdx = (newIndex % 3 + 3) % 3;
const destSlide = this.dom.slides[destSlideIdx];
if (destSlide) {
destSlide.style.transform = `translateY(${newIndex * 100}%)`;
destSlide.style.display = 'block';
}
}
this.state.currentIndex = newIndex;
if (newIndex >= this.state.videos.length - 5 && this.state.videos.length < (this.state.totalCount || 999999)) {
if (!this._fetchingAppend) {
this._fetchingAppend = true;
this.fetchVideos(null, null, false, this.state.playMode === 'random', true).finally(() => { this._fetchingAppend = false; });
}
}
if (this.dom.videoContainer) {
this.dom.videoContainer.style.transform = `translateY(-${newIndex * 100}%)`;
}
requestAnimationFrame(() => {
this.loadVideo(newIndex);
this.playVideo(newIndex);
});
}
reportPlayback(event, videoEl) {
if (!this.config.server || !this.config.token || this.state.videos.length === 0) return;
const now = Date.now();
if (event === 'progress' && now - (this.state.lastReportTime || 0) < 5000) return;
if (event === 'progress') this.state.lastReportTime = now;
const videoData = this.state.videos[this.state.currentIndex];
if (!videoData || !videoEl) return;
// Emby Fix: 1. Map standard names 2. Add MediaSourceId 3. Report Capabilities
const eventMap = { 'playing': 'TimeUpdate', 'progress': 'TimeUpdate', 'stopped': 'Stopped' };
const eventName = videoEl.paused ? 'Pause' : (eventMap[event] || 'TimeUpdate');
const mediaSourceId = videoData.MediaSources?.[0]?.Id || '';
if (event === 'playing') {
this.reportCapabilities();
}
const ticks = Math.floor(videoEl.currentTime * 10000000);
let endpoint = '/Sessions/Playing';
if (event === 'progress') endpoint = '/Sessions/Playing/Progress';
if (event === 'stopped') endpoint = '/Sessions/Playing/Stopped';
const payload = {
ItemId: videoData.Id,
PositionTicks: ticks,
IsPaused: videoEl.paused || event === 'stopped',
IsMuted: videoEl.muted,
VolumeLevel: Math.floor(videoEl.volume * 100),
PlayMethod: this.config.useStatic ? 'DirectPlay' : 'Transcode',
EventName: eventName,
CanSeek: true,
PlaySessionId: this.state.playSessionId,
QueueableMediaTypes: ["Video"]
};
if (mediaSourceId) payload.MediaSourceId = mediaSourceId;
fetch(`${this.config.server}/emby${endpoint}?api_key=${this.config.token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify(payload)
}).catch(() => { });
}
reportCapabilities() {
const { server, token } = this.config;
if (!server || !token) return;
fetch(`${server}/emby/Sessions/Capabilities/Full?api_key=${token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({
PlayableMediaTypes: ["Video"],
SupportsMediaControl: true,
SupportsPersistentConnections: false
})
}).catch(() => { });
}
getVideoSrc(id, mediaItem) {
const { server, token, useStatic } = this.config;
// Get metadata (codec and container)
const mediaSource = mediaItem?.MediaSources?.[0];
const codec = (mediaSource?.VideoCodec || '').toLowerCase();
const container = (mediaSource?.Container || '').toLowerCase();
const codecTag = (mediaSource?.VideoCodecTag || '').toLowerCase(); // Get tag like hev1/hvc1
// Smart Decision: For h264+mp4, always prioritize direct play
const isNativeCompatible = codec === 'h264' && (container.includes('mp4') || container.includes('m4v'));
if (!useStatic && !isNativeCompatible) {
// Force HLS only if Direct Play is off AND codec is not h264+mp4
return this._buildHlsSrc(id, mediaItem);
}
// -- Device Compatibility Check for Apple hev1 issue --
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// Specific bypass for iOS + HEVC + hev1 tag (Safari only natively supports hvc1)
// Direct play (Static) for hev1 fails on iOS. Force Direct Stream (Remux) to fix the tag.
if (isIOS && codec === 'hevc' && codecTag === 'hev1') {
const deviceId = this.config.deviceId || 'EmbyX-Device';
// Using stream.mp4 with Copy allows Emby to Remux (hev1 -> hvc1) without transcoding.
const params = `api_key=${token}&DeviceId=${deviceId}&VideoCodec=h264,hevc,hevc,av1&AudioCodec=aac,mp3,ac3&AllowVideoStreamCopy=true&AllowAudioStreamCopy=true`;
return `${server}/emby/Videos/${id}/stream.mp4?${params}`;
}
// -- Direct Play (Static) --
const playSessionId = this.state.playSessionId || ('ex_' + Date.now().toString(16));
const deviceId = this.config.deviceId || 'EmbyX-Device';
const msId = mediaSource?.Id || '';
const params = [
`api_key=${token}`,
`Static=true`,
msId ? `MediaSourceId=${msId}` : '',
`DeviceId=${deviceId}`,
`PlaySessionId=${playSessionId}`
].filter(Boolean).join('&');
return `${server}/emby/Videos/${id}/stream?${params}`;
}
getImageSrc(video) {
const { server, token } = this.config;
const fallbackUrl = './poster.webp';
if (typeof video === 'object' && video !== null) {
if (video.ImageTags && video.ImageTags.Primary) {
return `${server}/emby/Items/${video.Id}/Images/Primary?api_key=${token}`;
}
return fallbackUrl;
}
if (!video) return fallbackUrl;
return `${server}/emby/Items/${video}/Images/Primary?api_key=${token}`;
}
async showLibraries() {
this.toggleLoading(true);
const list = document.getElementById('librariesList');
list.innerHTML = '';
try {
const userId = this.config.userId;
const playlistsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Items?Recursive=true&IncludeItemTypes=Playlist&api_key=${this.config.token}`, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const playlistsData = await playlistsRes.json();
const viewsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Views?api_key=${this.config.token}`, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const viewsDataRaw = viewsRes.ok ? await viewsRes.json() : { Items: [] };
localStorage.setItem('emby_views_cache', JSON.stringify({ playlists: playlistsData, views: viewsDataRaw }));
const filteredViews = viewsDataRaw.Items ? viewsDataRaw.Items.filter(item => item.CollectionType !== 'playlists' && item.CollectionType !== 'boxsets') : [];
const favDiv = document.createElement('div');
const isFavSelected = this.state.currentLibraryId === 'favorites';
favDiv.setAttribute('tabindex', '0');
favDiv.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isFavSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
favDiv.innerHTML = `<i data-lucide="folder-heart" class="w-4 h-4"></i><span class="flex-1 truncate">Favorites</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('No favorites');
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 = 'Playlists';
list.appendChild(headerPl);
playlistsData.Items.forEach(pl => {
const div = document.createElement('div');
const isSelected = pl.Id === this.state.currentLibraryId;
div.setAttribute('tabindex', '0');
div.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
div.innerHTML = `<i data-lucide="list-video" class="w-4 h-4 flex-shrink-0"></i><span class="flex-1 truncate">${pl.Name}</span>${isSelected ? '<i data-lucide="check" class="w-3.5 h-3.5 flex-shrink-0"></i>' : ''}`;
div.onclick = () => {
this.state.currentLibraryId = pl.Id;
this.state.currentLibraryType = 'playlist';
localStorage.setItem('emby_lib_id', pl.Id);
localStorage.setItem('emby_lib_type', '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 = 'Libraries';
list.appendChild(headerLib);
filteredViews.forEach(lib => {
const div = document.createElement('div');
const isSelected = lib.Id === this.state.currentLibraryId;
let iconName = 'clapperboard';
if (lib.CollectionType === 'tvshows') iconName = 'tv';
else if (lib.CollectionType === 'homevideos') iconName = 'video';
else if (lib.CollectionType === 'musicvideos') iconName = 'cassette-tape';
else if (lib.CollectionType === 'boxsets') iconName = 'archive';
div.setAttribute('tabindex', '0');
div.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
div.innerHTML = `<i data-lucide="${iconName}" 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';
localStorage.setItem('emby_lib_id', lib.Id);
localStorage.setItem('emby_lib_type', '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('Failed to fetch Libraries:', e);
this.showToast('Failed to fetch Libraries');
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">Favorites</span>`;
favDiv.onclick = () => {
if (this.state.favorites.size === 0) {
this.showToast('No favorites');
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);
if (!item.UserData) item.UserData = {};
const isFav = item.UserData.IsFavorite === true;
const { server, token, userId } = this.config;
item.UserData.IsFavorite = !isFav;
if (!isFav) {
this.state.favorites.add(id);
this.showToast('❤️ Favorited');
} else {
this.state.favorites.delete(id);
this.showToast('Removed favorite');
}
this.updateUI();
if (server && token && userId) {
try {
const method = isFav ? 'DELETE' : 'POST';
const res = await fetch(`${server}/emby/Users/${userId}/FavoriteItems/${id}?api_key=${token}`, { method });
if (!res.ok) {
item.UserData.IsFavorite = isFav;
if (isFav) this.state.favorites.add(id); else this.state.favorites.delete(id);
this.updateUI();
this.showToast('Action failed, try again');
}
} catch (e) {
console.error('Favorite sync failed:', e);
}
}
}
updateUI() {
const item = this.state.videos[this.state.currentIndex];
if (item) {
this.dom.videoTitle.textContent = item.Name;
this.dom.videoDescription.textContent = item.Overview || 'No Description';
const favBtn = document.getElementById('favoriteBtn');
const isFav = item.UserData?.IsFavorite === true;
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="heart" class="${isFav ? 'text-secondary fill-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons({ nameAttr: 'data-lucide', nodes: [favBtn] });
}
}
updateTabHighlight() {
[this.dom.myBtn, this.dom.libraryBtn, this.dom.playModeBtn, this.dom.viewModeBtn].forEach(btn => {
if (btn) {
btn.classList.add('text-gray-400');
btn.classList.remove('text-white');
}
});
}
toggleAutoplay() {
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
} else {
app.classList.add('interface-hidden');
}
}
toggleDeleteMode() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
this.dom.deleteFileName.textContent = 'Confirm delete ' + (item.Name || 'this video') + '?';
this.toggleModal('deleteConfirmModal', true);
lucide.createIcons();
}
async executeDelete() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
try {
// Note: Emby DELETE API requires admin account; regular users get 403
// Must check res.ok, otherwise 403/404 are silently treated as success
const res = await fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, {
method: 'DELETE'
});
if (!res.ok) {
if (res.status === 403) {
this.showToast('❌ No permission. Use an admin account');
this.toggleModal('deleteConfirmModal', false);
return;
}
if (res.status !== 404) {
this.showToast(`❌ Delete failed (${res.status})`);
this.toggleModal('deleteConfirmModal', false);
return;
}
// 404 means already deleted — still remove from list
}
this.showToast('✅ Deleted');
this.state.videos.splice(this.state.currentIndex, 1);
this.toggleModal('deleteConfirmModal', false);
if (this.state.viewMode === 'grid') {
this.renderGridView();
} else {
if (this.state.currentIndex >= this.state.videos.length) {
this.state.currentIndex = Math.max(0, this.state.videos.length - 1);
}
// Fix: renderSlides only rebuilds DOM; must call loadVideo+playVideo to resume playback
this.renderSlides();
if (this.state.videos.length > 0) {
this.loadVideo(this.state.currentIndex);
this.playVideo(this.state.currentIndex);
}
}
} catch (e) {
this.showToast('❌ Delete failed: network error');
this.toggleModal('deleteConfirmModal', false);
}
}
showInterfaceTemp() {
const app = document.getElementById('app');
if (this.csTimer) clearTimeout(this.csTimer);
if (this.state.viewMode === 'grid') return;
if (this.isAnyModalOpen()) return;
if ((document.fullscreenElement || app.classList.contains('ios-pwa-fullscreen')) && !app.classList.contains('interface-hidden')) {
this.csTimer = setTimeout(() => {
app.classList.add('interface-hidden');
}, 5000);
}
}
_shuffleAroundCurrent() {
const idx = this.state.currentIndex;
const cur = this.state.videos[idx];
for (let i = this.state.videos.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.state.videos[i], this.state.videos[j]] = [this.state.videos[j], this.state.videos[i]];
}
const newPos = this.state.videos.findIndex(v => v.Id === cur.Id);
[this.state.videos[newPos], this.state.videos[idx]] = [this.state.videos[idx], this.state.videos[newPos]];
}
togglePlayMode() {
if (this.config.shortDrama) {
this.showToast('️ Sequence play locked in Short Drama mode');
return;
}
const isSeq = this.state.playMode === 'sequence';
this.state.playMode = isSeq ? 'random' : 'sequence';
localStorage.setItem('emby_play_mode', this.state.playMode);
const btn = this.dom.playModeBtn;
btn.innerHTML = `<i data-lucide="${isSeq ? 'shuffle' : 'repeat'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
if (isSeq) {
this.state.originalVideos = [...this.state.videos];
this._shuffleAroundCurrent();
if (this.dom.slides) this.renderBuffer();
this.showToast('🔀 Shuffle mode');
} else {
const curId = this.state.videos[this.state.currentIndex]?.Id;
this.state.videos = [...this.state.originalVideos];
this.state.originalVideos = [];
if (curId) {
const origPos = this.state.videos.findIndex(v => String(v.Id) === String(curId));
if (origPos >= 0 && origPos !== this.state.currentIndex) {
[this.state.videos[origPos], this.state.videos[this.state.currentIndex]] =
[this.state.videos[this.state.currentIndex], this.state.videos[origPos]];
}
}
if (this.dom.slides) this.renderBuffer();
this.showToast('🔁 Sequence mode');
}
}
nextVideo() {
const nextIndex = this.state.currentIndex + 1;
if (nextIndex >= this.state.videos.length) {
this.showToast('You reached the last video');
return;
}
this.switchSlide(nextIndex, 'up');
}
toggleViewMode() {
const isStream = this.state.viewMode === 'stream';
this.state.viewMode = isStream ? 'grid' : 'stream';
const btn = this.dom.viewModeBtn;
btn.innerHTML = `<i data-lucide="${this.state.viewMode === 'stream' ? 'gallery-vertical' : 'layout-grid'}" class="stroke-current w-5 h-5"></i>`;
lucide.createIcons();
if (this.state.viewMode === 'grid') {
const PAGE_SIZE = 150;
if (this.state.videos.length > PAGE_SIZE) {
const pageIndex = Math.floor(this.state.currentIndex / PAGE_SIZE);
const start = pageIndex * PAGE_SIZE;
this.state.videos = this.state.videos.slice(start, start + PAGE_SIZE);
this.state.currentIndex = this.state.currentIndex % PAGE_SIZE;
this.state.startIndex = (this.state.startIndex || 0) + start;
}
const app = document.getElementById('app');
if (app) {
app.classList.remove('interface-hidden');
app.classList.add('grid-active');
if (this.csTimer) clearTimeout(this.csTimer);
}
this.dom.videoContainer.style.touchAction = 'auto';
this.renderGridView();
} else {
const app = document.getElementById('app');
if (app) app.classList.remove('grid-active');
this.dom.videoContainer.style.touchAction = 'pan-y';
this.renderSlides();
this.loadVideo(this.state.currentIndex);
this.playVideo(this.state.currentIndex);
this.showInterfaceTemp();
}
}
toggleScaleMode() {
this.state.isScaleFill = !this.state.isScaleFill;
localStorage.setItem('emby_is_scale_fill', this.state.isScaleFill);
const videos = document.querySelectorAll('.video-fullscreen');
videos.forEach((v, index) => {
v.classList.remove('object-cover', 'object-contain');
v.classList.add(this.state.isScaleFill ? 'object-cover' : 'object-contain');
const thumb = document.getElementById(`thumb-${index}`);
const poster = document.getElementById(`poster-${index}`);
const overlay = document.getElementById(`overlay-${index}`);
if (this.state.isScaleFill) {
if (thumb) thumb.style.opacity = '1';
if (poster) poster.style.opacity = '0';
if (overlay) overlay.style.opacity = '0';
} else {
if (thumb) thumb.style.opacity = '0';
if (poster) poster.style.opacity = '0.7';
if (overlay) overlay.style.opacity = '1';
}
});
const scaleBtn = this.dom.scaleBtn;
scaleBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${this.state.isScaleFill ? 'maximize-2' : 'minimize-2'}" class="stroke-white w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons();
}
async showMediaInfo() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
this.dom.mediaInfoModal.classList.remove('hidden');
this.showInterfaceTemp();
this.dom.mediaInfoContent.innerHTML = `<div class="flex justify-center py-4"><i data-lucide="loader-2" class="w-6 h-6 animate-spin text-gray-500"></i></div>`;
lucide.createIcons();
try {
const res = await fetch(`${this.config.server}/emby/Users/${this.config.userId}/Items/${item.Id}?api_key=${this.config.token}&Fields=MediaSources`);
if (!res.ok) throw new Error('API Error');
const data = await res.json();
if (data && data.MediaSources && data.MediaSources.length > 0) {
const src = data.MediaSources[0];
const c = src.Container || 'Unknown';
const vStream = src.MediaStreams?.find(s => s.Type === 'Video');
const w = vStream?.Width || '?';
const h = vStream?.Height || '?';
const fps = vStream?.AverageFrameRate ? Math.round(parseFloat(vStream.AverageFrameRate)) : '?';
const vCodec = vStream?.Codec || 'Unknown';
const aCodec = src.MediaStreams?.find(s => s.Type === 'Audio')?.Codec || 'Unknown';
const bitrate = src.Bitrate ? (src.Bitrate / 1000000).toFixed(2) + ' Mbps' : 'Unknown';
const fileSizeStr = src.Size ? (src.Size > 1024 * 1024 * 1024 ? (src.Size / (1024 * 1024 * 1024)).toFixed(1) + ' GB' : (src.Size / (1024 * 1024)).toFixed(1) + ' MB') : 'Unknown';
const transcodeState = this.config.useStatic ? 'Direct Play' : 'Auto Transcode';
this.dom.mediaInfoContent.innerHTML = `
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">Resolution</span>
<span class="col-span-2 text-gray-200 font-mono">${w}x${h}@${fps}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">Container</span>
<span class="col-span-2 text-gray-200">${c.toUpperCase()}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">Video Codec</span>
<span class="col-span-2 text-gray-200 font-mono">${vCodec.toUpperCase()}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">Audio Codec</span>
<span class="col-span-2 text-gray-200 font-mono">${aCodec.toUpperCase()}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">Total Bitrate</span>
<span class="col-span-2 text-gray-200 font-mono">${bitrate}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center border-b border-gray-700/50">
<span class="text-gray-500 text-right">File Size</span>
<span class="col-span-2 text-gray-200 font-mono">${fileSizeStr}</span>
</div>
<div class="grid grid-cols-3 gap-2 py-2 items-center">
<span class="text-gray-500 text-right">Playback</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">Failed to get media info</div>`;
}
} catch (e) {
this.dom.mediaInfoContent.innerHTML = `<div class="text-center text-red-400 py-4">Failed to fetch data</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="heart" class="${isFav ? 'text-secondary fill-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons();
this.showInterfaceTemp();
};
this.dom.scaleBtn.onclick = (e) => {
e.stopPropagation();
this.toggleScaleMode();
this.showInterfaceTemp();
};
this.dom.videoInfoArea.onclick = (e) => {
e.stopPropagation();
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden') || app.classList.contains('grid-active')) return;
this.showMediaInfo();
};
this.dom.closeMediaInfoBtn.onclick = (e) => {
e.stopPropagation();
this.dom.mediaInfoModal.classList.add('hidden');
this.showInterfaceTemp();
};
this.dom.mediaInfoModal.onclick = (e) => {
if (e.target === e.currentTarget) {
this.dom.mediaInfoModal.classList.add('hidden');
this.showInterfaceTemp();
}
};
this.dom.rightToolbar.addEventListener('click', (e) => {
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
}
});
this.dom.muteBtn.onclick = (e) => {
e.stopPropagation();
this.state.isMuted = !this.state.isMuted;
localStorage.setItem('emby_is_muted', this.state.isMuted);
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) {
v.muted = this.state.isMuted;
}
const muteBtn = this.dom.muteBtn;
muteBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="${this.state.isMuted ? 'volume-x' : 'volume-2'}" class="${this.state.isMuted ? 'text-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons();
};
this.dom.fullscreenBtn.onclick = (e) => {
e.stopPropagation();
const app = document.getElementById('app');
const isIOS = /iPad|iPhone|iPod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches;
if (isIOS && isStandalone) {
if (!app.classList.contains('ios-pwa-fullscreen')) {
app.classList.add('ios-pwa-fullscreen');
this.showInterfaceTemp();
const fullscreenBtn = this.dom.fullscreenBtn;
fullscreenBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full"><i data-lucide="minimize" class="stroke-white w-5 h-5 drop-shadow-md"></i></div>`;
lucide.createIcons();
} else {
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
} else {
app.classList.remove('ios-pwa-fullscreen');
app.classList.remove('interface-hidden');
if (this.csTimer) clearTimeout(this.csTimer);
const fullscreenBtn = this.dom.fullscreenBtn;
fullscreenBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center cursor-pointer active:scale-90 transition-transform bg-black/30 rounded-full"><i data-lucide="maximize" class="stroke-white w-5 h-5 drop-shadow-md"></i></div>`;
lucide.createIcons();
}
}
return;
}
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.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', function () {
if (this.navTimer) clearTimeout(this.navTimer);
this.classList.remove('nav-clicked');
void this.offsetWidth;
this.classList.add('nav-clicked');
this.navTimer = setTimeout(() => {
this.classList.remove('nav-clicked');
}, 1000);
});
});
document.getElementById('closeLibraryBtn').onclick = () => this.toggleModal('libraryModal', false);
const libraryModal = document.getElementById('libraryModal');
libraryModal.onclick = (e) => {
if (e.target === e.currentTarget) {
this.toggleModal('libraryModal', false);
}
};
document.getElementById('closeProfileBtn').onclick = () => this.toggleModal('profilePage', false);
document.getElementById('saveConfigBtn').onclick = () => this.saveConfig();
document.getElementById('resetConfigBtn').onclick = () => this.resetConfig();
this.dom.confirmDeleteBtn.onclick = () => this.executeDelete();
this.dom.cancelDeleteBtn.onclick = () => this.toggleModal('deleteConfirmModal', false);
this.dom.deleteConfirmModal.onclick = (e) => {
if (e.target === e.currentTarget) {
this.toggleModal('deleteConfirmModal', false);
}
};
document.getElementById('progressBarContainer').onclick = (e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v && v.duration) v.currentTime = ((e.clientX - rect.left) / rect.width) * v.duration;
};
const container = document.getElementById('app');
this.bindGestures(container);
this.bindKeyboard();
}
bindKeyboard() {
let okClickCount = 0;
let okClickTimer = null;
document.addEventListener('keydown', (e) => {
const profileActive = document.getElementById('profilePage').classList.contains('active');
const libraryActive = !document.getElementById('libraryModal').classList.contains('hidden');
const isInput = ['input', 'textarea'].includes(document.activeElement.tagName.toLowerCase());
const key = e.key;
const keyCode = e.keyCode;
const lowKey = key.toLowerCase();
if (profileActive || libraryActive || document.getElementById('app').classList.contains('grid-active')) {
const items = Array.from(document.querySelectorAll(
profileActive ? '#profilePage .focusable-item' :
libraryActive ? '#libraryModal .focusable-item' :
'#videoContainer .focusable-item'
));
let currentIndex = items.indexOf(document.activeElement);
if (!isInput && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight' ||
lowKey === 'w' || lowKey === 'a' || lowKey === 's' || lowKey === 'd' ||
[38, 40, 37, 39].includes(keyCode))) {
e.preventDefault();
if (currentIndex === -1) {
let startItem = items[0];
if (document.getElementById('app').classList.contains('grid-active')) {
startItem = items.find(it => it.dataset.index == this.state.currentIndex) || items[0];
}
startItem?.focus();
} else {
const isVideoGrid = document.getElementById('app').classList.contains('grid-active') && !profileActive && !libraryActive;
if (isVideoGrid) {
let nextIdx = currentIndex;
if (key === 'ArrowDown' || lowKey === 's' || keyCode === 40) nextIdx += 3;
else if (key === 'ArrowUp' || lowKey === 'w' || keyCode === 38) nextIdx -= 3;
else if (key === 'ArrowRight' || lowKey === 'd' || keyCode === 39) nextIdx += 1;
else if (key === 'ArrowLeft' || lowKey === 'a' || keyCode === 37) nextIdx -= 1;
if (nextIdx >= 0 && nextIdx < items.length) {
items[nextIdx].focus();
items[nextIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else if (libraryActive) {
const cur = items[currentIndex];
const curR = cur.getBoundingClientRect();
const curCx = curR.left + curR.width / 2;
const curCy = curR.top + curR.height / 2;
let dir;
if (key === 'ArrowDown' || lowKey === 's' || keyCode === 40) dir = 'down';
else if (key === 'ArrowUp' || lowKey === 'w' || keyCode === 38) dir = 'up';
else if (key === 'ArrowRight' || lowKey === 'd' || keyCode === 39) dir = 'right';
else if (key === 'ArrowLeft' || lowKey === 'a' || keyCode === 37) dir = 'left';
let best = null, bestScore = Infinity;
items.forEach(item => {
if (item === cur) return;
const r = item.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = cx - curCx, dy = cy - curCy;
if (dir === 'down' && dy <= 2) return;
if (dir === 'up' && dy >= -2) return;
if (dir === 'right' && dx <= 2) return;
if (dir === 'left' && dx >= -2) return;
const score = (dir === 'down' || dir === 'up')
? Math.abs(dy) + Math.abs(dx) * 3
: Math.abs(dx) + Math.abs(dy) * 3;
if (score < bestScore) { bestScore = score; best = item; }
});
if (best) {
best.focus();
best.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} else {
if (key === 'ArrowDown' || lowKey === 's' || key === 'ArrowRight' || lowKey === 'd' || [40, 39].includes(keyCode)) {
currentIndex = (currentIndex + 1) % items.length;
} else {
currentIndex = (currentIndex - 1 + items.length) % items.length;
}
items[currentIndex].focus();
}
}
return;
}
if (key === 'Enter' || keyCode === 13) {
if (document.activeElement && document.activeElement.classList.contains('focusable-item')) {
if (document.activeElement.tagName.toLowerCase() === 'input' && document.activeElement.type !== 'checkbox') {
return;
}
e.preventDefault();
document.activeElement.click();
return;
}
}
}
if (isInput) return;
if (key === 'ArrowUp' || lowKey === 'w' || keyCode === 38) {
e.preventDefault();
this.prevVideo();
} else if (key === 'ArrowDown' || lowKey === 's' || keyCode === 40) {
e.preventDefault();
this.nextVideo();
}
else if (key === 'ArrowLeft' || lowKey === 'a' || keyCode === 37) {
e.preventDefault();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) v.currentTime = Math.max(0, v.currentTime - 15);
this.showInterfaceTemp();
} else if (key === 'ArrowRight' || lowKey === 'd' || keyCode === 39) {
e.preventDefault();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v && v.duration) v.currentTime = Math.min(v.duration, v.currentTime + 15);
this.showInterfaceTemp();
}
else if (key === ' ' || key === 'Enter' || keyCode === 13) {
e.preventDefault();
if (key === ' ') {
this.togglePlay();
} else {
okClickCount++;
if (okClickCount === 1) {
okClickTimer = setTimeout(() => {
this.togglePlay();
okClickCount = 0;
}, 300);
} else if (okClickCount === 2) {
clearTimeout(okClickTimer);
okClickCount = 0;
this.handleDoubleTap(window.innerWidth / 2, window.innerHeight / 2);
}
}
}
else if (key.toLowerCase() === 'j' || keyCode === 18 || keyCode === 93 || keyCode === 118) {
if (key.toLowerCase() === 'j' || [18, 93, 118].includes(keyCode)) {
e.preventDefault();
this.toggleScaleMode();
this.showInterfaceTemp();
}
}
else {
if (lowKey === 'r') {
this.dom.playModeBtn.click();
} else if (lowKey === 'f') {
this.dom.fullscreenBtn.click();
} else if (lowKey === 'e') {
this.dom.viewModeBtn.click();
} else if (lowKey === 'g') {
const modal = document.getElementById('libraryModal');
if (modal && !modal.classList.contains('hidden')) {
this.toggleModal('libraryModal', false);
} else {
this.showLibraries();
this.dom.libraryBtn.classList.remove('nav-clicked');
void this.dom.libraryBtn.offsetWidth;
this.dom.libraryBtn.classList.add('nav-clicked');
setTimeout(() => this.dom.libraryBtn.classList.remove('nav-clicked'), 1000);
}
} else if (lowKey === 'i') {
const modal = document.getElementById('profilePage');
if (modal && modal.classList.contains('active')) {
this.toggleModal('profilePage', false);
} else {
this.toggleModal('profilePage', true);
this.dom.myBtn.classList.remove('nav-clicked');
void this.dom.myBtn.offsetWidth;
this.dom.myBtn.classList.add('nav-clicked');
setTimeout(() => this.dom.myBtn.classList.remove('nav-clicked'), 1000);
}
} else if (lowKey === 'v') {
const isGridActive = document.getElementById('app').classList.contains('grid-active');
if (!isGridActive) {
if (!this.dom.mediaInfoModal.classList.contains('hidden')) {
this.toggleModal('mediaInfoModal', false);
} else {
this.showMediaInfo();
}
}
} else if (lowKey === 'u') {
this.handleDoubleTap(window.innerWidth / 2, window.innerHeight / 2);
} else if (lowKey === 'm') {
this.dom.muteBtn.click();
}
}
});
}
bindGestures(container) {
let state = 'IDLE'; // IDLE, SPEED, DRAG
let x0 = 0, y0 = 0;
let t0 = 0;
let longPressTimer = null;
let clickTimer = null;
let clickCount = 0;
let targetTime = 0;
let originalTime = 0;
let gestureActive = false;
container.style.touchAction = 'none';
container.style.userSelect = 'none';
container.style.webkitUserSelect = 'none';
const speedToast = document.createElement('div');
speedToast.className = 'absolute top-[calc(env(safe-area-inset-top)+20px)] left-1/2 -translate-x-1/2 bg-black/50 backdrop-blur-sm text-white px-4 py-2 rounded-full text-sm font-bold opacity-0 transition-opacity z-50 pointer-events-none flex items-center gap-2';
speedToast.innerHTML = '<i data-lucide="fast-forward" class="w-4 h-4 text-green-400"></i><span>Playing at 2x speed</span>';
document.getElementById('app').appendChild(speedToast);
lucide.createIcons();
const startHandler = (e) => {
if (this.state.viewMode !== 'stream') { gestureActive = false; return; }
const excluded = [
'.bottom-nav',
'.bottom-info',
'.right-toolbar',
'#fullscreenBtn',
'#deleteBtn',
'#libraryModal',
'#mediaInfoModal',
'#deleteConfirmModal',
'.profile-layer',
];
if (excluded.some(sel => e.target.closest(sel))) {
gestureActive = false;
return;
}
gestureActive = true;
state = 'IDLE';
const t = e.touches ? e.touches[0] : e;
x0 = t.clientX;
y0 = t.clientY;
t0 = Date.now();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) originalTime = v.currentTime;
clearTimeout(longPressTimer);
longPressTimer = setTimeout(() => {
if (state === 'IDLE') {
state = 'SPEED';
if (v) v.playbackRate = 2.0;
speedToast.classList.remove('opacity-0');
}
}, 300);
};
const moveHandler = (e) => {
if (!gestureActive || this.state.viewMode !== 'stream') return;
const t = e.touches ? e.touches[0] : e;
const diffX = t.clientX - x0;
const diffY = t.clientY - y0;
if (Math.abs(diffX) > Math.abs(diffY)) {
if (e.cancelable) e.preventDefault();
}
if (Math.abs(diffX) > 15 || Math.abs(diffY) > 15) {
clearTimeout(longPressTimer);
if (state === 'SPEED') {
speedToast.classList.add('opacity-0');
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v) v.playbackRate = 1.0;
}
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 15) {
state = 'DRAG';
}
}
if (state === 'DRAG') {
if (e.cancelable) e.preventDefault();
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (v && v.duration) {
const ratio = diffX / (window.innerWidth / 2);
targetTime = originalTime + (ratio * v.duration);
targetTime = Math.max(0, Math.min(targetTime, v.duration));
const progressEl = document.getElementById('progressLine');
if (progressEl) progressEl.style.width = `${(targetTime / v.duration) * 100}%`;
}
}
};
const endHandler = (e) => {
if (!gestureActive || this.state.viewMode !== 'stream') return;
gestureActive = false;
clearTimeout(longPressTimer);
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (state === 'SPEED') {
if (v) v.playbackRate = 1.0;
speedToast.classList.add('opacity-0');
state = 'IDLE';
this.showInterfaceTemp();
return;
}
if (state === 'DRAG') {
if (v) v.currentTime = targetTime;
state = 'IDLE';
this.showInterfaceTemp();
return;
}
state = 'IDLE';
const t = e.changedTouches ? e.changedTouches[0] : e;
const diffX = t.clientX - x0;
const diffY = t.clientY - y0;
const time = Date.now() - t0;
if (Math.abs(diffY) > 50 && Math.abs(diffY) > Math.abs(diffX)) {
diffY < 0 ? this.nextVideo() : this.prevVideo();
return;
}
if (Math.abs(diffX) < 10 && Math.abs(diffY) < 10 && time < 300) {
clickCount++;
if (clickCount === 1) {
clickTimer = setTimeout(() => {
const app = document.getElementById('app');
if (app.classList.contains('interface-hidden')) {
app.classList.remove('interface-hidden');
this.showInterfaceTemp();
} else {
this.togglePlay();
}
clickCount = 0;
}, 250);
} else if (clickCount === 2) {
clearTimeout(clickTimer);
clickCount = 0;
const w = window.innerWidth;
if (t.clientX < w / 4) {
this.handleDoubleTapSkip('rewind', t.clientX, t.clientY);
} else if (t.clientX > w * 3 / 4) {
this.handleDoubleTapSkip('forward', t.clientX, t.clientY);
} else {
this.handleDoubleTap(t.clientX, t.clientY);
}
}
}
};
container.style.userSelect = 'none';
container.style.webkitUserSelect = 'none';
container.addEventListener('contextmenu', (e) => {
if (e.target.closest('.touch-layer') || e.target.tagName.toLowerCase() === 'video') {
e.preventDefault();
}
});
container.addEventListener('touchstart', startHandler, { passive: true });
container.addEventListener('touchmove', moveHandler, { passive: false });
let lastTouchEnd = 0;
container.addEventListener('touchend', (e) => {
lastTouchEnd = Date.now();
endHandler(e);
});
container.addEventListener('touchcancel', () => { gestureActive = false; state = 'IDLE'; clearTimeout(longPressTimer); speedToast.classList.add('opacity-0'); });
let isMouseDown = false;
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (Date.now() - lastTouchEnd < 500) return;
isMouseDown = true;
e.touches = [{ clientX: e.clientX, clientY: e.clientY }];
startHandler(e);
});
container.addEventListener('mousemove', (e) => {
if (!isMouseDown) return;
e.touches = [{ clientX: e.clientX, clientY: e.clientY }];
moveHandler(e);
});
const mouseEndHandler = (e) => {
if (!isMouseDown) return;
isMouseDown = false;
e.changedTouches = [{ clientX: e.clientX, clientY: e.clientY }];
endHandler(e);
};
container.addEventListener('mouseup', mouseEndHandler);
container.addEventListener('mouseleave', mouseEndHandler);
}
async handleDoubleTapSkip(direction, x, y) {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (!v || !v.duration) return;
const isRewind = direction === 'rewind';
if (isRewind) {
v.currentTime = Math.max(0, v.currentTime - 15);
} else {
v.currentTime = Math.min(v.duration, v.currentTime + 15);
}
this.showInterfaceTemp();
const bubble = document.createElement('div');
bubble.innerHTML = `
<div class="flex flex-col items-center justify-center bg-black/40 backdrop-blur-md text-white rounded-full w-[80px] h-[80px] shadow-2xl space-y-1">
<div class="flex items-center justify-center">
${isRewind ? '<i data-lucide="chevrons-left" class="w-6 h-6"></i>' : '<i data-lucide="chevrons-right" class="w-6 h-6"></i>'}
</div>
<span class="text-xs font-bold leading-none">15s</span>
</div>
`;
bubble.className = 'absolute pointer-events-none z-50 transform pointer-events-none transition-all duration-300 ease-out flex items-center justify-center';
bubble.style.top = `${Math.max(80, Math.min(y - 40, window.innerHeight - 80))}px`;
if (isRewind) bubble.style.left = '20px';
else bubble.style.right = '20px';
bubble.style.opacity = '0';
bubble.style.transform = `scale(0.8) ${isRewind ? 'translateX(-20px)' : 'translateX(20px)'}`;
document.getElementById('app').appendChild(bubble);
lucide.createIcons({ root: bubble });
requestAnimationFrame(() => {
bubble.style.transform = 'scale(1) translateX(0)';
bubble.style.opacity = '1';
setTimeout(() => {
bubble.style.transform = `scale(0.8) ${isRewind ? 'translateX(-20px)' : 'translateX(20px)'}`;
bubble.style.opacity = '0';
setTimeout(() => bubble.remove(), 300);
}, 400);
});
}
async handleDoubleTap(x, y) {
const heart = document.createElement('div');
heart.innerHTML = '<i data-lucide="heart" class="fill-red-500 text-red-500 w-16 h-16 drop-shadow-xl"></i>';
heart.className = 'absolute pointer-events-none z-50 transform pointer-events-none transition-all duration-700 ease-out';
const rotation = Math.floor(Math.random() * 40) - 20;
heart.style.left = `${x - 32}px`;
heart.style.top = `${y - 32}px`;
heart.style.transform = `scale(0.5) rotate(${rotation}deg)`;
heart.style.opacity = '0';
document.getElementById('app').appendChild(heart);
lucide.createIcons({ root: heart });
requestAnimationFrame(() => {
heart.style.transform = `scale(1.2) rotate(${rotation}deg)`;
heart.style.opacity = '1';
setTimeout(() => {
heart.style.transform = `scale(1) rotate(${rotation}deg) translateY(-100px)`;
heart.style.opacity = '0';
setTimeout(() => heart.remove(), 700);
}, 200);
});
const currentVideo = this.state.videos[this.state.currentIndex];
if (currentVideo && !currentVideo.UserData?.IsFavorite) {
try {
const { server, token, userId } = this.config;
const res = await fetch(`${server}/emby/Users/${userId}/FavoriteItems/${currentVideo.Id}?api_key=${token}`, { method: 'POST' });
if (res.ok) {
if (!currentVideo.UserData) currentVideo.UserData = {};
currentVideo.UserData.IsFavorite = true;
this.state.favorites.add(String(currentVideo.Id));
const favBtn = document.getElementById('favoriteBtn');
if (favBtn) {
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="heart" class="text-secondary fill-secondary w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons({ root: favBtn });
}
this.showToast('❤️ Favorited');
}
} catch (e) { console.error('Double tap favorite failed:', e); }
}
}
toggleModal(id, show) {
const el = document.getElementById(id);
if (id === 'profilePage') {
if (show) { el.classList.add('active'); this.state.currentTab = 'my'; }
else { el.classList.remove('active'); }
if (show) this.updateTabHighlight();
} else {
if (show) el.classList.remove('hidden'); else el.classList.add('hidden');
}
if (show) {
setTimeout(() => {
const allItems = Array.from(el.querySelectorAll('.focusable-item'));
const selected = allItems.find(i => i.classList.contains('bg-primary/20'));
const target = selected || allItems[0];
if (target) target.focus();
}, 300);
} else {
if (document.activeElement && document.activeElement.closest(`#${id}`)) {
document.activeElement.blur();
}
}
this.showInterfaceTemp();
}
toggleLoading(show) {
const el = this.dom.loading;
if (show) {
el.classList.remove('hidden');
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const btn = document.getElementById(`playBtn-${slideIdx}`);
if (btn) btn.classList.remove('paused');
} else {
el.classList.add('hidden');
}
}
showToast(msg, duration = 3000) {
if (this._toastTimer) clearTimeout(this._toastTimer);
const t = this.dom.toast;
t.textContent = msg;
t.classList.remove('opacity-0');
this._toastTimer = setTimeout(() => t.classList.add('opacity-0'), duration);
}
formatTime(s) {
if (!s) return '00:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
}
window.onload = () => {
lucide.createIcons();
window.app = new EmbyApp();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js').catch(() => { });
}
};
</script>
</body>
</html>
<!--
End of EmbyX.
Made with ❤️ by Juneix (https://github.com/juneix)
Keep Coding, Keep Fun.
-->