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>
+245 -115
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">
@@ -46,8 +47,8 @@
}
/* 滑动容器 */
/* 优化 2: 一般情况使用 100dvh 避开移动端浏览器地址栏遮挡。如果是 PWA 模式 (standalone) 强制换回 100vh 以消除底部安全区留黑缝隙 */
.slide-container {
@apply relative w-screen overflow-hidden bg-black;
height: 100dvh;
}
@@ -62,7 +63,8 @@
@apply absolute top-0 left-0 w-full h-full z-10 overflow-hidden;
}
/* 播放/暂停按钮动画 - 修复:提高 z-index 确保在视频之上 */
/* 播放/暂停按钮动画 */
.play-pause-btn {
@apply absolute top-1/2 left-1/2 w-16 h-16 rounded-full flex items-center justify-center z-20 opacity-0 pointer-events-none transition-all duration-200 ease-out bg-black/30;
transform: translate(-50%, -50%) scale(1.5);
@@ -107,7 +109,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;
}
/* MD3 & Liquid Glass: 底部导航反馈 */
@@ -269,13 +271,10 @@
</div>
<div id="profilePage" class="profile-layer text-white">
<!-- 【透明度终极修正方案】 -->
<!-- 1. 下方专属背景层,仅在标题栏下方生效,彻底避开头部的双重背景叠加 -->
<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>
<!-- 2. 完全独立的悬浮标题栏。文字滑过此处下方时就会有毛玻璃透射感 -->
<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">
@@ -288,18 +287,17 @@
</div>
</div>
<!-- 3. 无背景的全局内容滚动层。因为无背景,所以滑到上半部分遮挡时,就会给标题栏贡献底部的文字重叠区 -->
<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))]">
<!-- mt-5 让下方的服务器配置模块整体远离顶部标题栏,留出更多呼吸感 -->
<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>服务器配置
</h2>
<div class="space-y-4 bg-gray-800/40 p-4 rounded-lg border border-gray-700/50">
<div>
<label class="text-gray-400 text-xs block mb-1.5">服务器地址</label>
<input type="text" id="configServer" placeholder="HTTP 访问请反代或自建 EmbyX"
<input type="text" id="configServer" placeholder="IP 或域名自动补全"
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">
@@ -326,18 +324,26 @@
<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">自动连播</label>
<label for="configAutoplay" class="text-gray-300 text-sm cursor-pointer flex-1">自动连播
</label>
<input type="checkbox" id="configAutoplay"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none"
checked>
</div>
<div id="rowShortDrama" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configShortDrama').click();">
<label for="configShortDrama" class="text-gray-300 text-sm cursor-pointer flex-1">短剧模式
</label>
<input type="checkbox" id="configShortDrama"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none">
</div>
<div id="rowDeleteMode" tabindex="0"
class="focusable-item flex items-center justify-between py-2.5 px-3 bg-gray-900/40 rounded-lg border border-transparent hover:bg-gray-800/60 transition-all cursor-pointer group"
onclick="document.getElementById('configDeleteMode').click();">
<label for="configDeleteMode"
class="text-yellow-500/90 text-sm cursor-pointer flex-1 font-medium">允许删除媒体
⚠️</label>
class="text-yellow-500/90 text-sm cursor-pointer flex-1 font-medium">允许删除媒体 ⚠️
</label>
<input type="checkbox" id="configDeleteMode"
class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none">
</div>
@@ -350,13 +356,10 @@
</div>
</div>
<!-- 分割线:
如果上下距离视觉不一致,可以分别修改下面的 mt-5 (距上缘) 和 mb-5 (距下缘)。
常用的数值比例: 4(16px), 6(24px), 8(32px), 10(40px)
-->
<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>关于项目
@@ -600,15 +603,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 = {}; // 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() {
@@ -661,13 +691,21 @@
// 检查配置并启动
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 原创设计@谢週五 %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',
@@ -676,7 +714,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');
@@ -690,6 +728,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';
// 生成或读取设备唯一 ID,解决多设备识别冲突问题
@@ -708,6 +747,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');
@@ -726,9 +766,10 @@
}
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
// 基于配置同步UI
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>`;
@@ -736,13 +777,27 @@
}
}
// 核心修改点:AuthenticateByName 登录逻辑
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();
// 自动补全协议头和端口
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';
// 如果是 IP 且没有端口号,补全默认的 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('请填写完整信息');
@@ -753,28 +808,21 @@
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('✅ 配置已保存');
this.toggleModal('profilePage', false);
return; // 提前退出,不重新拉取视频
// 清理数据缓存,保留核心设置并重新加载
localStorage.removeItem('emby_views_cache');
localStorage.removeItem('emby_favorites');
location.reload();
return;
}
const res = await fetch(`${server}/emby/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': `Emby Client="EmbyX", Device="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 })
});
@@ -800,9 +848,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 };
// 将密码位数存入 localStorage,不存密码本身;空密码至少显示 1 个 *
const pwdLen = Math.max(1, pwd.length);
@@ -814,9 +863,10 @@
lucide.createIcons();
}
this.showToast('✅ 配置已保存');
this.toggleModal('profilePage', false);
this.fetchVideos();
// 清理数据缓存,保留核心设置并重新加载
localStorage.removeItem('emby_views_cache');
localStorage.removeItem('emby_favorites');
location.reload();
} else {
this.showToast('❌ 认证失败:用户名或密码错误');
}
@@ -871,28 +921,31 @@
}
// --- 核心播放逻辑 ---
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); // 追加时不显示全屏 loading
try {
const { server, token, userId } = this.config;
if (!server || !token) {
this.showToast('请先配置服务器');
if (!isAppend) this.showToast('请先配置服务器');
return;
}
if (!isLoadMore) {
if (!isLoadMore && !isAppend) {
this.state.startIndex = 0;
}
const libraryId = parentId || this.state.currentLibraryId;
// --- 分页参数 ---
// PAGE_SIZE: 每页视频数,建议 993 列整行)每次请求量
// MAX_GRID_VIDEOS: 格子视图展示上限,超出部分通过翻页访问
const PAGE_SIZE = 99; // 可调整,建议保持 3 的倍数
// const MAX_GRID_VIDEOS = 1000; // 硬性上限,目前由 API TotalRecordCount 控制,留作后期限速使用
// PAGE_SIZE: 每页视频数,150 为体验甜点值
const PAGE_SIZE = 150;
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}`;
@@ -904,16 +957,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) {
@@ -922,25 +975,33 @@
let data = await res.json();
if (data && data.Items && data.Items.length > 0) {
this.state.videos = data.Items;
this.state.currentIndex = 0;
// 保存媒体库真实总数(API 返回,不受 Limit 影响)供分页显示
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; // 追加状态静默退出,不重置数据和 UI
} else {
this.renderSlides();
this.loadVideo(0);
this.state.videos = data.Items;
this.state.currentIndex = 0;
// 保存媒体库真实总数(API 返回,不受 Limit 影响)供分页显示
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('没有更多视频了');
} else if (isLoadMore || isAppend) {
if (!isAppend) this.showToast('没有更多视频了');
// 已到最后一页,不循环
this.state.startIndex = Math.max(0, (this.state.startIndex || 0));
this.toggleLoading(false);
@@ -957,15 +1018,17 @@
}
}
/**
* 核心修改点:渲染视频卡片结构 (三窗格复用 DOM)
*/
renderSlides() {
this.dom.videoContainer.innerHTML = '';
this.dom.videoContainer.className = 'relative w-full h-full transition-transform duration-300 ease-out';
// 清除格子视图残留的内联 transition,防止覆盖 class 里的过渡动画
this.dom.videoContainer.style.transition = '';
// 暂时剥夺动画权限,执行无缝硬切瞬移
this.dom.videoContainer.style.transition = 'none';
this.dom.videoContainer.style.transform = `translateY(-${this.state.currentIndex * 100}%)`;
// 强制浏览器重绘,应用上面的瞬移坐标
void this.dom.videoContainer.offsetHeight;
// 复原过渡动画,供后续手指上下滑动使用
this.dom.videoContainer.style.transition = '';
// 只初始化 3 个物理槽位
for (let i = 0; i < 3; i++) {
@@ -1059,7 +1122,7 @@
}
}
const PAGE_SIZE = 99; // 与 fetchVideos 中保持一致
const PAGE_SIZE = 150; // 与 fetchVideos 中保持一致
const totalCount = this.state.totalCount || 0;
const startIndex = this.state.startIndex || 0;
const currentPage = Math.floor(startIndex / PAGE_SIZE) + 1;
@@ -1076,7 +1139,7 @@
// 全局统一布局: [媒体库名] [count ✨] (若有多页则右侧添加 [ x/n ❯])
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="随机换一批" class="active:scale-90 transition-transform p-[2px] bg-white/10 rounded-full hover:bg-white/20">
@@ -1086,13 +1149,13 @@
<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>
` : ''}
`;
container.appendChild(safeHeader);
// 换一批按钮:所有模式都有,随机获取 99
// 换一批按钮:所有模式都有,随机获取 150
const shuffleBtn = document.getElementById('gridShuffleBtn');
if (shuffleBtn) {
const curLibId = this.state.currentLibraryId;
@@ -1528,17 +1591,7 @@
}
}
// ── HLS 转码 URL 构造器(全平台通用)────────────────────────────────
// MediaSourceId、PlaySessionId 是 Emby 转码 API 的必传参数,缺少会导致 500
// 修改规格:如需调整码率上限,修改 VideoBitrate(单位 bps,默认 20Mbps
//
// 【智能转码策略说明】
// VideoCodec=h264(只声明支持 h264):
// → 源文件是 h264?允许 stream copyremux 进 HLS 分片,不重编)→ 高效
// → 源文件是 hevc/av1?Emby 判断客户端不支持,自动转码为 h264 → 触发硬件加速
// AllowVideoStreamCopy=true
// 明确允许 h264 直接 remux,避免 h264 套娃重编(节省 GPU/CPU
// 注意:EnableAutoStreamCopy 不加,它会绕过 HLS 分片逻辑直接走流传输
// HLS master.m3u8 构造逻辑,处理 MediaSource 兼容性
_buildHlsSrc(id, mediaItem) {
const { server, token } = this.config;
const mediaSourceId = mediaItem?.MediaSources?.[0]?.Id || '';
@@ -1601,7 +1654,8 @@
this.reportPlayback('stopped', curV);
}
// 【动画修复】先同步预就位目标 slide,再同步触发 container 位移动画
// 同步预就位目标 slide,再同步触发 container 位移动画
// 这样浏览器能在同一帧内确认 slide 位置,动画由 GPU 合成层完成,不会闪现
if (this.dom.slides) {
const destSlideIdx = (newIndex % 3 + 3) % 3;
@@ -1615,13 +1669,21 @@
this.state.currentIndex = newIndex;
// 立即触发 CSS 过渡动画(container 位移)——不等 rAF
if (newIndex >= this.state.videos.length - 5 && this.state.videos.length < (this.state.totalCount || 999999)) {
if (!this._fetchingAppend) {
this._fetchingAppend = true;
this.fetchVideos(null, null, false, this.state.playMode === 'random', true).finally(() => { this._fetchingAppend = false; });
}
}
if (this.dom.videoContainer) {
this.dom.videoContainer.style.transform = `translateY(-${newIndex * 100}%)`;
}
// 视频加载(重操作)推到下一帧,不阻塞动画首帧渲染
requestAnimationFrame(() => {
this.loadVideo(newIndex);
this.playVideo(newIndex);
});
@@ -1631,15 +1693,15 @@
if (!this.config.server || !this.config.token || this.state.videos.length === 0) return;
const now = Date.now();
// 进度汇报增加 5s 节流,避免频繁请求,playing 和 stopped 立即执行
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 识别修正:1. 映射标准事件名 2. 补全 MediaSourceId 3. 首次播放进行能力上报
const eventMap = { 'playing': 'TimeUpdate', 'progress': 'TimeUpdate', 'stopped': 'Stopped' };
const eventName = videoEl.paused ? 'Pause' : (eventMap[event] || 'TimeUpdate');
const mediaSourceId = videoData.MediaSources?.[0]?.Id || '';
@@ -1671,7 +1733,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(() => { });
@@ -1684,7 +1746,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"],
@@ -1714,9 +1776,9 @@
}
// ── 设备兼容性判定 (苹果生态历史遗留问题处理) ─────────────────────
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);
// 特殊拦截:如果是 iOS 且是 hevc 编码且标签是苹果不支持的 hev1 (Safari 仅原生支持 hvc1)
// 这种视频直连(Static)必只有声音无画面,主动帮它走“直接串流(Direct Stream)”进行容器修正
if (isIOS && codec === 'hevc' && codecTag === 'hev1') {
@@ -1770,14 +1832,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: [] };
@@ -1843,9 +1905,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';
@@ -1940,7 +2008,7 @@
const favBtn = document.getElementById('favoriteBtn');
// 以 Emby API 返回的 UserData.IsFavorite 为单一数据源
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>`;
// 定向更新:只扫描 favBtn 内部,不全局扫描 DOM
lucide.createIcons({ nameAttr: 'data-lucide', nodes: [favBtn] });
}
@@ -2010,8 +2078,8 @@
if (this.state.currentIndex >= this.state.videos.length) {
this.state.currentIndex = Math.max(0, this.state.videos.length - 1);
}
// 修复:renderSlides 只重建 DOM,必须再调用 loadVideo+playVideo 才能自动播放
this.renderSlides();
if (this.state.videos.length > 0) {
this.loadVideo(this.state.currentIndex);
this.playVideo(this.state.currentIndex);
@@ -2057,6 +2125,10 @@
}
togglePlayMode() {
if (this.config.shortDrama) {
this.showToast('ℹ️ 短剧模式已锁定顺序播放');
return;
}
const isSeq = this.state.playMode === 'sequence';
this.state.playMode = isSeq ? 'random' : 'sequence';
localStorage.setItem('emby_play_mode', this.state.playMode);
@@ -2113,6 +2185,16 @@
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');
@@ -2242,7 +2324,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();
};
@@ -2798,7 +2880,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);
}
}
}
};
@@ -2851,6 +2940,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) {
// 1. 点赞动画 DOM 生成
@@ -2895,7 +3027,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('❤️ 已收藏');
@@ -2971,9 +3103,7 @@
// 注册 Service Worker 以满足安卓 PWA 安装要求
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>