Retire cancelled playback sessions and preserve VOD content time
Adapted from MikaelKW/nodecast-tv-plus playback lease work beginning at 43a58c7685f541498663b827c4b9d6b3753e7202. Reject superseded requests, release on cancellation, bound live restart attempts and await FFmpeg close before deleting cache. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
@ -1097,8 +1097,9 @@
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/api.js?v=auth-2"></script>
|
||||
<script src="/js/components/PlaybackSessions.js?v=1"></script>
|
||||
<script src="/js/components/VodDuration.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=2"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=3"></script>
|
||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||
@ -1108,7 +1109,7 @@
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=3"></script>
|
||||
<script src="/js/app.js?v=5"></script>
|
||||
|
||||
</body>
|
||||
|
||||
@ -220,6 +220,8 @@ class App {
|
||||
logoutLink.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
this.player.stop();
|
||||
this.pages.watch.stop();
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
|
||||
localStorage.removeItem('authToken');
|
||||
|
||||
52
public/js/components/PlaybackSessions.js
Normal file
52
public/js/components/PlaybackSessions.js
Normal file
@ -0,0 +1,52 @@
|
||||
(function (root) {
|
||||
const cancelled = () => Object.assign(new Error('Playback was superseded'), { name: 'AbortError' });
|
||||
class PlaybackSessions {
|
||||
constructor(isLive) {
|
||||
this.isLive = isLive;
|
||||
this.leaseId = null;
|
||||
this.sequence = 0;
|
||||
this.controller = null;
|
||||
this.timer = null;
|
||||
root.addEventListener?.('pagehide', () => this.stop());
|
||||
}
|
||||
async create(url, options) {
|
||||
this.leaseId ||= root.crypto.randomUUID?.() || Array.from(root.crypto.getRandomValues(new Uint8Array(16)),
|
||||
byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
const leaseId = this.leaseId, sequence = ++this.sequence;
|
||||
this.controller?.abort();
|
||||
const controller = this.controller = new AbortController();
|
||||
const response = await fetch('/api/transcode/session', {
|
||||
method: 'POST', credentials: 'same-origin', signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...options, url, isLive: this.isLive, playbackLeaseId: leaseId, requestSequence: sequence })
|
||||
});
|
||||
const session = await response.json();
|
||||
if (controller.signal.aborted || leaseId !== this.leaseId || sequence !== this.sequence) {
|
||||
if (session.sessionId) fetch(`/api/transcode/${session.sessionId}`, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||
throw cancelled();
|
||||
}
|
||||
if (!response.ok) throw new Error(session.error || 'Unable to start playback');
|
||||
this.timer ||= setInterval(() => {
|
||||
if (this.leaseId) fetch('/api/transcode/lease/heartbeat', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playbackLeaseId: this.leaseId })
|
||||
}).catch(() => {});
|
||||
}, 30000);
|
||||
return session;
|
||||
}
|
||||
stop() {
|
||||
const leaseId = this.leaseId;
|
||||
this.leaseId = null;
|
||||
this.sequence++;
|
||||
this.controller?.abort();
|
||||
this.controller = null;
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
if (leaseId) fetch('/api/transcode/lease', {
|
||||
method: 'DELETE', keepalive: true, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playbackLeaseId: leaseId })
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
root.PlaybackSessions = PlaybackSessions;
|
||||
}(typeof window === 'object' ? window : globalThis));
|
||||
@ -813,38 +813,29 @@ class VideoPlayer {
|
||||
* Start a HLS transcode session
|
||||
*/
|
||||
async startTranscodeSession(url, options = {}) {
|
||||
const generation = this.playbackGeneration;
|
||||
this.playbackSessions ||= new PlaybackSessions(true);
|
||||
const sessionOptions = options;
|
||||
try {
|
||||
console.log('[Player] Starting HLS transcode session...', options);
|
||||
const res = await fetch('/api/transcode/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, ...options })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to start session');
|
||||
const session = await res.json();
|
||||
const session = await this.playbackSessions.create(url, sessionOptions);
|
||||
if (generation !== this.playbackGeneration) return null;
|
||||
this.currentSessionId = session.sessionId;
|
||||
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
console.error('[Player] Session start failed:', err);
|
||||
// Fallback to direct transcode if session fails
|
||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError' && generation === this.playbackGeneration) {
|
||||
this.stopTranscodeSession();
|
||||
console.error('Unable to start playback:', error.message);
|
||||
this.showError('Unable to start transcoding. Please try again.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and cleanup current transcode session
|
||||
*/
|
||||
async stopTranscodeSession() {
|
||||
if (this.currentSessionId) {
|
||||
console.log('[Player] Stopping transcode session:', this.currentSessionId);
|
||||
try {
|
||||
// Fire and forget cleanup
|
||||
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error('Failed to stop session:', err);
|
||||
}
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
stopTranscodeSession() {
|
||||
this.playbackSessions?.stop();
|
||||
this.currentSessionId = null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -920,6 +911,7 @@ class VideoPlayer {
|
||||
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
videoMode,
|
||||
videoCodec: info.video,
|
||||
@ -927,6 +919,7 @@ class VideoPlayer {
|
||||
audioChannels: info.audioChannels,
|
||||
videoHeight: info.height
|
||||
});
|
||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||
this.currentUrl = playlistUrl; // Update currentUrl for HLS reload
|
||||
|
||||
this.playHls(playlistUrl);
|
||||
@ -960,48 +953,20 @@ class VideoPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
// CHECK: Force Video Transcode (Full) or Upscaling
|
||||
if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) {
|
||||
const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
|
||||
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
// Load HLS
|
||||
this.updateNowPlaying(channel, 'Transcoding (Video)');
|
||||
// ... (rest is same logic flow, simplified by just falling through to playHls call if I refactored)
|
||||
// But for minimize drift, I'll copy the block logic for HLS playback init
|
||||
// Actually, I can just fall through if I set looksLikeHls = true?
|
||||
// No, play logic is sequential.
|
||||
if (Hls.isSupported()) {
|
||||
// Start HLS
|
||||
// ... this repeats code. I should probably just set currentUrl and let HLS block handle?
|
||||
// But HLS block is lower down.
|
||||
// I will just execute the HLS init here as before.
|
||||
|
||||
// Actually, easiest way is to re-assign streamUrl and goto start? No.
|
||||
// Copy existing forceTranscode block logic
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
this.hls = new Hls();
|
||||
this.hls.loadSource(playlistUrl);
|
||||
this.hls.attachMedia(this.video);
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(console.error);
|
||||
});
|
||||
// Handle errors
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
console.log('[Player] HLS fatal error');
|
||||
this.hls.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return; // Exit
|
||||
}
|
||||
this.playHls(playlistUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
|
||||
@ -1014,10 +979,13 @@ class VideoPlayer {
|
||||
try {
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
||||
const info = await probeRes.json();
|
||||
videoCodec = info.video;
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
videoCodec = info.video;
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec });
|
||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
|
||||
@ -1210,20 +1178,24 @@ class VideoPlayer {
|
||||
}
|
||||
|
||||
this.hls = new Hls(this.getHlsConfig());
|
||||
const hls = this.hls, generation = this.playbackGeneration;
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (this.hls !== hls || this.playbackGeneration !== generation) return;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
|
||||
});
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (this.hls !== hls || this.playbackGeneration !== generation) return;
|
||||
if (data.fatal) {
|
||||
// Simple error handling for forced HLS/transcode modes
|
||||
console.error('Fatal HLS error in transcode mode:', data);
|
||||
this.hls.destroy();
|
||||
this.stopTranscodeSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -180,7 +180,14 @@ class WatchPage {
|
||||
});
|
||||
|
||||
// Progress bar
|
||||
this.progressSlider?.addEventListener('input', (e) => this.seek(e.target.value));
|
||||
this.progressSlider?.addEventListener('input', e => {
|
||||
this.progressScrubbing = true;
|
||||
this.timeCurrent.textContent = this.formatTime(this.getPlaybackDuration() * Number(e.target.value) / 100);
|
||||
});
|
||||
this.progressSlider?.addEventListener('change', e => {
|
||||
this.progressScrubbing = false;
|
||||
this.seek(e.target.value);
|
||||
});
|
||||
|
||||
// Video events
|
||||
this.video?.addEventListener('timeupdate', () => this.updateProgress());
|
||||
@ -246,6 +253,7 @@ class WatchPage {
|
||||
* @param {string} streamUrl - Stream URL
|
||||
*/
|
||||
async play(content, streamUrl) {
|
||||
this.stop();
|
||||
this.sourceDuration = 0;
|
||||
this.content = content;
|
||||
this.contentType = content.type;
|
||||
@ -274,7 +282,10 @@ class WatchPage {
|
||||
this.subtitleEl.textContent = content.subtitle || '';
|
||||
|
||||
// Load video
|
||||
await this.loadVideo(streamUrl);
|
||||
const loading = this.loadVideo(streamUrl);
|
||||
const generation = this.playbackGeneration;
|
||||
await loading;
|
||||
if (this.playbackGeneration !== generation) return;
|
||||
|
||||
// Show Now Playing indicator in navbar
|
||||
this.showNowPlaying(content.title);
|
||||
@ -328,42 +339,32 @@ class WatchPage {
|
||||
* Start a HLS transcode session
|
||||
*/
|
||||
async startTranscodeSession(url, options = {}) {
|
||||
const generation = this.playbackGeneration;
|
||||
this.playbackSessions ||= new PlaybackSessions(false);
|
||||
const sessionOptions = { seekOffset: this.resumeTime, ...options };
|
||||
try {
|
||||
console.log('[WatchPage] Starting HLS transcode session...', options);
|
||||
const res = await fetch('/api/transcode/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
seekOffset: this.resumeTime, // Pass resume point to backend
|
||||
...options
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to start session');
|
||||
const session = await res.json();
|
||||
const session = await this.playbackSessions.create(url, sessionOptions);
|
||||
if (generation !== this.playbackGeneration) return null;
|
||||
this.currentSessionId = session.sessionId;
|
||||
this.playbackTimeOffset = Number(sessionOptions.seekOffset) || 0;
|
||||
this.resumeTime = 0;
|
||||
this.currentTranscodeOptions = sessionOptions;
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
console.error('[WatchPage] Session start failed:', err);
|
||||
// Fallback to direct transcode if session fails
|
||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError' && generation === this.playbackGeneration) {
|
||||
this.stopTranscodeSession();
|
||||
console.error('Unable to start playback:', error.message);
|
||||
this.updateTranscodeStatus('error', 'Unable to start playback');
|
||||
this.loadingSpinner?.classList.remove('show');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and cleanup current transcode session
|
||||
*/
|
||||
async stopTranscodeSession() {
|
||||
if (this.currentSessionId) {
|
||||
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
|
||||
try {
|
||||
// Fire and forget cleanup
|
||||
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error('Failed to stop session:', err);
|
||||
}
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
stopTranscodeSession() {
|
||||
this.playbackSessions?.stop();
|
||||
this.currentSessionId = null;
|
||||
this.playbackTimeOffset = 0;
|
||||
}
|
||||
|
||||
async updateTranscodeStatus(mode, text) {
|
||||
@ -432,6 +433,7 @@ class WatchPage {
|
||||
console.warn('Could not load settings');
|
||||
}
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
// Detect stream type
|
||||
const looksLikeHls = url.includes('.m3u8') || url.includes('m3u8');
|
||||
const isRawTs = url.includes('.ts') && !url.includes('.m3u8');
|
||||
@ -463,6 +465,7 @@ class WatchPage {
|
||||
const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(url, {
|
||||
videoMode,
|
||||
seekOffset: this.resumeTime, // Ensure seekOffset is passed
|
||||
@ -471,6 +474,7 @@ class WatchPage {
|
||||
audioChannels: info.audioChannels,
|
||||
videoHeight: info.height
|
||||
});
|
||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||
this.playHls(playlistUrl);
|
||||
this.setVolumeFromStorage();
|
||||
return;
|
||||
@ -495,16 +499,19 @@ class WatchPage {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
// Priority 1: Force Video Transcode (Full) or Upscaling
|
||||
if (settings.forceVideoTranscode || settings.upscaleEnabled) {
|
||||
const statusText = settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
|
||||
const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
console.log(`[WatchPage] ${statusText} enabled. Starting session (encode)...`);
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(url, {
|
||||
videoMode: 'encode',
|
||||
seekOffset: this.resumeTime
|
||||
});
|
||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||
this.playHls(playlistUrl);
|
||||
this.setVolumeFromStorage();
|
||||
return;
|
||||
@ -520,14 +527,17 @@ class WatchPage {
|
||||
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
|
||||
const info = await probeRes.json();
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
videoCodec = info.video;
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
const playlistUrl = await this.startTranscodeSession(url, {
|
||||
videoMode: 'copy',
|
||||
videoCodec,
|
||||
seekOffset: this.resumeTime
|
||||
});
|
||||
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
|
||||
this.playHls(playlistUrl);
|
||||
this.setVolumeFromStorage();
|
||||
return;
|
||||
@ -594,27 +604,32 @@ class WatchPage {
|
||||
enableWorker: true,
|
||||
});
|
||||
|
||||
const hls = this.hls, generation = this.playbackGeneration;
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
// Listen for subtitle track updates
|
||||
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
|
||||
if (this.hls !== hls || this.playbackGeneration !== generation) return;
|
||||
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
|
||||
// Wait a moment for native text tracks to populate
|
||||
setTimeout(() => this.updateCaptionsTracks(), 100);
|
||||
setTimeout(() => { if (this.hls === hls && this.playbackGeneration === generation) this.updateCaptionsTracks(); }, 100);
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
|
||||
if (this.hls !== hls || this.playbackGeneration !== generation) return;
|
||||
console.log('[WatchPage] Subtitle track switched:', data);
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (this.hls !== hls || this.playbackGeneration !== generation) return;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||
});
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (this.hls !== hls || this.playbackGeneration !== generation) return;
|
||||
if (data.fatal) {
|
||||
console.error('[WatchPage] HLS fatal error:', data);
|
||||
// Try proxy on CORS error (only if not already proxied/transcoded)
|
||||
@ -624,6 +639,7 @@ class WatchPage {
|
||||
this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
|
||||
} else {
|
||||
this.hls.destroy();
|
||||
this.stopTranscodeSession();
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -646,6 +662,8 @@ class WatchPage {
|
||||
this.saveProgress();
|
||||
this.sourceDuration = 0;
|
||||
this.durationReady = false;
|
||||
this.pendingSeekProgress = null;
|
||||
this.progressScrubbing = false;
|
||||
if (this.timeTotal) {
|
||||
this.timeTotal.textContent = '';
|
||||
this.timeTotal.classList.add('hidden');
|
||||
@ -686,15 +704,33 @@ class WatchPage {
|
||||
}
|
||||
|
||||
skip(seconds) {
|
||||
if (this.video) {
|
||||
this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration || 0));
|
||||
}
|
||||
return this.seekToContentTime(this.getCurrentPlaybackTime() + seconds);
|
||||
}
|
||||
|
||||
seek(percent) {
|
||||
if (this.video && this.video.duration) {
|
||||
this.video.currentTime = (percent / 100) * this.video.duration;
|
||||
}
|
||||
return this.seekToContentTime(Number(percent) * this.getPlaybackDuration() / 100);
|
||||
}
|
||||
|
||||
getCurrentPlaybackTime() {
|
||||
if (Number.isFinite(this.pendingSeekProgress)) return this.pendingSeekProgress;
|
||||
const relative = Number(this.video?.currentTime) || 0;
|
||||
return relative + (this.currentSessionId ? this.playbackTimeOffset || 0 : 0);
|
||||
}
|
||||
|
||||
async seekToContentTime(requestedTime) {
|
||||
if (!this.video) return;
|
||||
const duration = this.getPlaybackDuration();
|
||||
if (!duration || !Number.isFinite(requestedTime)) return;
|
||||
const target = Math.max(0, Math.min(requestedTime, Math.max(0, duration - 0.25)));
|
||||
if (!this.currentSessionId) { this.video.currentTime = target; return; }
|
||||
const generation = this.playbackGeneration;
|
||||
this.pendingSeekProgress = target;
|
||||
this.hls?.destroy();
|
||||
this.hls = null;
|
||||
this.video.pause();
|
||||
this.showLoading();
|
||||
const playlist = await this.startTranscodeSession(this.currentUrl, { ...this.currentTranscodeOptions, seekOffset: target });
|
||||
if (playlist && generation === this.playbackGeneration) this.playHls(playlist);
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
@ -822,17 +858,18 @@ class WatchPage {
|
||||
updateProgress() {
|
||||
if (!this.video) return;
|
||||
this.updateDurationDisplay();
|
||||
|
||||
const percent = (this.video.currentTime / this.video.duration) * 100;
|
||||
this.progressSlider.value = percent;
|
||||
this.timeCurrent.textContent = this.formatTime(this.video.currentTime);
|
||||
const currentTime = this.getCurrentPlaybackTime(), duration = this.getPlaybackDuration();
|
||||
if (!this.progressScrubbing) {
|
||||
this.progressSlider.value = duration > 0 ? Math.min(100, currentTime / duration * 100) : 0;
|
||||
this.timeCurrent.textContent = this.formatTime(currentTime);
|
||||
}
|
||||
|
||||
// Show "Up Next" panel early for series (like streaming services do during credits)
|
||||
// Only show if auto-play next episode is enabled
|
||||
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
|
||||
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
|
||||
const duration = this.video.duration;
|
||||
const currentTime = this.video.currentTime;
|
||||
const duration = this.getPlaybackDuration();
|
||||
const currentTime = this.getCurrentPlaybackTime();
|
||||
|
||||
// Only proceed if we have reliable duration data
|
||||
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
|
||||
@ -851,6 +888,7 @@ class WatchPage {
|
||||
}
|
||||
|
||||
onMetadataLoaded() {
|
||||
this.pendingSeekProgress = null;
|
||||
this.durationReady = true;
|
||||
if (!this.currentSessionId) this.setSourceDuration(this.video?.duration);
|
||||
else this.updateDurationDisplay();
|
||||
@ -1468,10 +1506,10 @@ class WatchPage {
|
||||
}
|
||||
|
||||
async saveProgress() {
|
||||
if (!this.content || !this.video || this.video.paused) return;
|
||||
if (!this.content || !this.video || !this.video.src || this.video.paused) return;
|
||||
|
||||
const progress = Math.floor(this.video.currentTime);
|
||||
const duration = Math.floor(this.video.duration);
|
||||
const progress = Math.floor(this.getCurrentPlaybackTime());
|
||||
const duration = Math.floor(this.getPlaybackDuration());
|
||||
|
||||
if (isNaN(progress) || isNaN(duration) || duration <= 0) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user