live stream support
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>` : ''}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user