|
@@ -81,6 +81,12 @@
|
|
|
}
|
|
}
|
|
|
.ctrl-btn:hover { opacity: 0.7; }
|
|
.ctrl-btn:hover { opacity: 0.7; }
|
|
|
.ctrl-btn img { width: 24px; height: 24px; display: block; }
|
|
.ctrl-btn img { width: 24px; height: 24px; display: block; }
|
|
|
|
|
+ .ctrl-cc-icon {
|
|
|
|
|
+ width: 24px; height: 24px;
|
|
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
|
|
+ font-size: 10px; font-weight: 700; letter-spacing: 0.4px;
|
|
|
|
|
+ border: 1.5px solid #fff; border-radius: 4px; line-height: 1;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
#volume-wrap { display: flex; align-items: center; gap: 8px; }
|
|
#volume-wrap { display: flex; align-items: center; gap: 8px; }
|
|
|
#volume-slider {
|
|
#volume-slider {
|
|
@@ -296,7 +302,13 @@
|
|
|
border-radius: calc(var(--radius) * 1.5); padding: 4px 0; min-width: 200px;
|
|
border-radius: calc(var(--radius) * 1.5); padding: 4px 0; min-width: 200px;
|
|
|
box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
|
|
box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
|
|
|
z-index: 32;
|
|
z-index: 32;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ max-height: 320px;
|
|
|
}
|
|
}
|
|
|
|
|
+ #ctx-subtitle-sub::-webkit-scrollbar { width: 8px; }
|
|
|
|
|
+ #ctx-subtitle-sub::-webkit-scrollbar-track { background: transparent; }
|
|
|
|
|
+ #ctx-subtitle-sub::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
|
|
|
|
|
+ #ctx-subtitle-sub::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
|
|
|
|
|
|
|
|
/* ── Subtitle settings modal ───────────────────────────────────────── */
|
|
/* ── Subtitle settings modal ───────────────────────────────────────── */
|
|
|
#subtitle-settings-modal {
|
|
#subtitle-settings-modal {
|
|
@@ -424,8 +436,37 @@
|
|
|
color: var(--text);
|
|
color: var(--text);
|
|
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
|
user-select: none;
|
|
user-select: none;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ max-height: 320px;
|
|
|
}
|
|
}
|
|
|
#settings-popup.active { display: block; }
|
|
#settings-popup.active { display: block; }
|
|
|
|
|
+ #settings-popup::-webkit-scrollbar,
|
|
|
|
|
+ #subtitle-popup::-webkit-scrollbar { width: 8px; }
|
|
|
|
|
+ #settings-popup::-webkit-scrollbar-track,
|
|
|
|
|
+ #subtitle-popup::-webkit-scrollbar-track { background: transparent; }
|
|
|
|
|
+ #settings-popup::-webkit-scrollbar-thumb,
|
|
|
|
|
+ #subtitle-popup::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
|
|
|
|
|
+ #settings-popup::-webkit-scrollbar-thumb:hover,
|
|
|
|
|
+ #subtitle-popup::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Subtitle popup (dedicated CC button) ────────────────────────────── */
|
|
|
|
|
+ #subtitle-popup {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 14px; bottom: 62px;
|
|
|
|
|
+ z-index: 35;
|
|
|
|
|
+ background: rgba(28,28,30,0.97);
|
|
|
|
|
+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
|
|
|
+ border-radius: calc(var(--radius) * 1.5);
|
|
|
|
|
+ padding: 4px 0; min-width: 220px;
|
|
|
|
|
+ box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
|
|
|
+ user-select: none;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ max-height: 320px;
|
|
|
|
|
+ }
|
|
|
|
|
+ #subtitle-popup.active { display: block; }
|
|
|
.set-section { padding: 6px 14px 8px; }
|
|
.set-section { padding: 6px 14px 8px; }
|
|
|
.set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
|
|
.set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
|
|
|
.set-head {
|
|
.set-head {
|
|
@@ -625,18 +666,23 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
|
|
<div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
|
|
|
- <div class="set-link" id="set-load-subtitle">
|
|
|
|
|
- <img src="img/icons/movie_white.svg" alt="">Load SRT subtitle…
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="set-link" id="set-subtitle-settings">
|
|
|
|
|
- <img src="img/icons/settings_white.svg" alt="">Subtitle settings
|
|
|
|
|
- </div>
|
|
|
|
|
<div class="set-link" id="set-video-props">
|
|
<div class="set-link" id="set-video-props">
|
|
|
<img src="img/icons/menu_white.svg" alt="">Video properties
|
|
<img src="img/icons/menu_white.svg" alt="">Video properties
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <!-- Subtitle popup (dedicated CC button) -->
|
|
|
|
|
+ <div id="subtitle-popup">
|
|
|
|
|
+ <div class="ctx-item ctx-active" id="sub-pop-disable"><i class="ctx-icon">✓</i>Disable</div>
|
|
|
|
|
+ <div class="ctx-divider"></div>
|
|
|
|
|
+ <div class="ctx-item" id="sub-pop-load"><i class="ctx-icon">+</i>Load SRT file…</div>
|
|
|
|
|
+ <div class="ctx-divider"></div>
|
|
|
|
|
+ <div class="set-link" id="sub-pop-settings">
|
|
|
|
|
+ <img src="img/icons/settings_white.svg" alt="">Subtitle settings
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<!-- Player controls -->
|
|
<!-- Player controls -->
|
|
|
<div id="video-controls">
|
|
<div id="video-controls">
|
|
|
<div id="progress-wrap">
|
|
<div id="progress-wrap">
|
|
@@ -659,6 +705,9 @@
|
|
|
</div>
|
|
</div>
|
|
|
<span id="time-display">0:00 / 0:00</span>
|
|
<span id="time-display">0:00 / 0:00</span>
|
|
|
<span id="spacer"></span>
|
|
<span id="spacer"></span>
|
|
|
|
|
+ <button class="ctrl-btn" id="ctrl-subtitle" title="Subtitles">
|
|
|
|
|
+ <span class="ctrl-cc-icon">CC</span>
|
|
|
|
|
+ </button>
|
|
|
<button class="ctrl-btn" id="ctrl-settings" title="Playback settings">
|
|
<button class="ctrl-btn" id="ctrl-settings" title="Playback settings">
|
|
|
<img src="img/icons/settings_white.svg" alt="">
|
|
<img src="img/icons/settings_white.svg" alt="">
|
|
|
</button>
|
|
</button>
|
|
@@ -985,10 +1034,17 @@ function hideSeekFreeze() {
|
|
|
// no re-transcode, no black frame. Only a seek past that window (or an MP4
|
|
// no re-transcode, no black frame. Only a seek past that window (or an MP4
|
|
|
// stream, which has no seekable history at all) has to restart the transcode at
|
|
// stream, which has no seekable history at all) has to restart the transcode at
|
|
|
// the new offset.
|
|
// the new offset.
|
|
|
|
|
+// Restarting the transcode this close to the end of the file would leave ffmpeg
|
|
|
|
|
+// with nothing to encode, and the request comes back with no playable stream at
|
|
|
|
|
+// all — so a scrub to the very end lands just short of it instead.
|
|
|
|
|
+var TRANSCODE_SEEK_TAIL = 5;
|
|
|
|
|
+
|
|
|
function transcodeSeekTo(seconds) {
|
|
function transcodeSeekTo(seconds) {
|
|
|
if (!currentFile) { return; }
|
|
if (!currentFile) { return; }
|
|
|
var pos = Math.max(0, seconds);
|
|
var pos = Math.max(0, seconds);
|
|
|
- if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
|
|
|
|
|
|
|
+ if (transcodeDuration > 0) {
|
|
|
|
|
+ pos = Math.min(Math.max(0, transcodeDuration - TRANSCODE_SEEK_TAIL), pos);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
|
|
if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
|
|
|
setSubtitleSeekFloor(pos);
|
|
setSubtitleSeekFloor(pos);
|
|
@@ -1000,12 +1056,15 @@ function transcodeSeekTo(seconds) {
|
|
|
showSeekFreeze();
|
|
showSeekFreeze();
|
|
|
setSubtitleSeekFloor(pos);
|
|
setSubtitleSeekFloor(pos);
|
|
|
transcodeSeekOffset = pos;
|
|
transcodeSeekOffset = pos;
|
|
|
- if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, pos))) {
|
|
|
|
|
|
|
+ // The freeze frame stays up until the new stream is bound: attaching a
|
|
|
|
|
+ // playlist waits on the server to start the transcode, so playback resumes
|
|
|
|
|
+ // from the onReady callback rather than immediately.
|
|
|
|
|
+ if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, pos),
|
|
|
|
|
+ function (reason) { hideSeekFreeze(); showToast(reason); },
|
|
|
|
|
+ function () { playVideo(vid); })) {
|
|
|
hideSeekFreeze();
|
|
hideSeekFreeze();
|
|
|
showToast('This browser cannot play the HLS stream. Switch streaming mode to MP4 in settings.');
|
|
showToast('This browser cannot play the HLS stream. Switch streaming mode to MP4 in settings.');
|
|
|
- return;
|
|
|
|
|
}
|
|
}
|
|
|
- playVideo(vid);
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Whether `pos` (absolute time) falls inside what the current stream has
|
|
// Whether `pos` (absolute time) falls inside what the current stream has
|
|
@@ -1020,153 +1079,28 @@ function seekWithinTranscodedWindow(videoEl, pos) {
|
|
|
return relative <= available - 4;
|
|
return relative <= available - 4;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// Current playback position in whole-file terms (transcode streams restart at 0)
|
|
|
|
|
-function effectivePlaybackTime() {
|
|
|
|
|
- return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
|
|
|
|
|
-// The server renders one sprite sheet per file holding a downscaled frame every
|
|
|
|
|
-// few seconds; we fetch it once and keep it in memory, so hovering the timeline
|
|
|
|
|
-// costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
|
|
|
|
|
-// software-decoded H.265 source, which is exactly where previews matter most.
|
|
|
|
|
-var storyboard = null; // layout from the server + the loaded sheet URL
|
|
|
|
|
-var storyboardToken = 0; // invalidates in-flight loads when the file changes
|
|
|
|
|
-var storyboardTimer = null;
|
|
|
|
|
-
|
|
|
|
|
-function resetStoryboard() {
|
|
|
|
|
- storyboardToken++;
|
|
|
|
|
- storyboard = null;
|
|
|
|
|
- clearTimeout(storyboardTimer);
|
|
|
|
|
- storyboardTimer = null;
|
|
|
|
|
- $('#scrub-preview-thumb').removeClass('ready');
|
|
|
|
|
- hideScrubPreview();
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
|
|
|
|
|
-// transcode) get established before adding more load.
|
|
|
|
|
-function scheduleStoryboardLoad(filepath) {
|
|
|
|
|
- resetStoryboard();
|
|
|
|
|
- if (!filepath) { return; }
|
|
|
|
|
- var token = storyboardToken;
|
|
|
|
|
- storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function loadStoryboard(filepath, token) {
|
|
|
|
|
- var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
|
|
|
|
|
- fetch(base)
|
|
|
|
|
- .then(function (r) { return r.json(); })
|
|
|
|
|
- .then(function (meta) {
|
|
|
|
|
- if (token !== storyboardToken) { return; } // switched file while loading
|
|
|
|
|
- if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
|
|
|
|
|
- var img = new Image();
|
|
|
|
|
- img.onload = function () {
|
|
|
|
|
- if (token !== storyboardToken) { return; }
|
|
|
|
|
- meta.url = img.src;
|
|
|
|
|
- storyboard = meta;
|
|
|
|
|
- };
|
|
|
|
|
- img.src = base + '&image=1';
|
|
|
|
|
- })
|
|
|
|
|
- .catch(function () {}); // previews are optional — stay silent on failure
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Whole-file duration, whichever playback mode is active
|
|
|
|
|
-function scrubTotalDuration() {
|
|
|
|
|
- if (isTranscodedVideo) { return transcodeDuration; }
|
|
|
|
|
- return vid.duration || 0;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function initScrubPreview() {
|
|
|
|
|
- var $wrap = $('#progress-wrap');
|
|
|
|
|
- $wrap.on('mousemove', function (e) {
|
|
|
|
|
- var total = scrubTotalDuration();
|
|
|
|
|
- var rect = this.getBoundingClientRect();
|
|
|
|
|
- if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
|
|
|
|
|
- var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
|
|
|
- showScrubPreview(ratio * total, ratio, rect.width);
|
|
|
|
|
- });
|
|
|
|
|
- $wrap.on('mouseleave', hideScrubPreview);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function showScrubPreview(time, ratio, barWidth) {
|
|
|
|
|
- $('#scrub-preview-time').text(formatTime(time));
|
|
|
|
|
-
|
|
|
|
|
- var $thumb = $('#scrub-preview-thumb');
|
|
|
|
|
- var halfWidth;
|
|
|
|
|
- if (storyboard && storyboard.url) {
|
|
|
|
|
- var idx = Math.max(0, Math.min(storyboard.count - 1,
|
|
|
|
|
- Math.floor(time / storyboard.interval)));
|
|
|
|
|
- var col = idx % storyboard.cols;
|
|
|
|
|
- var row = Math.floor(idx / storyboard.cols);
|
|
|
|
|
- $thumb.addClass('ready').css({
|
|
|
|
|
- 'width': storyboard.tileWidth + 'px',
|
|
|
|
|
- 'height': storyboard.tileHeight + 'px',
|
|
|
|
|
- 'background-image': 'url("' + storyboard.url + '")',
|
|
|
|
|
- 'background-position': (-col * storyboard.tileWidth) + 'px '
|
|
|
|
|
- + (-row * storyboard.tileHeight) + 'px'
|
|
|
|
|
- });
|
|
|
|
|
- halfWidth = storyboard.tileWidth / 2;
|
|
|
|
|
- } else {
|
|
|
|
|
- // Sheet not ready (or unavailable) — the timestamp alone is still useful
|
|
|
|
|
- $thumb.removeClass('ready');
|
|
|
|
|
- halfWidth = 28;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Keep the popup within the bar rather than letting it hang off an edge
|
|
|
|
|
- var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
|
|
|
|
|
- $('#scrub-preview').css('left', x + 'px').addClass('active');
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
|
|
|
|
|
-
|
|
|
|
|
-// ── Transcode seek (seek-by-reload) ───────────────────────────────────────────
|
|
|
|
|
-// Formats the browser can't decode are streamed through ffmpeg, so "seeking"
|
|
|
|
|
-// means restarting the transcode at a new offset. Replacing .src blanks the
|
|
|
|
|
-// <video> to black until the first frame of the new segment arrives, which can
|
|
|
|
|
-// take seconds. Freeze the last painted frame under a spinner instead, and only
|
|
|
|
|
-// reveal the live element once it is genuinely playing again.
|
|
|
|
|
-var seekFreezeTimer = null;
|
|
|
|
|
-
|
|
|
|
|
-function showSeekFreeze() {
|
|
|
|
|
- var canvas = document.getElementById('seek-freeze');
|
|
|
|
|
- if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
|
|
|
|
|
- canvas.width = vid.videoWidth;
|
|
|
|
|
- canvas.height = vid.videoHeight;
|
|
|
|
|
- try {
|
|
|
|
|
- canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
- $(canvas).show();
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- // No decodable frame right now (e.g. seeking again mid-buffer) —
|
|
|
|
|
- // leave whatever is already frozen on screen rather than flashing black.
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- $('#seek-spinner').addClass('active');
|
|
|
|
|
- clearTimeout(seekFreezeTimer);
|
|
|
|
|
- // Safety net: a stalled transcode must never leave the overlay stuck on
|
|
|
|
|
- seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function hideSeekFreeze() {
|
|
|
|
|
- clearTimeout(seekFreezeTimer);
|
|
|
|
|
- seekFreezeTimer = null;
|
|
|
|
|
- $('#seek-freeze').hide();
|
|
|
|
|
- $('#seek-spinner').removeClass('active');
|
|
|
|
|
-}
|
|
|
|
|
|
|
+// ─── HLS stream recovery ──────────────────────────────────────────────────────
|
|
|
|
|
+// An HLS transcode lives on the server, and it can disappear from under the
|
|
|
|
|
+// player: reaped after a long pause, retired when the same player asked for a
|
|
|
|
|
+// different offset, or lost with a restart. The next segment then 404s and the
|
|
|
|
|
+// element reports a decode error, which leaves a black frame and no way back.
|
|
|
|
|
+// Re-open the stream where it stopped instead.
|
|
|
|
|
+var streamRecoveryAttempts = 0;
|
|
|
|
|
+var STREAM_RECOVERY_LIMIT = 3; // consecutive tries before giving up
|
|
|
|
|
|
|
|
-// Restart the transcode stream at `seconds`, holding the last frame until the
|
|
|
|
|
-// new segment plays. Clamped to the known duration.
|
|
|
|
|
-function transcodeSeekTo(seconds) {
|
|
|
|
|
- if (!currentFile) { return; }
|
|
|
|
|
- var pos = Math.max(0, seconds);
|
|
|
|
|
- if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
|
|
|
|
|
|
|
+function recoverTranscodeStream() {
|
|
|
|
|
+ if (!isTranscodedVideo || !usingHLS() || !currentFile) { return; }
|
|
|
|
|
+ // A file that genuinely cannot be transcoded fails every time; retrying it
|
|
|
|
|
+ // forever would replace one dead player with a flickering one.
|
|
|
|
|
+ if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
|
|
|
|
|
+ streamRecoveryAttempts++;
|
|
|
|
|
|
|
|
|
|
+ var resumeAt = effectivePlaybackTime();
|
|
|
showSeekFreeze();
|
|
showSeekFreeze();
|
|
|
- setSubtitleSeekFloor(pos);
|
|
|
|
|
- transcodeSeekOffset = pos;
|
|
|
|
|
- vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
|
|
|
|
|
- + '&start=' + pos.toFixed(3);
|
|
|
|
|
- vid.load();
|
|
|
|
|
- vid.play();
|
|
|
|
|
|
|
+ transcodeSeekOffset = resumeAt;
|
|
|
|
|
+ attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, resumeAt),
|
|
|
|
|
+ function (reason) { hideSeekFreeze(); showToast(reason); },
|
|
|
|
|
+ function () { playVideo(vid); });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Current playback position in whole-file terms (transcode streams restart at 0)
|
|
// Current playback position in whole-file terms (transcode streams restart at 0)
|
|
@@ -1276,9 +1210,15 @@ function initVideoControls() {
|
|
|
// Any seek — native or transcode reload — re-bases the subtitle floor
|
|
// Any seek — native or transcode reload — re-bases the subtitle floor
|
|
|
$(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
|
|
$(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
|
|
|
|
|
|
|
|
- $(vid).on('playing', hideSeekFreeze);
|
|
|
|
|
|
|
+ $(vid).on('playing', function () {
|
|
|
|
|
+ hideSeekFreeze();
|
|
|
|
|
+ streamRecoveryAttempts = 0; // the stream is healthy again
|
|
|
|
|
+ });
|
|
|
$(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
|
|
$(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
|
|
|
- $(vid).on('error', hideSeekFreeze);
|
|
|
|
|
|
|
+ $(vid).on('error', function () {
|
|
|
|
|
+ hideSeekFreeze();
|
|
|
|
|
+ recoverTranscodeStream();
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
// Auto-hide controls on mouse movement
|
|
// Auto-hide controls on mouse movement
|
|
|
$('#player-wrap').on('mousemove touchstart', showControls);
|
|
$('#player-wrap').on('mousemove touchstart', showControls);
|
|
@@ -1305,8 +1245,8 @@ function showControls() {
|
|
|
$('#video-controls').removeClass('hidden');
|
|
$('#video-controls').removeClass('hidden');
|
|
|
clearTimeout(controlsTimer);
|
|
clearTimeout(controlsTimer);
|
|
|
controlsTimer = setTimeout(function () {
|
|
controlsTimer = setTimeout(function () {
|
|
|
- // Keep the bar up while the settings popup is open — it is anchored to it
|
|
|
|
|
- if (!vid.paused && !$('#settings-popup').hasClass('active')) {
|
|
|
|
|
|
|
+ // Keep the bar up while a popup is open — both are anchored to it
|
|
|
|
|
+ if (!vid.paused && !$('#settings-popup').hasClass('active') && !$('#subtitle-popup').hasClass('active')) {
|
|
|
$('#video-controls').addClass('hidden');
|
|
$('#video-controls').addClass('hidden');
|
|
|
}
|
|
}
|
|
|
}, 3000);
|
|
}, 3000);
|
|
@@ -1382,6 +1322,7 @@ function initContextMenu() {
|
|
|
+ '" alt="">Repeat: ' + (repeatSingle ? 'On' : 'Off'));
|
|
+ '" alt="">Repeat: ' + (repeatSingle ? 'On' : 'Off'));
|
|
|
$('#ctx-subtitle-sub').hide();
|
|
$('#ctx-subtitle-sub').hide();
|
|
|
closeSettingsPopup();
|
|
closeSettingsPopup();
|
|
|
|
|
+ closeSubtitlePopup();
|
|
|
|
|
|
|
|
var rect = this.getBoundingClientRect();
|
|
var rect = this.getBoundingClientRect();
|
|
|
var x = e.clientX - rect.left;
|
|
var x = e.clientX - rect.left;
|
|
@@ -1453,14 +1394,16 @@ function applyStreamMode(mode, reloadCurrent) {
|
|
|
warnIfStreamModeUnplayable();
|
|
warnIfStreamModeUnplayable();
|
|
|
if (!isTranscodedVideo || !currentFile) { return; }
|
|
if (!isTranscodedVideo || !currentFile) { return; }
|
|
|
|
|
|
|
|
|
|
+ // Opening the file directly at the current position rather than at zero
|
|
|
|
|
+ // avoids starting a transcode from the beginning only to abandon it.
|
|
|
var resumeAt = effectivePlaybackTime();
|
|
var resumeAt = effectivePlaybackTime();
|
|
|
- transcodeSeekOffset = 0;
|
|
|
|
|
- if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, 0))) {
|
|
|
|
|
|
|
+ var startAt = resumeAt > 1 ? resumeAt : 0;
|
|
|
|
|
+ transcodeSeekOffset = startAt;
|
|
|
|
|
+ if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, startAt),
|
|
|
|
|
+ function (reason) { showToast(reason); },
|
|
|
|
|
+ function () { playVideo(vid); })) {
|
|
|
showToast('This browser cannot play the HLS stream.');
|
|
showToast('This browser cannot play the HLS stream.');
|
|
|
- return;
|
|
|
|
|
}
|
|
}
|
|
|
- playVideo(vid);
|
|
|
|
|
- if (resumeAt > 1) { transcodeSeekTo(resumeAt); }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function playerVideoElement() { return vid; }
|
|
function playerVideoElement() { return vid; }
|
|
@@ -1476,13 +1419,28 @@ function warnIfStreamModeUnplayable() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Cap a bottom-anchored popup's height so it never runs past the top edge of
|
|
|
|
|
+// the player (it only ever opens upward) — the popup's own scrollbar takes
|
|
|
|
|
+// over for whatever doesn't fit.
|
|
|
|
|
+function clampBottomPopupHeight($popup) {
|
|
|
|
|
+ var container = document.getElementById('player-wrap');
|
|
|
|
|
+ if (!container) { return; }
|
|
|
|
|
+ var margin = 10;
|
|
|
|
|
+ var containerRect = container.getBoundingClientRect();
|
|
|
|
|
+ var popupRect = $popup[0].getBoundingClientRect();
|
|
|
|
|
+ var available = popupRect.bottom - containerRect.top - margin;
|
|
|
|
|
+ $popup.css('max-height', Math.max(120, available) + 'px');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function openSettingsPopup() {
|
|
function openSettingsPopup() {
|
|
|
setRepeatSingle(repeatSingle);
|
|
setRepeatSingle(repeatSingle);
|
|
|
setPlaybackSpeed(playbackSpeed);
|
|
setPlaybackSpeed(playbackSpeed);
|
|
|
setAlwaysShowVolumeBar(alwaysShowVolumeBar);
|
|
setAlwaysShowVolumeBar(alwaysShowVolumeBar);
|
|
|
applyStreamMode(getStreamMode(), false);
|
|
applyStreamMode(getStreamMode(), false);
|
|
|
$('#player-ctx').hide();
|
|
$('#player-ctx').hide();
|
|
|
|
|
+ closeSubtitlePopup();
|
|
|
$('#settings-popup').addClass('active');
|
|
$('#settings-popup').addClass('active');
|
|
|
|
|
+ clampBottomPopupHeight($('#settings-popup'));
|
|
|
showControls();
|
|
showControls();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1493,6 +1451,46 @@ function toggleSettingsPopup() {
|
|
|
else { openSettingsPopup(); }
|
|
else { openSettingsPopup(); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ── Subtitle popup (dedicated CC button) ────────────────────────────────────
|
|
|
|
|
+function openSubtitlePopup() {
|
|
|
|
|
+ updateSubtitleSubmenu();
|
|
|
|
|
+ $('#player-ctx').hide();
|
|
|
|
|
+ closeSettingsPopup();
|
|
|
|
|
+ $('#subtitle-popup').addClass('active');
|
|
|
|
|
+ clampBottomPopupHeight($('#subtitle-popup'));
|
|
|
|
|
+ showControls();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function closeSubtitlePopup() { $('#subtitle-popup').removeClass('active'); }
|
|
|
|
|
+
|
|
|
|
|
+function toggleSubtitlePopup() {
|
|
|
|
|
+ if ($('#subtitle-popup').hasClass('active')) { closeSubtitlePopup(); }
|
|
|
|
|
+ else { openSubtitlePopup(); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function initSubtitlePopup() {
|
|
|
|
|
+ $('#ctrl-subtitle').on('click', function (e) {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ toggleSubtitlePopup();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $(document).on('mousedown.subpop', function (e) {
|
|
|
|
|
+ if (!$(e.target).closest('#subtitle-popup, #ctrl-subtitle').length) {
|
|
|
|
|
+ closeSubtitlePopup();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $('#sub-pop-disable').on('click', function () {
|
|
|
|
|
+ activeSubtitleIndex = -1;
|
|
|
|
|
+ stopAssTrack();
|
|
|
|
|
+ $('#subtitle-display').hide();
|
|
|
|
|
+ updateSubtitleSubmenu();
|
|
|
|
|
+ closeSubtitlePopup();
|
|
|
|
|
+ });
|
|
|
|
|
+ $('#sub-pop-load').on('click', function () { closeSubtitlePopup(); pickSubtitleFile(); });
|
|
|
|
|
+ $('#sub-pop-settings').on('click', function () { closeSubtitlePopup(); openSubtitleSettings(); });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function initSettingsPopup() {
|
|
function initSettingsPopup() {
|
|
|
$('#ctrl-settings').on('click', function (e) {
|
|
$('#ctrl-settings').on('click', function (e) {
|
|
|
e.stopPropagation();
|
|
e.stopPropagation();
|
|
@@ -1521,9 +1519,7 @@ function initSettingsPopup() {
|
|
|
applyStreamMode(String($(this).data('stream')), true);
|
|
applyStreamMode(String($(this).data('stream')), true);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- $('#set-load-subtitle').on('click', function () { closeSettingsPopup(); pickSubtitleFile(); });
|
|
|
|
|
- $('#set-subtitle-settings').on('click', function () { closeSettingsPopup(); openSubtitleSettings(); });
|
|
|
|
|
- $('#set-video-props').on('click', function () { closeSettingsPopup(); openVideoInfo('props'); });
|
|
|
|
|
|
|
+ $('#set-video-props').on('click', function () { closeSettingsPopup(); openVideoInfo('props'); });
|
|
|
|
|
|
|
|
// Apply persisted values to the video element
|
|
// Apply persisted values to the video element
|
|
|
setRepeatSingle(repeatSingle);
|
|
setRepeatSingle(repeatSingle);
|
|
@@ -2087,15 +2083,16 @@ function ensureEmbeddedFontLoaded(family) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function updateSubtitleSubmenu() {
|
|
|
|
|
- $('#ctx-subtitle-sub .ctx-sub-dynamic').remove();
|
|
|
|
|
|
|
+// Builds the track list (Disable / embedded tracks / loaded sidecar files) into
|
|
|
|
|
+// one menu, given its "Disable" and "Load" anchor items — shared by the
|
|
|
|
|
+// right-click context submenu and the dedicated CC button popup so both stay
|
|
|
|
|
+// in sync from a single call to updateSubtitleSubmenu().
|
|
|
|
|
+function populateSubtitleTrackList($disable, $load, onSelect) {
|
|
|
|
|
+ $disable.siblings('.ctx-sub-dynamic').remove();
|
|
|
var disabled = (activeSubtitleIndex < 0);
|
|
var disabled = (activeSubtitleIndex < 0);
|
|
|
- $('#ctx-sub-disable')
|
|
|
|
|
- .toggleClass('ctx-active', disabled)
|
|
|
|
|
|
|
+ $disable.toggleClass('ctx-active', disabled)
|
|
|
.find('.ctx-icon').text(disabled ? '✓' : '');
|
|
.find('.ctx-icon').text(disabled ? '✓' : '');
|
|
|
|
|
|
|
|
- var $load = $('#ctx-sub-load');
|
|
|
|
|
-
|
|
|
|
|
// Tracks muxed into the video, listed whether or not they are loaded yet
|
|
// Tracks muxed into the video, listed whether or not they are loaded yet
|
|
|
if (embeddedSubtitles.length > 0) {
|
|
if (embeddedSubtitles.length > 0) {
|
|
|
$('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
|
|
$('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
|
|
@@ -2112,7 +2109,7 @@ function updateSubtitleSubmenu() {
|
|
|
+ '</div>')
|
|
+ '</div>')
|
|
|
.on('click', function () {
|
|
.on('click', function () {
|
|
|
selectEmbeddedSubtitle(track, position);
|
|
selectEmbeddedSubtitle(track, position);
|
|
|
- $('#player-ctx').hide();
|
|
|
|
|
|
|
+ onSelect();
|
|
|
})
|
|
})
|
|
|
.insertBefore($load);
|
|
.insertBefore($load);
|
|
|
});
|
|
});
|
|
@@ -2135,13 +2132,23 @@ function updateSubtitleSubmenu() {
|
|
|
activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
|
|
activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
|
|
|
applyActiveSubtitle();
|
|
applyActiveSubtitle();
|
|
|
updateSubtitleSubmenu();
|
|
updateSubtitleSubmenu();
|
|
|
- $('#player-ctx').hide();
|
|
|
|
|
|
|
+ onSelect();
|
|
|
})
|
|
})
|
|
|
.insertBefore($load);
|
|
.insertBefore($load);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function updateSubtitleSubmenu() {
|
|
|
|
|
+ populateSubtitleTrackList($('#ctx-sub-disable'), $('#ctx-sub-load'), function () {
|
|
|
|
|
+ $('#player-ctx').hide();
|
|
|
|
|
+ });
|
|
|
|
|
+ populateSubtitleTrackList($('#sub-pop-disable'), $('#sub-pop-load'), function () {
|
|
|
|
|
+ closeSubtitlePopup();
|
|
|
|
|
+ });
|
|
|
|
|
+ if ($('#subtitle-popup').hasClass('active')) { clampBottomPopupHeight($('#subtitle-popup')); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function initSubtitleMenu() {
|
|
function initSubtitleMenu() {
|
|
|
var $ctx = $('#player-ctx');
|
|
var $ctx = $('#player-ctx');
|
|
|
var $parent = $('#ctx-subtitle-parent');
|
|
var $parent = $('#ctx-subtitle-parent');
|
|
@@ -2162,6 +2169,21 @@ function initSubtitleMenu() {
|
|
|
} else {
|
|
} else {
|
|
|
$sub.css({ left: '100%', right: 'auto' });
|
|
$sub.css({ left: '100%', right: 'auto' });
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // Clamp vertically so the submenu never runs past the player edge —
|
|
|
|
|
+ // flip to open upward when there's more room above than below, and
|
|
|
|
|
+ // cap its height to whichever side it opens on (scrollbar handles the rest).
|
|
|
|
|
+ var margin = 10;
|
|
|
|
|
+ var containerRect = document.getElementById('player-wrap').getBoundingClientRect();
|
|
|
|
|
+ var parentRect = this.getBoundingClientRect();
|
|
|
|
|
+ var spaceBelow = containerRect.bottom - parentRect.top - margin;
|
|
|
|
|
+ var spaceAbove = parentRect.bottom - containerRect.top - margin;
|
|
|
|
|
+ if (spaceBelow < 160 && spaceAbove > spaceBelow) {
|
|
|
|
|
+ $sub.css({ top: 'auto', bottom: 0, maxHeight: Math.max(120, spaceAbove) + 'px' });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $sub.css({ top: 0, bottom: 'auto', maxHeight: Math.max(120, spaceBelow) + 'px' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
$sub.show();
|
|
$sub.show();
|
|
|
});
|
|
});
|
|
|
$parent.on('mouseleave', scheduleSubHide);
|
|
$parent.on('mouseleave', scheduleSubHide);
|
|
@@ -2257,6 +2279,7 @@ function initKeyboard() {
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
$('#player-ctx').hide();
|
|
$('#player-ctx').hide();
|
|
|
closeSettingsPopup();
|
|
closeSettingsPopup();
|
|
|
|
|
+ closeSubtitlePopup();
|
|
|
closeVideoInfo();
|
|
closeVideoInfo();
|
|
|
closeSubtitleSettings(); break;
|
|
closeSubtitleSettings(); break;
|
|
|
|
|
|
|
@@ -2271,6 +2294,7 @@ function initMain(){
|
|
|
initVideoControls();
|
|
initVideoControls();
|
|
|
initContextMenu();
|
|
initContextMenu();
|
|
|
initSettingsPopup();
|
|
initSettingsPopup();
|
|
|
|
|
+ initSubtitlePopup();
|
|
|
initScrubPreview();
|
|
initScrubPreview();
|
|
|
initAssRenderer();
|
|
initAssRenderer();
|
|
|
resetSubtitleSeekFloor(); // a freshly opened file starts unsuppressed
|
|
resetSubtitleSeekFloor(); // a freshly opened file starts unsuppressed
|