live stream support
This commit is contained in:
134
backend/main.py
134
backend/main.py
@@ -210,13 +210,15 @@ def stream_video():
|
||||
video_url = ""
|
||||
if request.method == 'POST':
|
||||
video_url = request.json.get('url')
|
||||
live_hint = bool((request.json or {}).get('live'))
|
||||
else:
|
||||
video_url = request.args.get('url')
|
||||
live_hint = str(request.args.get('live', '')).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
if not video_url:
|
||||
return jsonify({"error": "No URL provided"}), 400
|
||||
|
||||
dbg(f"method={request.method} url={video_url}")
|
||||
dbg(f"method={request.method} url={video_url} live={live_hint}")
|
||||
|
||||
def is_hls(url):
|
||||
return '.m3u8' in urllib.parse.urlparse(url).path
|
||||
@@ -529,11 +531,96 @@ def stream_video():
|
||||
return None
|
||||
return headers.get('Referer') or headers.get('referer')
|
||||
|
||||
def build_master_playlist(info, referer):
|
||||
"""Synthesize an HLS master playlist from yt-dlp's parsed formats.
|
||||
|
||||
yt-dlp already downloads and parses the upstream master during
|
||||
extraction, so we reconstruct an equivalent master that points each
|
||||
variant playlist at our proxy. This avoids re-fetching the upstream
|
||||
master (some sites issue a single-use session token on it, which the
|
||||
extraction already consumed) and works generically for any HLS source
|
||||
that exposes separate audio/video renditions. Returns the playlist
|
||||
text, or None if there aren't enough HLS formats to build one.
|
||||
"""
|
||||
formats = info.get('formats') or []
|
||||
|
||||
def codec_present(value):
|
||||
return value not in (None, '', 'none')
|
||||
|
||||
def is_hls_format(fmt):
|
||||
url = fmt.get('url') or ''
|
||||
return bool(url) and ('m3u8' in str(fmt.get('protocol') or '') or is_hls(url))
|
||||
|
||||
audio_fmts, video_fmts = [], []
|
||||
for fmt in formats:
|
||||
if not is_hls_format(fmt):
|
||||
continue
|
||||
if codec_present(fmt.get('vcodec')):
|
||||
video_fmts.append(fmt)
|
||||
elif codec_present(fmt.get('acodec')):
|
||||
audio_fmts.append(fmt)
|
||||
|
||||
if not video_fmts:
|
||||
return None
|
||||
|
||||
def proxied(url):
|
||||
return (f"/api/stream?url={urllib.parse.quote(url, safe='')}"
|
||||
f"&referer={urllib.parse.quote(referer, safe='')}")
|
||||
|
||||
lines = ['#EXTM3U', '#EXT-X-VERSION:3']
|
||||
|
||||
audio_group = None
|
||||
if audio_fmts:
|
||||
audio_group = 'aud'
|
||||
for index, fmt in enumerate(audio_fmts):
|
||||
name = (fmt.get('format_note') or fmt.get('language')
|
||||
or fmt.get('format_id') or f'audio{index}')
|
||||
attrs = [
|
||||
'TYPE=AUDIO',
|
||||
f'GROUP-ID="{audio_group}"',
|
||||
f'NAME="{name}"',
|
||||
f'DEFAULT={"YES" if index == 0 else "NO"}',
|
||||
'AUTOSELECT=YES',
|
||||
]
|
||||
if fmt.get('language'):
|
||||
attrs.append(f'LANGUAGE="{fmt["language"]}"')
|
||||
attrs.append(f'URI="{proxied(fmt["url"])}"')
|
||||
lines.append('#EXT-X-MEDIA:' + ','.join(attrs))
|
||||
|
||||
for fmt in video_fmts:
|
||||
bitrate = fmt.get('tbr') or fmt.get('vbr')
|
||||
bandwidth = int(float(bitrate) * 1000) if bitrate else 1000000
|
||||
codecs = []
|
||||
if codec_present(fmt.get('vcodec')):
|
||||
codecs.append(fmt['vcodec'])
|
||||
if audio_group and codec_present(audio_fmts[0].get('acodec')):
|
||||
codecs.append(audio_fmts[0]['acodec'])
|
||||
elif codec_present(fmt.get('acodec')):
|
||||
codecs.append(fmt['acodec'])
|
||||
attrs = [f'BANDWIDTH={bandwidth}']
|
||||
if fmt.get('width') and fmt.get('height'):
|
||||
attrs.append(f'RESOLUTION={int(fmt["width"])}x{int(fmt["height"])}')
|
||||
if fmt.get('fps'):
|
||||
attrs.append(f'FRAME-RATE={float(fmt["fps"]):.3f}')
|
||||
if codecs:
|
||||
attrs.append(f'CODECS="{",".join(codecs)}"')
|
||||
if audio_group:
|
||||
attrs.append(f'AUDIO="{audio_group}"')
|
||||
lines.append('#EXT-X-STREAM-INF:' + ','.join(attrs))
|
||||
lines.append(proxied(fmt['url']))
|
||||
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
try:
|
||||
# Configure yt-dlp options
|
||||
ydl_opts = {
|
||||
# Prefer HLS when available to enable chunked streaming in the browser.
|
||||
'format': 'best[protocol*=m3u8]/best[ext=mp4]/best',
|
||||
# Live cam streams expose only separate video-only and audio-only HLS
|
||||
# tracks (no muxed format), so `best` alone raises "Requested format
|
||||
# is not available". Fall back to the best video-only rendition; we
|
||||
# then hand the browser the master manifest below so its HLS player
|
||||
# can pull in the matching audio track.
|
||||
'format': 'best[protocol*=m3u8]/best[ext=mp4]/best/bestvideo[protocol*=m3u8]/bestvideo',
|
||||
'format_sort': ['res', 'fps', 'vcodec:avc1', 'acodec:aac'],
|
||||
'quiet': False,
|
||||
'no_warnings': False,
|
||||
@@ -587,26 +674,55 @@ def stream_video():
|
||||
elif isinstance(info.get('http_headers'), dict):
|
||||
upstream_headers = info['http_headers']
|
||||
|
||||
if request.method == 'HEAD' and selected_format:
|
||||
manifest_url = selected_format.get('manifest_url')
|
||||
if manifest_url:
|
||||
return Response("", status=301, headers=[('Location', f"/api/stream?url={manifest_url}")])
|
||||
|
||||
referer_hint = None
|
||||
if upstream_headers:
|
||||
referer_hint = extract_referer(upstream_headers)
|
||||
if not referer_hint:
|
||||
parsed = urllib.parse.urlparse(video_url)
|
||||
referer_hint = f"{parsed.scheme}://{parsed.netloc}/"
|
||||
|
||||
# When the chosen rendition carries no muxed audio (live cam
|
||||
# streams) or the source is live, the browser needs a *master*
|
||||
# playlist so its HLS player can combine the separate audio + video
|
||||
# renditions. We synthesize that master from yt-dlp's already-parsed
|
||||
# formats rather than re-fetching the upstream master.
|
||||
def format_lacks_audio(fmt):
|
||||
if not isinstance(fmt, dict):
|
||||
return False
|
||||
acodec = str(fmt.get('acodec') or '').lower()
|
||||
vcodec = str(fmt.get('vcodec') or '').lower()
|
||||
return vcodec not in ('', 'none') and acodec in ('', 'none')
|
||||
|
||||
needs_master = bool(info.get('is_live') or live_hint or format_lacks_audio(selected_format))
|
||||
master_playlist = build_master_playlist(info, referer_hint) if needs_master else None
|
||||
dbg(f"is_live={info.get('is_live')} needs_master={needs_master} synthesized={bool(master_playlist)}")
|
||||
|
||||
if master_playlist:
|
||||
if request.method == 'HEAD':
|
||||
return Response("", status=200, content_type='application/vnd.apple.mpegurl')
|
||||
return Response(master_playlist, status=200, content_type='application/vnd.apple.mpegurl')
|
||||
|
||||
# Synthesis unavailable (e.g. a single muxed variant): fall back to
|
||||
# the upstream master URL when one exists, else the variant itself.
|
||||
master_fallback = None
|
||||
if needs_master:
|
||||
master_fallback = (selected_format or {}).get('manifest_url') or info.get('manifest_url')
|
||||
|
||||
if request.method == 'HEAD' and selected_format:
|
||||
head_manifest = master_fallback or selected_format.get('manifest_url')
|
||||
if head_manifest:
|
||||
location = f"/api/stream?url={urllib.parse.quote(head_manifest, safe='')}"
|
||||
return Response("", status=301, headers=[('Location', location)])
|
||||
|
||||
dbg(f"resolved stream_url={stream_url} referer_hint={referer_hint}")
|
||||
|
||||
if protocol and 'm3u8' in protocol:
|
||||
dbg("protocol indicates hls")
|
||||
return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers)
|
||||
return proxy_hls_playlist(master_fallback or stream_url, referer_hint, upstream_headers=upstream_headers)
|
||||
|
||||
if is_hls(stream_url):
|
||||
dbg("stream_url is hls")
|
||||
return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers)
|
||||
return proxy_hls_playlist(master_fallback or stream_url, referer_hint, upstream_headers=upstream_headers)
|
||||
|
||||
if is_dash(stream_url):
|
||||
dbg("stream_url is dash")
|
||||
|
||||
@@ -1095,6 +1095,37 @@ body.theme-light .video-menu-btn {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e0245e;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.feed-live-badge {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
/* Live streams can't be seeked, so hide the feed scrubber. */
|
||||
.feed-slide.is-live .feed-timeline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<header class="top-bar">
|
||||
<div class="logo">Jacuzzi</div>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search videos..." oninput="handleSearch(this.value)">
|
||||
<input type="text" id="search-input" placeholder="Search videos...">
|
||||
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
@@ -162,6 +162,5 @@
|
||||
<script src="static/js/feed.js"></script>
|
||||
<script src="static/js/ui.js"></script>
|
||||
<script src="static/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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) {
|
||||
if (searchInput && searchInput.value !== value) {
|
||||
searchInput.value = value;
|
||||
}
|
||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// 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