live stream support

This commit is contained in:
Simon
2026-06-22 12:34:47 +00:00
parent a97f7e7b0f
commit b5b3e13dd0
9 changed files with 271 additions and 35 deletions

View File

@@ -38,6 +38,7 @@ App.favorites = App.favorites || {};
channel: video.channel || (meta && meta.channel) || '',
uploader: video.uploader || (meta && meta.uploader) || '',
duration: video.duration || (meta && meta.duration) || 0,
isLive: !!(video.isLive || (meta && meta.isLive)),
meta: meta
};
};
@@ -103,14 +104,16 @@ App.favorites = App.favorites || {};
card.className = 'favorite-card';
card.dataset.favKey = item.key;
const uploaderText = item.uploader || '';
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
card.innerHTML = `
${liveBadge}
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button>
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
<div class="video-menu" role="menu">
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
</div>
<img src="${item.thumb}" alt="${item.title}">
<img src="${item.thumb}" alt="${item.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>

View File

@@ -138,31 +138,55 @@ App.feed = App.feed || {};
if (!resolved.url) return;
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`;
const isHls = /\.m3u8($|\?)/i.test(resolved.url);
const canUseHls = !!(window.Hls && window.Hls.isSupported());
const liveParam = resolved.isLive ? '&live=1' : '';
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
video.muted = state.feedMuted;
video.preload = 'auto';
if (isHls && canUseHls) {
const hls = new window.Hls();
const startPlay = () => {
if (!autoplay) return;
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
};
const attachHls = (HlsLib) => {
const hls = new HlsLib();
video._hlsPlayer = hls;
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(window.Hls.Events.ERROR, (event, data) => {
hls.on(HlsLib.Events.ERROR, (event, data) => {
if (data && data.fatal && video._hlsPlayer === hls) {
hls.destroy();
video._hlsPlayer = null;
}
});
} else {
startPlay();
};
const startNative = () => {
video.src = streamUrl;
startPlay();
};
if (!isHls) {
startNative();
return;
}
if (autoplay) {
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
if (window.Hls && window.Hls.isSupported()) {
attachHls(window.Hls);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
startNative();
} else {
// Lazy-load hls.js on demand for the first HLS slide.
App.ensureHls()
.then((HlsLib) => {
if (HlsLib && HlsLib.isSupported()) attachHls(HlsLib);
else startNative();
})
.catch(startNative);
}
};
@@ -177,15 +201,17 @@ App.feed = App.feed || {};
if (!scroller) return null;
const slide = document.createElement('div');
slide.className = 'feed-slide';
slide.className = v.isLive ? 'feed-slide is-live' : 'feed-slide';
slide.dataset.videoId = v.id;
slide.dataset.index = String(index);
slide._videoData = v;
slide._index = index;
const uploaderText = v.uploader || '';
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
slide.innerHTML = `
<img class="feed-poster" src="${v.thumb || ''}" alt="">
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async">
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
${liveBadge}
<div class="feed-info">
<h4 class="feed-title">${v.title || ''}</h4>
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}

View File

@@ -77,9 +77,16 @@ App.player = App.player || {};
}
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`;
const liveParam = resolved.isLive ? '&live=1' : '';
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
// Live cam streams resolve (server-side) to HLS; treat them as HLS up
// front so we skip the content-type HEAD probe and go straight to it.
if (resolved.isLive) {
isHls = true;
isDirectMedia = false;
}
// Cleanup existing player instance to prevent aborted bindings.
if (state.hlsPlayer) {
@@ -159,6 +166,16 @@ App.player = App.player || {};
}
};
// Confirmed direct media (mp4/webm/…) never needs hls.js; everything
// else might, so pull it in now that the type has been sniffed.
if (!window.Hls && (isHls || !isDirectMedia)) {
try {
await App.ensureHls();
} catch (err) {
// Fall back to native playback below.
}
}
const canUseHls = !!(window.Hls && window.Hls.isSupported());
const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl'));
let hlsTried = false;

View File

@@ -27,3 +27,23 @@ App.constants = {
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
PREFERRED_QUALITY_KEY: 'preferredQuality'
};
// Lazily injects hls.js the first time a stream actually needs it. Sessions
// that only browse thumbnails, or that play native/MP4, never download it.
// Resolves with window.Hls (or null if loading failed).
App.ensureHls = function() {
if (window.Hls) return Promise.resolve(window.Hls);
if (App._hlsPromise) return App._hlsPromise;
App._hlsPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5';
script.async = true;
script.onload = () => resolve(window.Hls || null);
script.onerror = () => {
App._hlsPromise = null;
reject(new Error('Failed to load hls.js'));
};
document.head.appendChild(script);
});
return App._hlsPromise;
};

View File

@@ -556,6 +556,9 @@ App.ui = App.ui || {};
const searchInput = document.getElementById('search-input');
const clearSearchBtn = document.getElementById('search-clear-btn');
if (searchInput && clearSearchBtn) {
let searchDebounce = null;
const SEARCH_DEBOUNCE_MS = 300;
const updateClearVisibility = () => {
const hasValue = searchInput.value.trim().length > 0;
clearSearchBtn.classList.toggle('is-visible', hasValue);
@@ -565,13 +568,24 @@ App.ui = App.ui || {};
clearSearchBtn.addEventListener('click', (event) => {
event.preventDefault();
if (!searchInput.value) return;
if (searchDebounce) clearTimeout(searchDebounce);
searchInput.value = '';
updateClearVisibility();
App.videos.handleSearch('');
searchInput.focus();
});
// Update the clear button immediately for snappy feedback, but
// debounce the actual reload so typing doesn't wipe the grid and
// fire a backend request on every keystroke.
searchInput.addEventListener('input', updateClearVisibility);
searchInput.addEventListener('input', () => {
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
searchDebounce = null;
App.videos.handleSearch(searchInput.value);
}, SEARCH_DEBOUNCE_MS);
});
updateClearVisibility();
}

View File

@@ -110,7 +110,7 @@ App.videos = App.videos || {};
App.videos.buildImageProxyUrl = function(imageUrl) {
if (!imageUrl) return '';
try {
return `/api/image?url=${encodeURIComponent(imageUrl)}&ts=${Date.now()}`;
return `/api/image?url=${encodeURIComponent(imageUrl)}`;
} catch (err) {
return '';
}
@@ -290,14 +290,16 @@ App.videos = App.videos || {};
const tagsMarkup = tags.length
? `<div class="video-tags">${tags.map(tag => `<button class="video-tag" type="button" data-tag="${tag}">${tag}</button>`).join('')}</div>`
: '';
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
card.innerHTML = `
${liveBadge}
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
<div class="video-menu" role="menu">
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
</div>
<img src="${v.thumb}" alt="${v.title}">
<img src="${v.thumb}" alt="${v.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>
@@ -432,11 +434,16 @@ App.videos = App.videos || {};
App.videos.handleSearch = function(value) {
if (typeof value === 'string') {
const searchInput = document.getElementById('search-input');
if (searchInput) {
if (searchInput.value !== value) {
searchInput.value = value;
}
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
if (searchInput && searchInput.value !== value) {
searchInput.value = value;
}
// Keep the clear button in sync without re-dispatching an `input`
// event (which would re-trigger the debounced reload listener).
const clearBtn = document.getElementById('search-clear-btn');
if (searchInput && clearBtn) {
const hasValue = searchInput.value.trim().length > 0;
clearBtn.classList.toggle('is-visible', hasValue);
clearBtn.disabled = !hasValue;
}
}
state.currentPage = 1;
@@ -600,6 +607,8 @@ App.videos = App.videos || {};
let sourceUrl = '';
let referer = '';
let userAgent = '';
const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' &&
(videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive)));
if (typeof videoOrUrl === 'string') {
sourceUrl = videoOrUrl;
} else if (videoOrUrl && typeof videoOrUrl === 'object') {
@@ -634,7 +643,7 @@ App.videos = App.videos || {};
referer = '';
}
}
return { url: sourceUrl, referer, userAgent };
return { url: sourceUrl, referer, userAgent, isLive };
};
// Builds a proxied stream URL. Extra params other than `url` are forwarded
@@ -644,7 +653,8 @@ App.videos = App.videos || {};
if (!resolved.url) return '';
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`;
const liveParam = resolved.isLive ? '&live=1' : '';
return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
};
App.videos.downloadVideo = function(video) {