Fix progress bar seek pausing playback + add persistent mini-player
- video-player: add userSeeking flag so mid-seek pause events are suppressed; force-resume on 'seeked' if video was playing before seek; guard player click handler against progCont clicks; e.preventDefault() on touchend to stop synthetic click toggling play - audio-player: apply identical seek fixes (same four changes) - app layout: add floating mini-player that saves video state to sessionStorage when bottom nav is tapped while a video is playing, then restores playback on the next page via a floating overlay Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
615e7efd7c
commit
d1441b213a
@ -709,13 +709,15 @@ const loopRow = document.getElementById('ytpLoopRow');
|
|||||||
const loopVal = document.getElementById('ytpLoopVal');
|
const loopVal = document.getElementById('ytpLoopVal');
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────
|
||||||
let hideTimer = null;
|
let hideTimer = null;
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let isTheater = false;
|
let isTheater = false;
|
||||||
let isFullscreen = false;
|
let isFullscreen = false;
|
||||||
let currentSpeed = 1;
|
let currentSpeed = 1;
|
||||||
let lastTap = 0;
|
let lastTap = 0;
|
||||||
let tapTimer = null;
|
let tapTimer = null;
|
||||||
|
let userSeeking = false; // true from seekTo() until 'seeked' fires
|
||||||
|
let wasPlayingBeforeSeek = false; // remember play state so we can resume after seek
|
||||||
|
|
||||||
// ── HLS source ────────────────────────────────────────
|
// ── HLS source ────────────────────────────────────────
|
||||||
const HLS_URL = @json($hlsUrl);
|
const HLS_URL = @json($hlsUrl);
|
||||||
@ -904,6 +906,7 @@ playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); show
|
|||||||
let clickTimer = null;
|
let clickTimer = null;
|
||||||
player.addEventListener('click', e => {
|
player.addEventListener('click', e => {
|
||||||
if (e.target === settingsBtn || settingsPanel.contains(e.target)) return;
|
if (e.target === settingsBtn || settingsPanel.contains(e.target)) return;
|
||||||
|
if (progCont.contains(e.target)) return;
|
||||||
clearTimeout(clickTimer);
|
clearTimeout(clickTimer);
|
||||||
clickTimer = setTimeout(() => { togglePlay(); }, 200);
|
clickTimer = setTimeout(() => { togglePlay(); }, 200);
|
||||||
});
|
});
|
||||||
@ -954,6 +957,8 @@ volRange.addEventListener('click', e => e.stopPropagation());
|
|||||||
// ── Progress bar scrubbing ────────────────────────────
|
// ── Progress bar scrubbing ────────────────────────────
|
||||||
function seekTo(pct) {
|
function seekTo(pct) {
|
||||||
if (!video.duration) return;
|
if (!video.duration) return;
|
||||||
|
if (!userSeeking) wasPlayingBeforeSeek = !video.paused;
|
||||||
|
userSeeking = true;
|
||||||
video.currentTime = pct * video.duration;
|
video.currentTime = pct * video.duration;
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
@ -1000,7 +1005,7 @@ progCont.addEventListener('touchstart', e => {
|
|||||||
progCont.addEventListener('touchmove', e => {
|
progCont.addEventListener('touchmove', e => {
|
||||||
if (isDragging) seekTo(touchProgressPct(e));
|
if (isDragging) seekTo(touchProgressPct(e));
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
progCont.addEventListener('touchend', () => { isDragging = false; progBar.classList.remove('dragging'); });
|
progCont.addEventListener('touchend', e => { e.preventDefault(); isDragging = false; progBar.classList.remove('dragging'); });
|
||||||
|
|
||||||
// ── Settings panel ────────────────────────────────────
|
// ── Settings panel ────────────────────────────────────
|
||||||
settingsBtn.addEventListener('click', e => {
|
settingsBtn.addEventListener('click', e => {
|
||||||
@ -1143,7 +1148,19 @@ if (pipBtn) {
|
|||||||
|
|
||||||
// ── Video events ──────────────────────────────────────
|
// ── Video events ──────────────────────────────────────
|
||||||
video.addEventListener('play', () => { updatePlayIcon(); resetHideTimer(); largePlay.classList.remove('visible'); requestWakeLock(); });
|
video.addEventListener('play', () => { updatePlayIcon(); resetHideTimer(); largePlay.classList.remove('visible'); requestWakeLock(); });
|
||||||
video.addEventListener('pause', () => { updatePlayIcon(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock(); });
|
video.addEventListener('pause', () => {
|
||||||
|
if (userSeeking) return; // suppress mid-seek pause events
|
||||||
|
updatePlayIcon(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock();
|
||||||
|
});
|
||||||
|
video.addEventListener('seeked', () => {
|
||||||
|
userSeeking = false;
|
||||||
|
if (wasPlayingBeforeSeek) {
|
||||||
|
// Resume if the browser/HLS.js paused the video during seeking
|
||||||
|
if (video.paused) video.play().catch(() => {});
|
||||||
|
largePlay.classList.remove('visible');
|
||||||
|
resetHideTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
video.addEventListener('timeupdate', updateProgress);
|
video.addEventListener('timeupdate', updateProgress);
|
||||||
video.addEventListener('progress', updateProgress);
|
video.addEventListener('progress', updateProgress);
|
||||||
video.addEventListener('durationchange', () => { timeDur.textContent = fmt(video.duration); });
|
video.addEventListener('durationchange', () => { timeDur.textContent = fmt(video.duration); });
|
||||||
@ -1242,10 +1259,25 @@ function init() {
|
|||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
|
// If user returned from mini-player, seek to the saved timestamp
|
||||||
|
const miniRaw = sessionStorage.getItem('ytpMiniState');
|
||||||
|
let miniSeekTime = 0;
|
||||||
|
if (miniRaw) {
|
||||||
|
try {
|
||||||
|
const ms = JSON.parse(miniRaw);
|
||||||
|
if (ms && ms.time > 0) miniSeekTime = ms.time;
|
||||||
|
} catch(e) {}
|
||||||
|
sessionStorage.removeItem('ytpMiniState');
|
||||||
|
}
|
||||||
|
|
||||||
// canplay fires for ALL source types (HLS.js, MP4, Safari native HLS)
|
// canplay fires for ALL source types (HLS.js, MP4, Safari native HLS)
|
||||||
// once the browser has enough data to start — most reliable autoplay trigger
|
// once the browser has enough data to start — most reliable autoplay trigger
|
||||||
video.addEventListener('canplay', function autoStart() {
|
video.addEventListener('canplay', function autoStart() {
|
||||||
video.removeEventListener('canplay', autoStart);
|
video.removeEventListener('canplay', autoStart);
|
||||||
|
if (miniSeekTime > 0) {
|
||||||
|
video.currentTime = miniSeekTime;
|
||||||
|
miniSeekTime = 0;
|
||||||
|
}
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1597,6 +1597,210 @@
|
|||||||
@endif
|
@endif
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
|
<!-- ── Persistent Mini-Player ─────────────────────────────────────────
|
||||||
|
Shown on non-video pages when the user navigated away while a
|
||||||
|
video was playing. State is stored in sessionStorage under the
|
||||||
|
key "ytpMiniState".
|
||||||
|
──────────────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="ytpMini" style="display:none;" aria-label="Mini player">
|
||||||
|
<div id="ytpMiniVideo">
|
||||||
|
<video id="ytpMiniVid" playsinline></video>
|
||||||
|
</div>
|
||||||
|
<div id="ytpMiniBar">
|
||||||
|
<div id="ytpMiniInfo">
|
||||||
|
<span id="ytpMiniTitle"></span>
|
||||||
|
</div>
|
||||||
|
<div id="ytpMiniControls">
|
||||||
|
<button id="ytpMiniPlay" title="Play / Pause"><i class="bi bi-play-fill"></i></button>
|
||||||
|
<a id="ytpMiniExpand" title="Open video"><i class="bi bi-box-arrow-up-right"></i></a>
|
||||||
|
<button id="ytpMiniClose" title="Close"><i class="bi bi-x-lg"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#ytpMini {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||||
|
right: 12px;
|
||||||
|
width: 280px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1999;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
#ytpMiniVideo {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
#ytpMiniVid { width:100%; height:100%; object-fit:contain; display:block; }
|
||||||
|
#ytpMiniBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
gap: 6px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
#ytpMiniInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
#ytpMiniTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #eee;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#ytpMiniControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#ytpMiniControls button,
|
||||||
|
#ytpMiniControls a {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
#ytpMiniControls button:hover,
|
||||||
|
#ytpMiniControls a:hover { color: #fff; background: rgba(255,255,255,.1); }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#ytpMini { width: calc(100vw - 24px); right: 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var STORAGE_KEY = 'ytpMiniState';
|
||||||
|
|
||||||
|
/* ── Save state when nav bar is tapped while video plays ── */
|
||||||
|
document.querySelectorAll('.yt-bottom-nav-item').forEach(function (link) {
|
||||||
|
link.addEventListener('click', function () {
|
||||||
|
var v = document.getElementById('videoPlayer');
|
||||||
|
if (!v || v.paused || !v.currentSrc) return;
|
||||||
|
|
||||||
|
var title = document.title.replace(/\s*\|.*$/, '').trim();
|
||||||
|
var videoPageUrl = window.location.href;
|
||||||
|
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
src: v.currentSrc,
|
||||||
|
time: v.currentTime,
|
||||||
|
title: title,
|
||||||
|
url: videoPageUrl,
|
||||||
|
muted: v.muted,
|
||||||
|
volume: v.volume
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── On page load, restore mini-player if state exists ── */
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
/* If we're on the video page itself, clear the saved state */
|
||||||
|
var mainVideo = document.getElementById('videoPlayer');
|
||||||
|
if (mainVideo) {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
var state;
|
||||||
|
try { state = JSON.parse(raw); } catch(e) { return; }
|
||||||
|
if (!state || !state.src) return;
|
||||||
|
|
||||||
|
var wrap = document.getElementById('ytpMini');
|
||||||
|
var vid = document.getElementById('ytpMiniVid');
|
||||||
|
var titleEl = document.getElementById('ytpMiniTitle');
|
||||||
|
var playBtn = document.getElementById('ytpMiniPlay');
|
||||||
|
var expandBtn = document.getElementById('ytpMiniExpand');
|
||||||
|
var closeBtn = document.getElementById('ytpMiniClose');
|
||||||
|
if (!wrap || !vid) return;
|
||||||
|
|
||||||
|
titleEl.textContent = state.title || 'Video';
|
||||||
|
expandBtn.href = state.url || '#';
|
||||||
|
vid.volume = state.volume ?? 0.5;
|
||||||
|
vid.muted = state.muted ?? false;
|
||||||
|
|
||||||
|
/* Load HLS.js if needed, then attach source */
|
||||||
|
function startMini(Hls) {
|
||||||
|
if (Hls && Hls.isSupported() && /\.m3u8/.test(state.src)) {
|
||||||
|
var hls = new Hls({ startLevel: -1 });
|
||||||
|
hls.loadSource(state.src);
|
||||||
|
hls.attachMedia(vid);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||||
|
vid.currentTime = state.time || 0;
|
||||||
|
vid.play().catch(function () {
|
||||||
|
vid.muted = true;
|
||||||
|
vid.play().catch(function () {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
vid.src = state.src;
|
||||||
|
vid.addEventListener('loadedmetadata', function () {
|
||||||
|
vid.currentTime = state.time || 0;
|
||||||
|
}, { once: true });
|
||||||
|
vid.play().catch(function () {
|
||||||
|
vid.muted = true;
|
||||||
|
vid.play().catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wrap.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.Hls) {
|
||||||
|
startMini(window.Hls);
|
||||||
|
} else {
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5/dist/hls.min.js';
|
||||||
|
s.onload = function () { startMini(window.Hls); };
|
||||||
|
s.onerror = function () { startMini(null); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Play / pause toggle */
|
||||||
|
vid.addEventListener('play', function () { playBtn.querySelector('i').className = 'bi bi-pause-fill'; });
|
||||||
|
vid.addEventListener('pause', function () { playBtn.querySelector('i').className = 'bi bi-play-fill'; });
|
||||||
|
playBtn.addEventListener('click', function () {
|
||||||
|
if (vid.paused) { vid.play().catch(function(){}); }
|
||||||
|
else { vid.pause(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Expand → navigate to video page */
|
||||||
|
expandBtn.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var t = vid.currentTime;
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
src: state.src, time: t, title: state.title,
|
||||||
|
url: state.url, muted: vid.muted, volume: vid.volume
|
||||||
|
}));
|
||||||
|
window.location.href = state.url;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Close */
|
||||||
|
closeBtn.addEventListener('click', function () {
|
||||||
|
vid.pause();
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -374,8 +374,10 @@ const barsBtn = document.getElementById('ytpBarsBtn');
|
|||||||
const animCanvas = document.getElementById('audioAnimCanvas');
|
const animCanvas = document.getElementById('audioAnimCanvas');
|
||||||
|
|
||||||
const NEXT_URL = @json($nextUrl ?? null);
|
const NEXT_URL = @json($nextUrl ?? null);
|
||||||
let hideTimer = null;
|
let hideTimer = null;
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
|
let userSeeking = false;
|
||||||
|
let wasPlayingBeforeSeek = false;
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
function fmt(s) {
|
function fmt(s) {
|
||||||
@ -422,6 +424,7 @@ function togglePlay() {
|
|||||||
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); showControls(); });
|
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); showControls(); });
|
||||||
player.addEventListener('click', e => {
|
player.addEventListener('click', e => {
|
||||||
if (settingsPanel.contains(e.target) || settingsBtn === e.target) return;
|
if (settingsPanel.contains(e.target) || settingsBtn === e.target) return;
|
||||||
|
if (progCont.contains(e.target)) return;
|
||||||
togglePlay();
|
togglePlay();
|
||||||
});
|
});
|
||||||
player.addEventListener('mousemove', showControls);
|
player.addEventListener('mousemove', showControls);
|
||||||
@ -447,6 +450,8 @@ volRange.addEventListener('click', e => e.stopPropagation());
|
|||||||
// ── Progress bar ─────────────────────────────────────────────
|
// ── Progress bar ─────────────────────────────────────────────
|
||||||
function seekTo(pct) {
|
function seekTo(pct) {
|
||||||
if (!audio.duration) return;
|
if (!audio.duration) return;
|
||||||
|
if (!userSeeking) wasPlayingBeforeSeek = !audio.paused;
|
||||||
|
userSeeking = true;
|
||||||
audio.currentTime = Math.max(0, Math.min(1, pct)) * audio.duration;
|
audio.currentTime = Math.max(0, Math.min(1, pct)) * audio.duration;
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
@ -468,7 +473,7 @@ document.addEventListener('mousemove', e => { if (isDragging) seekTo(progressPct
|
|||||||
document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; progBar.classList.remove('dragging'); } });
|
document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; progBar.classList.remove('dragging'); } });
|
||||||
progCont.addEventListener('touchstart', e => { isDragging = true; progBar.classList.add('dragging'); seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
|
progCont.addEventListener('touchstart', e => { isDragging = true; progBar.classList.add('dragging'); seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
|
||||||
progCont.addEventListener('touchmove', e => { if (isDragging) seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
|
progCont.addEventListener('touchmove', e => { if (isDragging) seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
|
||||||
progCont.addEventListener('touchend', () => { isDragging = false; progBar.classList.remove('dragging'); });
|
progCont.addEventListener('touchend', e => { e.preventDefault(); isDragging = false; progBar.classList.remove('dragging'); });
|
||||||
|
|
||||||
// ── Settings / Speed ─────────────────────────────────────────
|
// ── Settings / Speed ─────────────────────────────────────────
|
||||||
settingsBtn.addEventListener('click', e => {
|
settingsBtn.addEventListener('click', e => {
|
||||||
@ -540,7 +545,17 @@ document.addEventListener('keydown', e => {
|
|||||||
|
|
||||||
// ── Audio events ─────────────────────────────────────────────
|
// ── Audio events ─────────────────────────────────────────────
|
||||||
audio.addEventListener('play', () => { updatePlayIcon(); startBars(); showControls(); largePlay.classList.remove('visible'); requestWakeLock(); });
|
audio.addEventListener('play', () => { updatePlayIcon(); startBars(); showControls(); largePlay.classList.remove('visible'); requestWakeLock(); });
|
||||||
audio.addEventListener('pause', () => { updatePlayIcon(); stopBars(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock(); });
|
audio.addEventListener('pause', () => {
|
||||||
|
if (userSeeking) return; // suppress mid-seek pause events
|
||||||
|
updatePlayIcon(); stopBars(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock();
|
||||||
|
});
|
||||||
|
audio.addEventListener('seeked', () => {
|
||||||
|
userSeeking = false;
|
||||||
|
if (wasPlayingBeforeSeek) {
|
||||||
|
if (audio.paused) audio.play().catch(() => {});
|
||||||
|
largePlay.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
audio.addEventListener('timeupdate', updateProgress);
|
audio.addEventListener('timeupdate', updateProgress);
|
||||||
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); });
|
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); });
|
||||||
audio.addEventListener('ended', () => { releaseWakeLock(); if (NEXT_URL) window.location.href = NEXT_URL; });
|
audio.addEventListener('ended', () => { releaseWakeLock(); if (NEXT_URL) window.location.href = NEXT_URL; });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user