This commit is contained in:
juneix
2026-04-21 17:04:00 +08:00
parent 2ea6d989a3
commit ca664bc0e1
2 changed files with 460 additions and 179 deletions
+215 -64
View File
@@ -11,6 +11,7 @@
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#000000">
<meta name="referrer" content="no-referrer">
@@ -97,7 +98,7 @@
}
.progress-line {
@apply h-full bg-white transition-[width] duration-100 ease-linear;
@apply h-full bg-white transition-[width] duration-[250ms] ease-linear;
}
.nav-btn {
@@ -282,8 +283,7 @@
<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"
<input type="text" id="configServer" placeholder="Auto-completion of IP or domain name"
class="w-full bg-gray-900 text-white text-sm rounded px-3 py-2.5 outline-none border border-gray-700 focus:border-primary transition-colors focusable-item">
</div>
<div class="grid grid-cols-2 gap-2">
@@ -316,6 +316,14 @@
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none"
checked>
</div>
<div id="rowShortDrama" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configShortDrama').click();">
<label for="configShortDrama" class="text-gray-300 text-sm cursor-pointer flex-1">Short
Drama Mode</label>
<input type="checkbox" id="configShortDrama"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none">
</div>
<div id="rowDeleteMode" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configDeleteMode').click();">
@@ -572,14 +580,42 @@
originalVideos: []
};
this.config = { server: '', user: '', token: '', userId: '', useStatic: true, autoplay: true, deleteMode: false };
this.config = { server: '', user: '', token: '', userId: '', useStatic: true, autoplay: true, shortDrama: false, deleteMode: false };
this.csTimer = null;
this.dom = {};
this.config.deviceName = this.getDeviceName();
this.init();
}
getDeviceName() {
const ua = navigator.userAgent;
if (/android/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
if (match && match[1]) {
const parts = match[1].split(';');
for (let p of parts) {
p = p.trim();
if (/^linux/i.test(p) || /^u$/i.test(p) || /^android/i.test(p) || /^[a-z]{2}-[a-z]{2}$/i.test(p) || /wv/.test(p)) continue;
const model = p.split('Build/')[0].trim();
if (model) return `Android (${model})`;
}
}
return "Android";
} else if (/ipad|macintosh/i.test(ua) && navigator.maxTouchPoints > 1) {
return "iPad";
} else if (/iphone/i.test(ua)) {
return "iPhone";
} else if (/macintosh/i.test(ua)) {
return "macOS";
} else if (/windows/i.test(ua)) {
return "Windows";
}
return "Web Browser";
}
isAnyModalOpen() {
const modals = ['mediaInfoModal', 'deleteConfirmModal', 'profilePage', 'libraryModal'];
return modals.some(id => {
@@ -625,12 +661,21 @@
lucide.createIcons();
if (this.config.server && this.config.token) {
this.fetchVideos(this.state.currentLibraryId);
this.fetchVideos(this.state.currentLibraryId, null, false, this.state.playMode === 'random');
} else {
setTimeout(() => this.toggleModal('profilePage', true), 500);
}
// ─── Branding Egg ───
console.log(
"%c EmbyX %c Designed by Juneix %c",
"background:#3b82f6;color:#fff;padding:2px 6px;border-radius:4px 0 0 4px;font-weight:bold;",
"background:#1e293b;color:#fff;padding:2px 6px;border-radius:0 4px 4px 0;",
"background:transparent"
);
}
cacheDOM() {
const ids = [
'videoContainer', 'clickToPlayOverlay', 'loading', 'toast',
@@ -639,7 +684,7 @@
'favoriteBtn', 'scaleBtn', 'muteBtn', 'fullscreenBtn', 'deleteBtn',
'mediaInfoModal', 'mediaInfoContent', 'closeMediaInfoBtn',
'deleteConfirmModal', 'confirmDeleteBtn', 'cancelDeleteBtn', 'deleteFileName',
'configServer', 'configUser', 'configPwd', 'configStatic', 'configAutoplay', 'configDeleteMode'
'configServer', 'configUser', 'configPwd', 'configStatic', 'configAutoplay', 'configShortDrama', 'configDeleteMode'
];
ids.forEach(id => this.dom[id] = document.getElementById(id));
this.dom.rightToolbar = document.querySelector('.right-toolbar');
@@ -652,6 +697,7 @@
this.config.userId = localStorage.getItem('emby_uid') || '';
this.config.useStatic = localStorage.getItem('emby_static') !== 'false';
this.config.autoplay = localStorage.getItem('emby_autoplay') !== 'false';
this.config.shortDrama = localStorage.getItem('emby_shortdrama') === 'true';
this.config.deleteMode = localStorage.getItem('emby_delete_mode') === 'true';
// Unique Device ID for session management
@@ -669,6 +715,7 @@
const savedPlayMode = localStorage.getItem('emby_play_mode');
if (savedPlayMode !== null) this.state.playMode = savedPlayMode;
if (this.config.shortDrama) this.state.playMode = 'sequence';
const savedLibId = localStorage.getItem('emby_lib_id');
const savedLibType = localStorage.getItem('emby_lib_type');
@@ -685,6 +732,7 @@
}
if (this.dom.configStatic) this.dom.configStatic.checked = this.config.useStatic;
if (this.dom.configAutoplay) this.dom.configAutoplay.checked = this.config.autoplay;
if (this.dom.configShortDrama) this.dom.configShortDrama.checked = this.config.shortDrama;
if (this.dom.configDeleteMode) this.dom.configDeleteMode.checked = this.config.deleteMode;
// Sync UI based on config
@@ -696,34 +744,43 @@
}
async saveConfig() {
const server = this.dom.configServer.value.trim().replace(/\/$/, "");
let server = this.dom.configServer.value.trim().replace(/\/$/, "");
const user = this.dom.configUser.value.trim();
const pwd = this.dom.configPwd.value.trim();
// Auto-fill protocol and port
if (server && !server.startsWith('http')) {
const host = server.split(':')[0];
const isIP = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(host) || host === 'localhost' || host === '127.0.0.1';
// If IP and no port specified, append default 8096
if (isIP && !server.includes(':')) {
server += ':8096';
}
server = (isIP ? 'http://' : 'https://') + server;
}
const isStatic = this.dom.configStatic.checked;
const autoplay = this.dom.configAutoplay.checked;
const shortDrama = this.dom.configShortDrama.checked;
const deleteMode = this.dom.configDeleteMode.checked;
if (!server || !user) return this.showToast('Please fill in all fields');
this.toggleLoading(true);
try {
if (/^\*+$/.test(pwd) && this.config.token && this.config.server === server && this.config.user === user) {
localStorage.setItem('emby_static', isStatic);
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_shortdrama', shortDrama);
localStorage.setItem('emby_delete_mode', deleteMode);
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);
// Clear data cache, keep core settings and reload
localStorage.removeItem('emby_views_cache');
localStorage.removeItem('emby_favorites');
location.reload();
return;
}
@@ -731,7 +788,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({ Username: user, Pw: pwd })
});
@@ -755,9 +812,10 @@
localStorage.setItem('emby_uid', data.SessionInfo.UserId);
localStorage.setItem('emby_static', isStatic);
localStorage.setItem('emby_autoplay', autoplay);
localStorage.setItem('emby_shortdrama', shortDrama);
localStorage.setItem('emby_delete_mode', deleteMode);
this.config = { ...this.config, server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, deleteMode };
this.config = { ...this.config, server, user, token: data.AccessToken, userId: data.SessionInfo.UserId, useStatic: isStatic, autoplay, shortDrama, deleteMode };
const pwdLen = Math.max(1, pwd.length);
localStorage.setItem('emby_pwd_len', pwdLen);
@@ -822,25 +880,30 @@
}
}
async fetchVideos(parentId = null, ids = null, isLoadMore = false, isRandom = false) {
this.toggleLoading(true);
async fetchVideos(parentId = null, ids = null, isLoadMore = false, isRandom = false, isAppend = false) {
this.toggleLoading(!isAppend);
try {
const { server, token, userId } = this.config;
if (!server || !token) {
this.showToast('Please config server first');
if (!isAppend) this.showToast('Please config server first');
return;
}
if (!isLoadMore) {
if (!isLoadMore && !isAppend) {
this.state.startIndex = 0;
}
const libraryId = parentId || this.state.currentLibraryId;
const PAGE_SIZE = 99;
const PAGE_SIZE = 150;
// 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`;
let fetchStartIndex = this.state.startIndex || 0;
if (isAppend) {
fetchStartIndex += this.state.videos.length;
}
let url = `${server}/emby/Users/${userId}/Items?api_key=${token}&Recursive=true&IncludeItemTypes=Movie,Episode,Video,MusicVideo&Limit=${PAGE_SIZE}&StartIndex=${fetchStartIndex}&Fields=Overview,Path,RunTimeTicks,MediaSources`;
if (ids) {
url += `&Ids=${ids}`;
@@ -851,14 +914,16 @@
} else if (libraryId === 'favorites') {
url += `&SortBy=DateCreated&SortOrder=Descending&Filters=IsFavorite`;
} else if (libraryId) {
url += `&SortBy=DateCreated&SortOrder=Descending&ParentId=${libraryId}`;
const sortStr = this.config.shortDrama ? 'SortBy=Path&SortOrder=Ascending' : 'SortBy=DateCreated&SortOrder=Descending';
url += `&${sortStr}&ParentId=${libraryId}`;
} else {
url += `&SortBy=DateCreated&SortOrder=Descending`;
const sortStr = this.config.shortDrama ? 'SortBy=Path&SortOrder=Ascending' : 'SortBy=DateCreated&SortOrder=Descending';
url += `&${sortStr}`;
}
const res = await fetch(url, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
if (!res.ok) {
@@ -867,23 +932,31 @@
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();
if (isAppend) {
this.state.videos.push(...data.Items);
if (this.state.playMode === 'random' && this.state.originalVideos) {
this.state.originalVideos.push(...data.Items);
}
return;
} else {
this.renderSlides();
this.loadVideo(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');
} else if (isLoadMore || isAppend) {
if (!isAppend) this.showToast('No more videos');
this.state.startIndex = Math.max(0, (this.state.startIndex || 0));
this.toggleLoading(false);
return;
@@ -902,9 +975,13 @@
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 = '';
// Temporarily disable transition for instant snap
this.dom.videoContainer.style.transition = 'none';
this.dom.videoContainer.style.transform = `translateY(-${this.state.currentIndex * 100}%)`;
// Force browser reflow to apply the instant transform
void this.dom.videoContainer.offsetHeight;
// Restore transition for subsequent manual swipes
this.dom.videoContainer.style.transition = '';
for (let i = 0; i < 3; i++) {
const el = document.createElement('div');
@@ -990,7 +1067,7 @@
}
}
const PAGE_SIZE = 99;
const PAGE_SIZE = 150;
const totalCount = this.state.totalCount || 0;
const startIndex = this.state.startIndex || 0;
const currentPage = Math.floor(startIndex / PAGE_SIZE) + 1;
@@ -1005,7 +1082,7 @@
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">
<span class="text-xs font-bold text-primary/70 tabular-nums flex items-center">
${countStr}
<span class="w-1.5"></span>
<button id="gridShuffleBtn" title="Shuffle Videos" class="active:scale-90 transition-transform p-[2px] bg-white/10 rounded-full hover:bg-white/20">
@@ -1015,7 +1092,7 @@
<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>
<span class="text-xs font-bold text-white tabular-nums">${currentPage}<span class="px-0.5">/</span>${totalPages}</span>
<button id="gridNextBtn" class="px-1.5 py-0.5 rounded text-xs ${currentPage >= totalPages ? 'text-gray-600 pointer-events-none' : 'text-gray-300 active:scale-90 hover:text-white transition-colors'}"></button>
` : ''}
`;
@@ -1505,6 +1582,13 @@
this.state.currentIndex = newIndex;
if (newIndex >= this.state.videos.length - 5 && this.state.videos.length < (this.state.totalCount || 999999)) {
if (!this._fetchingAppend) {
this._fetchingAppend = true;
this.fetchVideos(null, null, false, this.state.playMode === 'random', true).finally(() => { this._fetchingAppend = false; });
}
}
if (this.dom.videoContainer) {
this.dom.videoContainer.style.transform = `translateY(-${newIndex * 100}%)`;
}
@@ -1558,7 +1642,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify(payload)
}).catch(() => { });
@@ -1571,7 +1655,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
},
body: JSON.stringify({
PlayableMediaTypes: ["Video"],
@@ -1599,8 +1683,8 @@
}
// -- Device Compatibility Check for Apple hev1 issue --
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
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.
@@ -1652,14 +1736,14 @@
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}"`
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const playlistsData = await playlistsRes.json();
const viewsRes = await fetch(`${this.config.server}/emby/Users/${userId}/Views?api_key=${this.config.token}`, {
headers: {
'X-Emby-Authorization': `Emby Client="EmbyX", Device="Web", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
'X-Emby-Authorization': `Emby Client="EmbyX", Device="${this.config.deviceName}", DeviceId="${this.config.deviceId}", Version="${this.config.appVersion}"`
}
});
const viewsDataRaw = viewsRes.ok ? await viewsRes.json() : { Items: [] };
@@ -1721,9 +1805,15 @@
filteredViews.forEach(lib => {
const div = document.createElement('div');
const isSelected = lib.Id === this.state.currentLibraryId;
let iconName = 'clapperboard';
if (lib.CollectionType === 'tvshows') iconName = 'tv';
else if (lib.CollectionType === 'homevideos') iconName = 'video';
else if (lib.CollectionType === 'musicvideos') iconName = 'cassette-tape';
else if (lib.CollectionType === 'boxsets') iconName = 'archive';
div.setAttribute('tabindex', '0');
div.className = `focusable-item col-span-1 p-2.5 rounded cursor-pointer text-sm flex items-center gap-2 ${isSelected ? 'bg-primary/20 text-primary font-bold border border-primary/50' : 'bg-white/10 text-gray-300 hover:bg-white/20'}`;
div.innerHTML = `<i data-lucide="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.innerHTML = `<i data-lucide="${iconName}" class="w-4 h-4 flex-shrink-0"></i><span class="flex-1 truncate">${lib.Name}</span>${isSelected ? '<i data-lucide="check" class="w-3.5 h-3.5 flex-shrink-0"></i>' : ''}`;
div.onclick = () => {
this.state.currentLibraryId = lib.Id;
this.state.currentLibraryType = 'library';
@@ -1810,7 +1900,7 @@
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>`;
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="heart" class="${isFav ? 'text-secondary fill-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons({ nameAttr: 'data-lucide', nodes: [favBtn] });
}
}
@@ -1916,6 +2006,10 @@
}
togglePlayMode() {
if (this.config.shortDrama) {
this.showToast('️ Sequence play locked in Short Drama mode');
return;
}
const isSeq = this.state.playMode === 'sequence';
this.state.playMode = isSeq ? 'random' : 'sequence';
localStorage.setItem('emby_play_mode', this.state.playMode);
@@ -1965,6 +2059,15 @@
lucide.createIcons();
if (this.state.viewMode === 'grid') {
const PAGE_SIZE = 150;
if (this.state.videos.length > PAGE_SIZE) {
const pageIndex = Math.floor(this.state.currentIndex / PAGE_SIZE);
const start = pageIndex * PAGE_SIZE;
this.state.videos = this.state.videos.slice(start, start + PAGE_SIZE);
this.state.currentIndex = this.state.currentIndex % PAGE_SIZE;
this.state.startIndex = (this.state.startIndex || 0) + start;
}
const app = document.getElementById('app');
if (app) {
app.classList.remove('interface-hidden');
@@ -2088,7 +2191,7 @@
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>`;
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="heart" class="${isFav ? 'text-secondary fill-secondary' : 'stroke-white'} w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons();
this.showInterfaceTemp();
};
@@ -2606,7 +2709,14 @@
} else if (clickCount === 2) {
clearTimeout(clickTimer);
clickCount = 0;
this.handleDoubleTap(t.clientX, t.clientY);
const w = window.innerWidth;
if (t.clientX < w / 4) {
this.handleDoubleTapSkip('rewind', t.clientX, t.clientY);
} else if (t.clientX > w * 3 / 4) {
this.handleDoubleTapSkip('forward', t.clientX, t.clientY);
} else {
this.handleDoubleTap(t.clientX, t.clientY);
}
}
}
};
@@ -2653,6 +2763,49 @@
container.addEventListener('mouseleave', mouseEndHandler);
}
async handleDoubleTapSkip(direction, x, y) {
const slideIdx = (this.state.currentIndex % 3 + 3) % 3;
const v = document.getElementById(`video-p${slideIdx}`);
if (!v || !v.duration) return;
const isRewind = direction === 'rewind';
if (isRewind) {
v.currentTime = Math.max(0, v.currentTime - 15);
} else {
v.currentTime = Math.min(v.duration, v.currentTime + 15);
}
this.showInterfaceTemp();
const bubble = document.createElement('div');
bubble.innerHTML = `
<div class="flex flex-col items-center justify-center bg-black/40 backdrop-blur-md text-white rounded-full w-[80px] h-[80px] shadow-2xl space-y-1">
<div class="flex items-center justify-center">
${isRewind ? '<i data-lucide="chevrons-left" class="w-6 h-6"></i>' : '<i data-lucide="chevrons-right" class="w-6 h-6"></i>'}
</div>
<span class="text-xs font-bold leading-none">15s</span>
</div>
`;
bubble.className = 'absolute pointer-events-none z-50 transform pointer-events-none transition-all duration-300 ease-out flex items-center justify-center';
bubble.style.top = `${Math.max(80, Math.min(y - 40, window.innerHeight - 80))}px`;
if (isRewind) bubble.style.left = '20px';
else bubble.style.right = '20px';
bubble.style.opacity = '0';
bubble.style.transform = `scale(0.8) ${isRewind ? 'translateX(-20px)' : 'translateX(20px)'}`;
document.getElementById('app').appendChild(bubble);
lucide.createIcons({ root: bubble });
requestAnimationFrame(() => {
bubble.style.transform = 'scale(1) translateX(0)';
bubble.style.opacity = '1';
setTimeout(() => {
bubble.style.transform = `scale(0.8) ${isRewind ? 'translateX(-20px)' : 'translateX(20px)'}`;
bubble.style.opacity = '0';
setTimeout(() => bubble.remove(), 300);
}, 400);
});
}
async handleDoubleTap(x, y) {
const heart = document.createElement('div');
heart.innerHTML = '<i data-lucide="heart" class="fill-red-500 text-red-500 w-16 h-16 drop-shadow-xl"></i>';
@@ -2690,7 +2843,7 @@
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>`;
favBtn.innerHTML = `<div class="w-12 h-12 flex items-center justify-center"><i data-lucide="heart" class="text-secondary fill-secondary w-6 h-6 drop-shadow-md"></i></div>`;
lucide.createIcons({ root: favBtn });
}
this.showToast('❤️ Favorited');
@@ -2758,9 +2911,7 @@
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));
navigator.serviceWorker.register('./sw.js').catch(() => { });
}
};
</script>