live stream support
This commit is contained in:
136
backend/main.py
136
backend/main.py
@@ -210,13 +210,15 @@ def stream_video():
|
|||||||
video_url = ""
|
video_url = ""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
video_url = request.json.get('url')
|
video_url = request.json.get('url')
|
||||||
|
live_hint = bool((request.json or {}).get('live'))
|
||||||
else:
|
else:
|
||||||
video_url = request.args.get('url')
|
video_url = request.args.get('url')
|
||||||
|
live_hint = str(request.args.get('live', '')).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
|
||||||
if not video_url:
|
if not video_url:
|
||||||
return jsonify({"error": "No URL provided"}), 400
|
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):
|
def is_hls(url):
|
||||||
return '.m3u8' in urllib.parse.urlparse(url).path
|
return '.m3u8' in urllib.parse.urlparse(url).path
|
||||||
@@ -529,11 +531,96 @@ def stream_video():
|
|||||||
return None
|
return None
|
||||||
return headers.get('Referer') or headers.get('referer')
|
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:
|
try:
|
||||||
# Configure yt-dlp options
|
# Configure yt-dlp options
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
# Prefer HLS when available to enable chunked streaming in the browser.
|
# 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'],
|
'format_sort': ['res', 'fps', 'vcodec:avc1', 'acodec:aac'],
|
||||||
'quiet': False,
|
'quiet': False,
|
||||||
'no_warnings': False,
|
'no_warnings': False,
|
||||||
@@ -587,26 +674,55 @@ def stream_video():
|
|||||||
elif isinstance(info.get('http_headers'), dict):
|
elif isinstance(info.get('http_headers'), dict):
|
||||||
upstream_headers = info['http_headers']
|
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
|
referer_hint = None
|
||||||
if upstream_headers:
|
if upstream_headers:
|
||||||
referer_hint = extract_referer(upstream_headers)
|
referer_hint = extract_referer(upstream_headers)
|
||||||
if not referer_hint:
|
if not referer_hint:
|
||||||
parsed = urllib.parse.urlparse(video_url)
|
parsed = urllib.parse.urlparse(video_url)
|
||||||
referer_hint = f"{parsed.scheme}://{parsed.netloc}/"
|
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}")
|
dbg(f"resolved stream_url={stream_url} referer_hint={referer_hint}")
|
||||||
|
|
||||||
if protocol and 'm3u8' in protocol:
|
if protocol and 'm3u8' in protocol:
|
||||||
dbg("protocol indicates hls")
|
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):
|
if is_hls(stream_url):
|
||||||
dbg("stream_url is hls")
|
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):
|
if is_dash(stream_url):
|
||||||
dbg("stream_url is dash")
|
dbg("stream_url is dash")
|
||||||
|
|||||||
@@ -1095,6 +1095,37 @@ body.theme-light .video-menu-btn {
|
|||||||
background: var(--bg-tertiary);
|
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 {
|
.favorite-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<div class="logo">Jacuzzi</div>
|
<div class="logo">Jacuzzi</div>
|
||||||
<div class="search-container">
|
<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>
|
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -162,6 +162,5 @@
|
|||||||
<script src="static/js/feed.js"></script>
|
<script src="static/js/feed.js"></script>
|
||||||
<script src="static/js/ui.js"></script>
|
<script src="static/js/ui.js"></script>
|
||||||
<script src="static/js/main.js"></script>
|
<script src="static/js/main.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ App.favorites = App.favorites || {};
|
|||||||
channel: video.channel || (meta && meta.channel) || '',
|
channel: video.channel || (meta && meta.channel) || '',
|
||||||
uploader: video.uploader || (meta && meta.uploader) || '',
|
uploader: video.uploader || (meta && meta.uploader) || '',
|
||||||
duration: video.duration || (meta && meta.duration) || 0,
|
duration: video.duration || (meta && meta.duration) || 0,
|
||||||
|
isLive: !!(video.isLive || (meta && meta.isLive)),
|
||||||
meta: meta
|
meta: meta
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -103,14 +104,16 @@ App.favorites = App.favorites || {};
|
|||||||
card.className = 'favorite-card';
|
card.className = 'favorite-card';
|
||||||
card.dataset.favKey = item.key;
|
card.dataset.favKey = item.key;
|
||||||
const uploaderText = item.uploader || '';
|
const uploaderText = item.uploader || '';
|
||||||
|
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
||||||
card.innerHTML = `
|
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="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>
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
||||||
<div class="video-menu" role="menu">
|
<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="info" role="menuitem">Show info</button>
|
||||||
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||||
</div>
|
</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" aria-hidden="true">
|
||||||
<div class="video-loading-spinner"></div>
|
<div class="video-loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,31 +138,55 @@ App.feed = App.feed || {};
|
|||||||
if (!resolved.url) return;
|
if (!resolved.url) return;
|
||||||
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
||||||
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
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 isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
|
||||||
const canUseHls = !!(window.Hls && window.Hls.isSupported());
|
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
|
||||||
|
|
||||||
video.muted = state.feedMuted;
|
video.muted = state.feedMuted;
|
||||||
video.preload = 'auto';
|
video.preload = 'auto';
|
||||||
|
|
||||||
if (isHls && canUseHls) {
|
const startPlay = () => {
|
||||||
const hls = new window.Hls();
|
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;
|
video._hlsPlayer = hls;
|
||||||
hls.loadSource(streamUrl);
|
hls.loadSource(streamUrl);
|
||||||
hls.attachMedia(video);
|
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) {
|
if (data && data.fatal && video._hlsPlayer === hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
video._hlsPlayer = null;
|
video._hlsPlayer = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
startPlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNative = () => {
|
||||||
video.src = streamUrl;
|
video.src = streamUrl;
|
||||||
|
startPlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isHls) {
|
||||||
|
startNative();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoplay) {
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
const playPromise = video.play();
|
attachHls(window.Hls);
|
||||||
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
} 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;
|
if (!scroller) return null;
|
||||||
|
|
||||||
const slide = document.createElement('div');
|
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.videoId = v.id;
|
||||||
slide.dataset.index = String(index);
|
slide.dataset.index = String(index);
|
||||||
slide._videoData = v;
|
slide._videoData = v;
|
||||||
slide._index = index;
|
slide._index = index;
|
||||||
const uploaderText = v.uploader || '';
|
const uploaderText = v.uploader || '';
|
||||||
|
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
||||||
slide.innerHTML = `
|
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>
|
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
|
||||||
|
${liveBadge}
|
||||||
<div class="feed-info">
|
<div class="feed-info">
|
||||||
<h4 class="feed-title">${v.title || ''}</h4>
|
<h4 class="feed-title">${v.title || ''}</h4>
|
||||||
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
|
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
|
||||||
|
|||||||
@@ -77,9 +77,16 @@ App.player = App.player || {};
|
|||||||
}
|
}
|
||||||
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
||||||
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
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 isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
||||||
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/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.
|
// Cleanup existing player instance to prevent aborted bindings.
|
||||||
if (state.hlsPlayer) {
|
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 canUseHls = !!(window.Hls && window.Hls.isSupported());
|
||||||
const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl'));
|
const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl'));
|
||||||
let hlsTried = false;
|
let hlsTried = false;
|
||||||
|
|||||||
@@ -27,3 +27,23 @@ App.constants = {
|
|||||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||||
PREFERRED_QUALITY_KEY: 'preferredQuality'
|
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 searchInput = document.getElementById('search-input');
|
||||||
const clearSearchBtn = document.getElementById('search-clear-btn');
|
const clearSearchBtn = document.getElementById('search-clear-btn');
|
||||||
if (searchInput && clearSearchBtn) {
|
if (searchInput && clearSearchBtn) {
|
||||||
|
let searchDebounce = null;
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
const updateClearVisibility = () => {
|
const updateClearVisibility = () => {
|
||||||
const hasValue = searchInput.value.trim().length > 0;
|
const hasValue = searchInput.value.trim().length > 0;
|
||||||
clearSearchBtn.classList.toggle('is-visible', hasValue);
|
clearSearchBtn.classList.toggle('is-visible', hasValue);
|
||||||
@@ -565,13 +568,24 @@ App.ui = App.ui || {};
|
|||||||
clearSearchBtn.addEventListener('click', (event) => {
|
clearSearchBtn.addEventListener('click', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!searchInput.value) return;
|
if (!searchInput.value) return;
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
updateClearVisibility();
|
updateClearVisibility();
|
||||||
App.videos.handleSearch('');
|
App.videos.handleSearch('');
|
||||||
searchInput.focus();
|
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', updateClearVisibility);
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
|
searchDebounce = setTimeout(() => {
|
||||||
|
searchDebounce = null;
|
||||||
|
App.videos.handleSearch(searchInput.value);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
});
|
||||||
updateClearVisibility();
|
updateClearVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ App.videos = App.videos || {};
|
|||||||
App.videos.buildImageProxyUrl = function(imageUrl) {
|
App.videos.buildImageProxyUrl = function(imageUrl) {
|
||||||
if (!imageUrl) return '';
|
if (!imageUrl) return '';
|
||||||
try {
|
try {
|
||||||
return `/api/image?url=${encodeURIComponent(imageUrl)}&ts=${Date.now()}`;
|
return `/api/image?url=${encodeURIComponent(imageUrl)}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -290,14 +290,16 @@ App.videos = App.videos || {};
|
|||||||
const tagsMarkup = tags.length
|
const tagsMarkup = tags.length
|
||||||
? `<div class="video-tags">${tags.map(tag => `<button class="video-tag" type="button" data-tag="${tag}">${tag}</button>`).join('')}</div>`
|
? `<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 = `
|
card.innerHTML = `
|
||||||
|
${liveBadge}
|
||||||
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
|
<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>
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
||||||
<div class="video-menu" role="menu">
|
<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="info" role="menuitem">Show info</button>
|
||||||
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||||
</div>
|
</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" aria-hidden="true">
|
||||||
<div class="video-loading-spinner"></div>
|
<div class="video-loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,11 +434,16 @@ App.videos = App.videos || {};
|
|||||||
App.videos.handleSearch = function(value) {
|
App.videos.handleSearch = function(value) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
if (searchInput) {
|
if (searchInput && searchInput.value !== value) {
|
||||||
if (searchInput.value !== value) {
|
searchInput.value = value;
|
||||||
searchInput.value = value;
|
}
|
||||||
}
|
// Keep the clear button in sync without re-dispatching an `input`
|
||||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
// 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;
|
state.currentPage = 1;
|
||||||
@@ -600,6 +607,8 @@ App.videos = App.videos || {};
|
|||||||
let sourceUrl = '';
|
let sourceUrl = '';
|
||||||
let referer = '';
|
let referer = '';
|
||||||
let userAgent = '';
|
let userAgent = '';
|
||||||
|
const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' &&
|
||||||
|
(videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive)));
|
||||||
if (typeof videoOrUrl === 'string') {
|
if (typeof videoOrUrl === 'string') {
|
||||||
sourceUrl = videoOrUrl;
|
sourceUrl = videoOrUrl;
|
||||||
} else if (videoOrUrl && typeof videoOrUrl === 'object') {
|
} else if (videoOrUrl && typeof videoOrUrl === 'object') {
|
||||||
@@ -634,7 +643,7 @@ App.videos = App.videos || {};
|
|||||||
referer = '';
|
referer = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { url: sourceUrl, referer, userAgent };
|
return { url: sourceUrl, referer, userAgent, isLive };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
||||||
@@ -644,7 +653,8 @@ App.videos = App.videos || {};
|
|||||||
if (!resolved.url) return '';
|
if (!resolved.url) return '';
|
||||||
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
||||||
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
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) {
|
App.videos.downloadVideo = function(video) {
|
||||||
|
|||||||
Reference in New Issue
Block a user