diff --git a/frontend/css/style.css b/frontend/css/style.css
index 9d61029..964e302 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -939,6 +939,39 @@ body.theme-light .setting-item select option {
text-align: left;
}
+.video-loading {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.45);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ pointer-events: none;
+ z-index: 4;
+}
+
+.video-card.is-loading .video-loading,
+.favorite-card.is-loading .video-loading {
+ opacity: 1;
+}
+
+.video-loading-spinner {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ border: 3px solid rgba(255, 255, 255, 0.35);
+ border-top-color: var(--text-primary);
+ animation: video-card-spinner 0.8s linear infinite;
+}
+
+@keyframes video-card-spinner {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.uploader-link:hover {
color: var(--text-primary);
text-decoration: underline;
diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js
index 1426d9a..dcded10 100644
--- a/frontend/js/favorites.js
+++ b/frontend/js/favorites.js
@@ -111,6 +111,9 @@ App.favorites = App.favorites || {};
+
${item.title}
${uploaderText ? `
` : ''}
@@ -120,7 +123,11 @@ App.favorites = App.favorites || {};
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') {
App.videos.attachNoReferrerRetry(thumb);
}
- card.onclick = () => App.player.open(item.meta || item);
+ card.onclick = () => {
+ if (card.classList.contains('is-loading')) return;
+ card.classList.add('is-loading');
+ App.player.open(item.meta || item, { originEl: card });
+ };
const favoriteBtn = card.querySelector('.favorite-btn');
if (favoriteBtn) {
favoriteBtn.onclick = (event) => {
diff --git a/frontend/js/player.js b/frontend/js/player.js
index 93926f9..cc93a55 100644
--- a/frontend/js/player.js
+++ b/frontend/js/player.js
@@ -29,10 +29,22 @@ App.player = App.player || {};
return host;
}
- App.player.open = async function(source) {
+ App.player.open = async function(source, opts) {
const modal = document.getElementById('video-modal');
const video = document.getElementById('player');
- if (!modal || !video) return;
+ const originEl = opts && opts.originEl ? opts.originEl : null;
+ const clearLoading = () => {
+ if (originEl) {
+ originEl.classList.remove('is-loading');
+ }
+ };
+ if (originEl) {
+ originEl.classList.add('is-loading');
+ }
+ if (!modal || !video) {
+ clearLoading();
+ return;
+ }
const useMobileFullscreen = isMobilePlayback() || isTvPlayback();
let playbackStarted = false;
@@ -60,6 +72,7 @@ App.player = App.player || {};
if (App.ui && App.ui.showError) {
App.ui.showError('Unable to play this stream.');
}
+ clearLoading();
return;
}
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
@@ -128,6 +141,7 @@ App.player = App.player || {};
const startPlayback = () => {
if (playbackStarted) return;
playbackStarted = true;
+ clearLoading();
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {});
@@ -152,6 +166,7 @@ App.player = App.player || {};
startPlayback();
state.hlsPlayer.on(window.Hls.Events.ERROR, function(event, data) {
if (data && data.fatal) {
+ clearLoading();
if (App.ui && App.ui.showError) {
App.ui.showError('Unable to play this stream.');
}
@@ -166,6 +181,7 @@ App.player = App.player || {};
if (App.ui && App.ui.showError) {
App.ui.showError('HLS is not supported in this browser.');
}
+ clearLoading();
return;
}
} else {
@@ -174,6 +190,7 @@ App.player = App.player || {};
}
video.onerror = () => {
+ clearLoading();
if (App.ui && App.ui.showError) {
App.ui.showError('Video failed to load.');
}
diff --git a/frontend/js/videos.js b/frontend/js/videos.js
index 218d4ae..65f8224 100644
--- a/frontend/js/videos.js
+++ b/frontend/js/videos.js
@@ -134,6 +134,9 @@ App.videos = App.videos || {};
+
${v.title}
${uploaderText ? `` : ''}
${durationText ? `${durationText}
` : ''}
@@ -183,7 +186,11 @@ App.videos = App.videos || {};
App.videos.closeAllMenus();
};
}
- card.onclick = () => App.player.open(v);
+ card.onclick = () => {
+ if (card.classList.contains('is-loading')) return;
+ card.classList.add('is-loading');
+ App.player.open(v, { originEl: card });
+ };
grid.appendChild(card);
state.renderedVideoIds.add(v.id);
});