From ca664bc0e1a4ec1488139a3ce97cfa3a3364f038 Mon Sep 17 00:00:00 2001 From: juneix <81808039+juneix@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:04:00 +0800 Subject: [PATCH] v1.1 --- en/index.html | 279 +++++++++++++++++++++++++++++--------- zh/index.html | 360 ++++++++++++++++++++++++++++++++++---------------- 2 files changed, 460 insertions(+), 179 deletions(-) diff --git a/en/index.html b/en/index.html index a60ed56..253dd84 100644 --- a/en/index.html +++ b/en/index.html @@ -11,6 +11,7 @@ + @@ -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 @@
-
@@ -316,6 +316,14 @@ class="rounded text-primary bg-gray-700 border-none w-4 h-4 pointer-events-none" checked>
+
+ + +
@@ -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 = ``; - 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 = ` ${libraryName} - + ${countStr} - ${currentPage}/${totalPages} + ${currentPage}/${totalPages} ` : ''} `; @@ -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 = `${lib.Name}${isSelected ? '' : ''}`; + div.innerHTML = `${lib.Name}${isSelected ? '' : ''}`; 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 = `
`; + favBtn.innerHTML = `
`; 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 = `
`; + favBtn.innerHTML = `
`; 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 = ` +
+
+ ${isRewind ? '' : ''} +
+ 15s +
+ `; + 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 = ''; @@ -2690,7 +2843,7 @@ const favBtn = document.getElementById('favoriteBtn'); if (favBtn) { - favBtn.innerHTML = `
`; + favBtn.innerHTML = `
`; 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(() => { }); } }; diff --git a/zh/index.html b/zh/index.html index 84add78..1dcf650 100644 --- a/zh/index.html +++ b/zh/index.html @@ -11,6 +11,7 @@ + @@ -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 @@
- -
-
@@ -288,18 +287,17 @@
-
-
+

服务器配置

-
@@ -326,18 +324,26 @@
- +
+
+ + +
+ class="text-yellow-500/90 text-sm cursor-pointer flex-1 font-medium">允许删除媒体 ⚠️ +
@@ -350,13 +356,10 @@
-
+

关于项目 @@ -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 = ``; @@ -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 = ``; - 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: 每页视频数,建议 99(3 列整行)每次请求量 - // 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 = ` ${libraryName} - + ${countStr} - ${currentPage}/${totalPages} + ${currentPage}/${totalPages} ` : ''} `; 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 copy(remux 进 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 = `${lib.Name}${isSelected ? '' : ''}`; + div.innerHTML = `${lib.Name}${isSelected ? '' : ''}`; 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 = `
`; + favBtn.innerHTML = `
`; // 定向更新:只扫描 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 = `
`; + favBtn.innerHTML = `
`; 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 = ` +
+
+ ${isRewind ? '' : ''} +
+ 15s +
+ `; + 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 = `
`; + favBtn.innerHTML = `
`; 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(() => { }); } };