2774 lines
148 KiB
HTML
2774 lines
148 KiB
HTML
<!--
|
||
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="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-100 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));
|
||
}
|
||
}
|
||
</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="Reverse proxy or Self-host EmbyX for HTTP"
|
||
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="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, deleteMode: false };
|
||
|
||
this.csTimer = null;
|
||
|
||
this.dom = {};
|
||
this.init();
|
||
}
|
||
|
||
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);
|
||
} else {
|
||
setTimeout(() => this.toggleModal('profilePage', true), 500);
|
||
}
|
||
}
|
||
|
||
cacheDOM() {
|
||
const ids = [
|
||
'videoContainer', 'clickToPlayOverlay', 'loading', 'toast',
|
||
'videoInfoArea', 'videoTitle', 'videoDescription', 'progressLine', 'currentTime', 'totalTime',
|
||
'playModeBtn', 'viewModeBtn', 'libraryBtn', 'myBtn',
|
||
'favoriteBtn', 'scaleBtn', 'muteBtn', 'fullscreenBtn', 'deleteBtn',
|
||
'mediaInfoModal', 'mediaInfoContent', 'closeMediaInfoBtn',
|
||
'deleteConfirmModal', 'confirmDeleteBtn', 'cancelDeleteBtn', 'deleteFileName',
|
||
'configServer', 'configUser', 'configPwd', 'configStatic', 'configAutoplay', 'configDeleteMode'
|
||
];
|
||
ids.forEach(id => this.dom[id] = document.getElementById(id));
|
||
this.dom.rightToolbar = document.querySelector('.right-toolbar');
|
||
}
|
||
|
||
loadConfig() {
|
||
this.config.server = localStorage.getItem('emby_server') || '';
|
||
this.config.user = localStorage.getItem('emby_user') || '';
|
||
this.config.token = localStorage.getItem('emby_token') || '';
|
||
this.config.userId = localStorage.getItem('emby_uid') || '';
|
||
this.config.useStatic = localStorage.getItem('emby_static') !== 'false';
|
||
this.config.autoplay = localStorage.getItem('emby_autoplay') !== 'false';
|
||
this.config.deleteMode = localStorage.getItem('emby_delete_mode') === 'true';
|
||
|
||
// 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;
|
||
|
||
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.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() {
|
||
const server = this.dom.configServer.value.trim().replace(/\/$/, "");
|
||
const user = this.dom.configUser.value.trim();
|
||
const pwd = this.dom.configPwd.value.trim();
|
||
const isStatic = this.dom.configStatic.checked;
|
||
const autoplay = this.dom.configAutoplay.checked;
|
||
const deleteMode = this.dom.configDeleteMode.checked;
|
||
|
||
if (!server || !user) return this.showToast('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_delete_mode', deleteMode);
|
||
this.config.useStatic = isStatic;
|
||
this.config.autoplay = autoplay;
|
||
this.config.deleteMode = deleteMode;
|
||
this.state.isAutoplay = autoplay;
|
||
|
||
this.dom.deleteBtn.style.display = deleteMode ? 'flex' : 'none';
|
||
if (this.dom.playModeBtn) {
|
||
this.dom.playModeBtn.innerHTML = `<i data-lucide="${autoplay ? 'repeat' : 'repeat-1'}" class="stroke-current w-5 h-5"></i>`;
|
||
lucide.createIcons();
|
||
}
|
||
|
||
this.showToast('✅ Settings saved');
|
||
this.toggleModal('profilePage', false);
|
||
return;
|
||
}
|
||
|
||
const res = await fetch(`${server}/emby/Users/AuthenticateByName`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${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_delete_mode', deleteMode);
|
||
|
||
this.config = { ...this.config, server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, 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) {
|
||
this.toggleLoading(true);
|
||
try {
|
||
const { server, token, userId } = this.config;
|
||
if (!server || !token) {
|
||
this.showToast('Please config server first');
|
||
return;
|
||
}
|
||
|
||
if (!isLoadMore) {
|
||
this.state.startIndex = 0;
|
||
}
|
||
|
||
const libraryId = parentId || this.state.currentLibraryId;
|
||
|
||
const PAGE_SIZE = 99;
|
||
// const MAX_GRID_VIDEOS = 1000;
|
||
|
||
let url = `${server}/emby/Users/${userId}/Items?api_key=${token}&Recursive=true&IncludeItemTypes=Movie,Episode,Video&Limit=${PAGE_SIZE}&StartIndex=${this.state.startIndex || 0}&Fields=Overview,Path,RunTimeTicks,MediaSources`;
|
||
|
||
if (ids) {
|
||
url += `&Ids=${ids}`;
|
||
} else if (isRandom) {
|
||
url += `&SortBy=Random`;
|
||
if (libraryId && libraryId !== 'favorites') url += `&ParentId=${libraryId}`;
|
||
if (libraryId === 'favorites') url += `&Filters=IsFavorite`;
|
||
} else if (libraryId === 'favorites') {
|
||
url += `&SortBy=DateCreated&SortOrder=Descending&Filters=IsFavorite`;
|
||
} else if (libraryId) {
|
||
url += `&SortBy=DateCreated&SortOrder=Descending&ParentId=${libraryId}`;
|
||
} else {
|
||
url += `&SortBy=DateCreated&SortOrder=Descending`;
|
||
}
|
||
|
||
const res = await fetch(url, {
|
||
headers: {
|
||
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${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) {
|
||
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) {
|
||
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';
|
||
// Clear inline transition left by grid mode, so class-based animation works
|
||
this.dom.videoContainer.style.transition = '';
|
||
this.dom.videoContainer.style.transform = `translateY(-${this.state.currentIndex * 100}%)`;
|
||
|
||
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></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 = 99;
|
||
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 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 text-gray-400 tabular-nums">${currentPage}<span class="text-gray-600">/</span>${totalPages}</span>
|
||
<button id="gridNextBtn" class="px-1.5 py-0.5 rounded text-xs ${currentPage >= totalPages ? 'text-gray-600 pointer-events-none' : 'text-gray-300 active:scale-90 hover:text-white transition-colors'}">❯</button>
|
||
` : ''}
|
||
`;
|
||
container.appendChild(safeHeader);
|
||
|
||
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 (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="Web", 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="Web", 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="Web", 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="Web", 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;
|
||
div.setAttribute('tabindex', '0');
|
||
div.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
|
||
div.innerHTML = `<i data-lucide="film" class="w-4 h-4 flex-shrink-0"></i><span class="flex-1 truncate">${lib.Name}</span>${isSelected ? '<i data-lucide="check" class="w-3.5 h-3.5 flex-shrink-0"></i>' : ''}`;
|
||
div.onclick = () => {
|
||
this.state.currentLibraryId = lib.Id;
|
||
this.state.currentLibraryType = 'library';
|
||
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="${isFav ? 'folder-heart' : 'heart'}" class="${isFav ? 'text-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() {
|
||
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 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="${isFav ? 'folder-heart' : 'heart'}" class="${isFav ? 'text-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
|
||
lucide.createIcons();
|
||
this.showInterfaceTemp();
|
||
};
|
||
|
||
this.dom.scaleBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.toggleScaleMode();
|
||
this.showInterfaceTemp();
|
||
};
|
||
|
||
this.dom.videoInfoArea.onclick = (e) => {
|
||
e.stopPropagation();
|
||
const app = document.getElementById('app');
|
||
if (app.classList.contains('interface-hidden') || app.classList.contains('grid-active')) return;
|
||
this.showMediaInfo();
|
||
};
|
||
|
||
this.dom.closeMediaInfoBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.dom.mediaInfoModal.classList.add('hidden');
|
||
this.showInterfaceTemp();
|
||
};
|
||
|
||
this.dom.mediaInfoModal.onclick = (e) => {
|
||
if (e.target === e.currentTarget) {
|
||
this.dom.mediaInfoModal.classList.add('hidden');
|
||
this.showInterfaceTemp();
|
||
}
|
||
};
|
||
|
||
this.dom.rightToolbar.addEventListener('click', (e) => {
|
||
const app = document.getElementById('app');
|
||
if (app.classList.contains('interface-hidden')) {
|
||
app.classList.remove('interface-hidden');
|
||
this.showInterfaceTemp();
|
||
}
|
||
});
|
||
|
||
this.dom.muteBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
this.state.isMuted = !this.state.isMuted;
|
||
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;
|
||
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 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="folder-heart" class="text-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')
|
||
.then(reg => console.log('SW Registered', reg))
|
||
.catch(err => console.log('SW Failed', err));
|
||
}
|
||
};
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|
||
<!--
|
||
End of EmbyX.
|
||
Made with ❤️ by Juneix (https://github.com/juneix)
|
||
Keep Coding, Keep Fun.
|
||
--> |