chore: fix some usability

This commit is contained in:
Emilien 2026-08-03 02:48:42 +02:00
parent 240c8f0f22
commit ce6d2d6d7e
3 changed files with 160 additions and 10 deletions

View File

@ -72,7 +72,14 @@
}
.shaka-bottom-controls {
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0));
background: transparent;
}
/* Control-contrast gradient goes on the SCRIM, which Shaka fades in/out together with the
controls. (Putting it on .shaka-bottom-controls left a permanent black band at the bottom
because that element stays visible when the controls auto-hide.) */
.shaka-scrim-container {
background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0));
}
/* Buttons - match video.js colors */
@ -249,8 +256,40 @@
display: none !important;
}
/* Hide Shaka's built-in buffering spinner we drive our own (.sabr-loading-spinner),
which also covers the pre-load SABR init. Otherwise both show at once. */
.shaka-spinner-container {
display: none !important;
}
/* Poster/thumbnail styling */
.shaka-video-container .shaka-poster {
background-size: cover;
object-fit: cover;
}
/* Loading spinner shown while the container has .is-loading (SABR init + buffering).
Driven by sabr_player.js (shaka's own spinner only covers its post-load buffering). */
.sabr-loading-spinner {
display: none;
position: absolute;
top: 50%;
left: 50%;
width: 56px;
height: 56px;
margin: -28px 0 0 -28px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: rgba(0, 182, 240, 1);
border-radius: 50%;
animation: sabr-spin 0.9s linear infinite;
z-index: 20;
pointer-events: none;
}
.sabr-player-container.is-loading .sabr-loading-spinner {
display: block;
}
@keyframes sabr-spin {
to { transform: rotate(360deg); }
}

View File

@ -326,7 +326,10 @@
for (var j = 0; j < storyboard.storyboardCount; j++) {
var startTime = j * segmentDuration;
var endTime = Math.min(startTime + segmentDuration, duration);
var urls = [storyboard.templateUrl.replace('$M', String(j))];
// NOTE: must be `let` (block-scoped). With `var` the closure below captures the
// function-scoped binding, so every SegmentReference returns the LAST storyboard
// image -> the same (mismatched, distorted) thumbnail for the whole video.
let urls = [storyboard.templateUrl.replace('$M', String(j))];
var segmentReference = new shaka.media.SegmentReference(
startTime, endTime,
function () { return urls; },

View File

@ -60,6 +60,7 @@ var SABRPlayer = (function () {
// per reload (loadVideo re-runs on every SABR reload).
var requestFiltersInstalled = false;
var loadedListenerInstalled = false;
var keydownInstalled = false;
var sabrManifest = null; // captured from player.getManifest() on 'loaded'
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
var reloadCount = 0; // cap reloads to prevent infinite loop on blocked/throttled videos
@ -162,8 +163,9 @@ var SABRPlayer = (function () {
async function fetchOnesieConfig() {
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) return clientConfig;
var cached = SABRHelpers.loadCachedClientConfig();
if (cached) { clientConfig = cached; return clientConfig; }
// Do NOT read a persisted (localStorage) config: its baseUrl embeds the egress IP
// that YouTube saw at fetch time, so a cached config goes stale when the proxy/egress
// IP changes and the CDN rejects the mismatched IP. Always refetch fresh.
try {
var tvConfigResponse = await SABRHelpers.fetchWithProxy(
'https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl',
@ -185,7 +187,7 @@ var SABRPlayer = (function () {
baseUrl: onesieHotConfig.baseUrl,
timestamp: Date.now()
};
SABRHelpers.saveCachedClientConfig(clientConfig);
// Not persisted to localStorage on purpose (baseUrl embeds a now-stale-on-proxy-change ip).
return clientConfig;
} catch (error) {
console.error('[SABRPlayer]', 'Failed to fetch Onesie client config', error);
@ -294,7 +296,8 @@ var SABRPlayer = (function () {
shaka.polyfill.installAll();
shakaContainer = document.createElement('div');
shakaContainer.className = 'sabr-player-container';
// Start in the loading state so the spinner is visible during the initial SABR init.
shakaContainer.className = 'sabr-player-container is-loading';
shakaContainer.style.width = '100%';
shakaContainer.style.height = '100%';
@ -305,6 +308,14 @@ var SABRPlayer = (function () {
videoElement.style.backgroundColor = '#000';
shakaContainer.appendChild(videoElement);
// Loading spinner shown during our custom SABR init (Innertube/Onesie/PO token/load)
// and whenever the video is buffering. Shaka's own spinner only covers its own
// buffering phase (after player.load), not the pre-load init, so we drive our own.
var spinnerEl = document.createElement('div');
spinnerEl.className = 'sabr-loading-spinner';
shakaContainer.appendChild(spinnerEl);
containerElement.appendChild(shakaContainer);
player = new shaka.Player();
@ -329,11 +340,20 @@ var SABRPlayer = (function () {
videoElement.addEventListener('volumechange', function () { saveVolume(videoElement.volume); });
videoElement.addEventListener('playing', function () {
player.configure('abr.restrictions.maxHeight', Infinity);
hideSpinner();
});
videoElement.addEventListener('waiting', function () { showSpinner(); });
videoElement.addEventListener('canplay', function () { hideSpinner(); });
videoElement.addEventListener('pause', function () {
if (currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
});
// Page-level keyboard shortcuts (installed once per SABRPlayer instance).
if (!keydownInstalled) {
document.addEventListener('keydown', handleKeydown);
keydownInstalled = true;
}
await player.attach(videoElement);
if (shaka.ui && shaka.ui.Overlay) {
@ -432,6 +452,28 @@ var SABRPlayer = (function () {
return out;
}
// Rewrite a storyboard template URL to a same-origin URL so the seek-bar thumbnail tiles
// load under CSP img-src 'self'. Reuses Invidious's EXISTING storyboard proxy route (the
// one the classic video.js player uses), matching src/invidious/videos/storyboard.cr:
// https://<authority>.ytimg.com/sb/<rest> -> /sb/<authority>/<rest>
// A raw string swap (not URL parsing) preserves the '$M' tile placeholder and the literal
// '$' inside the signature (sigh=rs$AOn...).
function proxyStoryboardUrl(templateUrl) {
var m = templateUrl.match(/^https?:\/\/(i\d?)\.ytimg\.com\/sb\/(.*)$/);
if (m) {
return '/sb/' + m[1] + '/' + m[2];
}
// Fallback for non-ytimg hosts: generic SABR /proxy (same-origin for CSP).
try {
var u = new URL(templateUrl);
return '/proxy' + u.pathname +
(u.search ? u.search + '&' : '?') +
'__host=' + encodeURIComponent(u.host) + '&__headers=%5B%5D';
} catch (e) {
return templateUrl;
}
}
function buildStoryboards(videoInfo) {
var out = [];
var sb = videoInfo.storyboards;
@ -441,7 +483,7 @@ var SABRPlayer = (function () {
var board = boards.length ? boards[boards.length - 1] : null;
if (!board) return out;
out.push({
templateUrl: board.template_url,
templateUrl: proxyStoryboardUrl(board.template_url),
mimeType: 'image/webp',
columns: board.columns,
rows: board.rows,
@ -528,6 +570,61 @@ var SABRPlayer = (function () {
function getPlayer() { return player; }
function getManifest() { return sabrManifest; }
// Loading spinner: toggled via an 'is-loading' class on the player container.
function showSpinner() { if (shakaContainer) shakaContainer.classList.add('is-loading'); }
function hideSpinner() { if (shakaContainer) shakaContainer.classList.remove('is-loading'); }
// Global keyboard shortcuts (YouTube-style). Shaka's built-in shortcuts only fire when a
// control is focused, so we add a page-level handler. Ignored while typing in a field.
function handleKeydown(e) {
if (!player || !videoElement) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
var t = e.target;
var tag = (t && t.tagName) || '';
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (t && t.isContentEditable)) return;
var dur = isFinite(videoElement.duration) ? videoElement.duration : Infinity;
var handled = true;
switch (e.key) {
case ' ':
case 'k':
if (videoElement.paused) videoElement.play().catch(function () {}); else videoElement.pause();
break;
case 'ArrowLeft':
videoElement.currentTime = Math.max(0, videoElement.currentTime - 5);
break;
case 'ArrowRight':
videoElement.currentTime = Math.min(dur, videoElement.currentTime + 5);
break;
case 'j':
videoElement.currentTime = Math.max(0, videoElement.currentTime - 10);
break;
case 'l':
videoElement.currentTime = Math.min(dur, videoElement.currentTime + 10);
break;
case 'ArrowUp':
videoElement.volume = Math.min(1, videoElement.volume + 0.05);
break;
case 'ArrowDown':
videoElement.volume = Math.max(0, videoElement.volume - 0.05);
break;
case 'm':
videoElement.muted = !videoElement.muted;
break;
case 'f':
if (document.fullscreenElement) { document.exitFullscreen(); }
else if (shakaContainer && shakaContainer.requestFullscreen) { shakaContainer.requestFullscreen(); }
break;
default:
// number keys 0-9 -> seek to that fraction of the video
if (e.key >= '0' && e.key <= '9' && isFinite(dur)) {
videoElement.currentTime = dur * (parseInt(e.key, 10) / 10);
} else {
handled = false;
}
}
if (handled) e.preventDefault();
}
function wireSabrStream(loadFn) {
if (!sabrStream) return;
sabrStream.onBackoffRequested(function (info) {
@ -566,6 +663,7 @@ var SABRPlayer = (function () {
// session identity. The 60s SABR wall is tied to the session/streaming
// context, so a fresh session is what actually lets the reload recover.
var resumeAt = (videoElement && isFinite(videoElement.currentTime)) ? videoElement.currentTime : undefined;
var wasPaused = !!(videoElement && videoElement.paused);
innertube = null;
clientConfig = null;
playbackWebPoToken = null;
@ -573,6 +671,9 @@ var SABRPlayer = (function () {
try { BotguardService.dispose(); } catch (e) {}
var reloadOptions = Object.assign({}, currentLoadOptions || {});
if (resumeAt !== undefined && resumeAt > 1) reloadOptions.startTime = resumeAt;
// Don't force playback if the user had paused: a server-requested reload should
// resume at the same position AND keep the paused state (not "play again").
reloadOptions.startPaused = wasPaused;
if (currentVideoId && loadFn) loadFn(currentVideoId, shakaContainer, reloadOptions);
});
}
@ -584,6 +685,7 @@ var SABRPlayer = (function () {
currentLoadOptions = options;
playbackWebPoToken = null;
playbackWebPoTokenContentBinding = videoId;
showSpinner(); // re-show on reloads (container already exists)
try {
// Start Shaka player init (DOM + polyfills) in parallel with network init.
@ -738,9 +840,13 @@ var SABRPlayer = (function () {
: undefined;
await player.load(manifestUri, isLive ? undefined : startTime, mimeType);
videoElement.play().catch(function (err) {
if (err.name === 'NotAllowedError') console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
});
// Don't auto-play on a reload that happened while the user had the video paused
// (options.startPaused set by the SABR reload handler).
if (!options.startPaused) {
videoElement.play().catch(function (err) {
if (err.name === 'NotAllowedError') console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
});
}
if (savePositionInterval) clearInterval(savePositionInterval);
savePositionInterval = setInterval(function () {
@ -752,12 +858,14 @@ var SABRPlayer = (function () {
return { player: player, videoElement: videoElement, videoInfo: videoInfo };
} catch (error) {
console.error('[SABRPlayer]', 'Error loading video:', error);
hideSpinner();
throw error;
}
}
async function dispose() {
if (savePositionInterval) { clearInterval(savePositionInterval); savePositionInterval = null; }
if (keydownInstalled) { document.removeEventListener('keydown', handleKeydown); keydownInstalled = false; }
if (videoElement && currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
if (sabrStream) { try { sabrStream.cleanup(); } catch (e) {} sabrStream = null; }