Compare commits
45 Commits
fc68035a79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73e413352 | ||
|
|
7ba8896405 | ||
|
|
ece4852d4f | ||
|
|
a06a952a28 | ||
|
|
24a2c9f738 | ||
|
|
1f5910a996 | ||
|
|
081493d13f | ||
|
|
81597c3bb2 | ||
|
|
3d81b6aae7 | ||
|
|
d2e1e3adea | ||
|
|
c2872c1883 | ||
|
|
5baca567cb | ||
|
|
7b90c05a29 | ||
|
|
16e42cf318 | ||
|
|
e6d36711b1 | ||
|
|
257e19e9db | ||
|
|
ee1cb511df | ||
|
|
f06a7cd3d0 | ||
|
|
437d42ea3d | ||
|
|
bd07bdef3c | ||
|
|
c2289bf3ec | ||
|
|
1651e5a375 | ||
|
|
df8aaa5f9f | ||
|
|
a9949e452f | ||
|
|
6915da7f85 | ||
|
|
407e3bf9c6 | ||
|
|
313ba70fec | ||
|
|
10ebcc87c0 | ||
|
|
1becdce9ff | ||
|
|
1dc6048d9c | ||
|
|
88997a7527 | ||
|
|
8ebbaeab1c | ||
|
|
395b7e2c6d | ||
|
|
f62cae1508 | ||
|
|
7f5ada3a82 | ||
|
|
da53e6cc88 | ||
|
|
5a2021580d | ||
|
|
f71d8e3ee1 | ||
|
|
c67a5cde16 | ||
|
|
b9f49530e4 | ||
|
|
18cb317730 | ||
|
|
8273bc335d | ||
|
|
af2572090d | ||
|
|
c9a7dc4e82 | ||
|
|
62c7bfd694 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
*/__pycache__
|
||||
*/__pycache__/*
|
||||
*/__pycache__/*
|
||||
.tmp
|
||||
frontend/dist/*
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"compile-hero.disable-compile-files-on-did-save-code": false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.9-slim
|
||||
FROM python:3.13
|
||||
|
||||
# Install yt-dlp and dependencies
|
||||
RUN apt-get update && apt-get install -y ffmpeg curl && \
|
||||
@@ -6,7 +6,8 @@ RUN apt-get update && apt-get install -y ffmpeg curl && \
|
||||
chmod a+rx /usr/local/bin/yt-dlp
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install -r backend/requirements.txt
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
RUN rm requirements.txt
|
||||
|
||||
CMD ["python", "backend/main.py"]
|
||||
512
backend/main.py
512
backend/main.py
@@ -1,4 +1,5 @@
|
||||
from flask import Flask, request, Response, send_from_directory, jsonify
|
||||
import os
|
||||
import requests
|
||||
from flask_cors import CORS
|
||||
import urllib.parse
|
||||
@@ -6,6 +7,7 @@ from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
import yt_dlp
|
||||
import io
|
||||
from urllib.parse import urljoin
|
||||
|
||||
# Serve frontend static files under `/static` to avoid colliding with API routes
|
||||
app = Flask(__name__, static_folder='../frontend', static_url_path='/static')
|
||||
@@ -101,14 +103,71 @@ def videos_proxy():
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/image', methods=['GET', 'HEAD'])
|
||||
def image_proxy():
|
||||
image_url = request.args.get('url')
|
||||
if not image_url:
|
||||
return jsonify({"error": "No URL provided"}), 400
|
||||
|
||||
parsed = urllib.parse.urlparse(image_url)
|
||||
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
||||
return jsonify({"error": "Invalid target URL"}), 400
|
||||
|
||||
try:
|
||||
safe_request_headers = {}
|
||||
for k in ('User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language'):
|
||||
if k in request.headers:
|
||||
safe_request_headers[k] = request.headers[k]
|
||||
|
||||
resp = session.get(image_url, headers=safe_request_headers, stream=True, timeout=15, allow_redirects=True)
|
||||
|
||||
hop_by_hop = {
|
||||
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||
'te', 'trailers', 'transfer-encoding', 'upgrade'
|
||||
}
|
||||
|
||||
forwarded_headers = []
|
||||
for name, value in resp.headers.items():
|
||||
if name.lower() in hop_by_hop:
|
||||
continue
|
||||
forwarded_headers.append((name, value))
|
||||
|
||||
if request.method == 'HEAD':
|
||||
resp.close()
|
||||
return Response("", status=resp.status_code, headers=forwarded_headers)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
for chunk in resp.iter_content(1024 * 16):
|
||||
if chunk:
|
||||
yield chunk
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
@app.route('/api/stream', methods=['POST', 'GET'])
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(app.static_folder, 'favicon.ico')
|
||||
|
||||
@app.route('/api/stream', methods=['POST', 'GET', 'HEAD'])
|
||||
def stream_video():
|
||||
# Note: <video> tags perform GET. To support your POST requirement,
|
||||
# we handle the URL via JSON post or URL params.
|
||||
debug_param = os.getenv('STREAM_DEBUG', '').strip().lower()
|
||||
debug_enabled = debug_param in ('1', 'true', 'yes', 'on')
|
||||
cookie_param = os.getenv('STREAM_FORWARD_COOKIES', '').strip().lower()
|
||||
forward_cookies = cookie_param in ('1', 'true', 'yes', 'on')
|
||||
def dbg(message):
|
||||
if debug_enabled:
|
||||
app.logger.info("[stream_video] %s", message)
|
||||
|
||||
video_url = ""
|
||||
if request.method == 'POST':
|
||||
video_url = request.json.get('url')
|
||||
@@ -118,62 +177,405 @@ def stream_video():
|
||||
if not video_url:
|
||||
return jsonify({"error": "No URL provided"}), 400
|
||||
|
||||
def generate():
|
||||
try:
|
||||
# Configure yt-dlp options
|
||||
ydl_opts = {
|
||||
'format': 'best[ext=mp4]/best[vcodec^=avc1]/best[vcodec^=vp]/best',
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'socket_timeout': 30,
|
||||
'retries': 3,
|
||||
'fragment_retries': 3,
|
||||
'http_headers': {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
},
|
||||
'skip_unavailable_fragments': True
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
# Extract the info
|
||||
info = ydl.extract_info(video_url, download=False)
|
||||
|
||||
# Try to get the URL from the info dict (works for progressive downloads)
|
||||
stream_url = info.get('url')
|
||||
format_id = info.get('format_id')
|
||||
|
||||
# If no direct URL, try to get it from formats
|
||||
if not stream_url and 'formats' in info:
|
||||
# Find the best format that has a URL
|
||||
for fmt in info['formats']:
|
||||
if fmt.get('url'):
|
||||
stream_url = fmt.get('url')
|
||||
break
|
||||
|
||||
if not stream_url:
|
||||
yield b"Error: Could not extract stream URL"
|
||||
return
|
||||
|
||||
# Prepare headers for the stream request
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
|
||||
# Add any cookies or authentication headers from yt-dlp
|
||||
if 'http_headers' in info:
|
||||
headers.update(info['http_headers'])
|
||||
|
||||
# Stream the video from the extracted URL
|
||||
resp = session.get(stream_url, headers=headers, stream=True, timeout=30, allow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
for chunk in resp.iter_content(chunk_size=1024 * 16):
|
||||
dbg(f"method={request.method} url={video_url}")
|
||||
|
||||
def is_hls(url):
|
||||
return '.m3u8' in urllib.parse.urlparse(url).path
|
||||
|
||||
def is_dash(url):
|
||||
return urllib.parse.urlparse(url).path.lower().endswith('.mpd')
|
||||
|
||||
def guess_content_type(url):
|
||||
path = urllib.parse.urlparse(url).path.lower()
|
||||
if path.endswith('.m3u8'):
|
||||
return 'application/vnd.apple.mpegurl'
|
||||
if path.endswith('.mpd'):
|
||||
return 'application/dash+xml'
|
||||
if path.endswith('.mp4') or path.endswith('.m4v') or path.endswith('.m4s'):
|
||||
return 'video/mp4'
|
||||
if path.endswith('.webm'):
|
||||
return 'video/webm'
|
||||
if path.endswith('.ts'):
|
||||
return 'video/mp2t'
|
||||
if path.endswith('.mov'):
|
||||
return 'video/quicktime'
|
||||
if path.endswith('.m4a'):
|
||||
return 'audio/mp4'
|
||||
if path.endswith('.mp3'):
|
||||
return 'audio/mpeg'
|
||||
if path.endswith('.ogg') or path.endswith('.oga'):
|
||||
return 'audio/ogg'
|
||||
return None
|
||||
|
||||
def is_direct_media(url):
|
||||
path = urllib.parse.urlparse(url).path.lower()
|
||||
return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov'))
|
||||
|
||||
def looks_like_m3u8_bytes(chunk):
|
||||
if not chunk:
|
||||
return False
|
||||
sample = chunk.lstrip(b'\xef\xbb\xbf')
|
||||
return b'#EXTM3U' in sample[:1024]
|
||||
|
||||
def looks_like_mp4_bytes(chunk):
|
||||
if not chunk or len(chunk) < 8:
|
||||
return False
|
||||
return chunk[4:8] == b'ftyp'
|
||||
|
||||
def build_upstream_headers(referer):
|
||||
headers = {
|
||||
'User-Agent': request.headers.get('User-Agent'),
|
||||
'Accept': request.headers.get('Accept'),
|
||||
'Accept-Language': request.headers.get('Accept-Language'),
|
||||
'Accept-Encoding': request.headers.get('Accept-Encoding'),
|
||||
'Referer': referer,
|
||||
'Origin': referer,
|
||||
}
|
||||
|
||||
# Pass through fetch metadata and client hints when present
|
||||
for key in (
|
||||
'Sec-Fetch-Mode', 'Sec-Fetch-Site', 'Sec-Fetch-Dest', 'Sec-Fetch-User',
|
||||
'Sec-CH-UA', 'Sec-CH-UA-Mobile', 'Sec-CH-UA-Platform', 'DNT'
|
||||
):
|
||||
if key in request.headers:
|
||||
headers[key] = request.headers[key]
|
||||
|
||||
if forward_cookies and 'Cookie' in request.headers:
|
||||
headers['Cookie'] = request.headers['Cookie']
|
||||
dbg("forwarding cookies")
|
||||
|
||||
# Remove keys with None values
|
||||
return {k: v for k, v in headers.items() if v}
|
||||
|
||||
def build_forwarded_headers(resp, target_url=None, content_type_override=None):
|
||||
hop_by_hop = {
|
||||
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||
'te', 'trailers', 'transfer-encoding', 'upgrade'
|
||||
}
|
||||
|
||||
forwarded_headers = []
|
||||
response_content_type = None
|
||||
for name, value in resp.headers.items():
|
||||
if name.lower() in hop_by_hop:
|
||||
continue
|
||||
if name.lower() == 'content-length':
|
||||
forwarded_headers.append((name, value))
|
||||
continue
|
||||
if name.lower() == 'content-type':
|
||||
response_content_type = value
|
||||
if name.lower() == 'content-type' and content_type_override:
|
||||
continue
|
||||
forwarded_headers.append((name, value))
|
||||
|
||||
if not content_type_override:
|
||||
if not response_content_type or 'application/octet-stream' in response_content_type:
|
||||
content_type_override = guess_content_type(target_url or resp.url)
|
||||
|
||||
if content_type_override:
|
||||
forwarded_headers.append(('Content-Type', content_type_override))
|
||||
dbg(f"content_type_override={content_type_override}")
|
||||
|
||||
return forwarded_headers
|
||||
|
||||
def proxy_response(target_url, content_type_override=None, referer_override=None, upstream_headers=None):
|
||||
# Extract the base domain to spoof the referer
|
||||
request_referer = request.args.get('referer')
|
||||
if referer_override:
|
||||
referer = referer_override
|
||||
elif request_referer:
|
||||
referer = request_referer
|
||||
else:
|
||||
parsed_uri = urllib.parse.urlparse(target_url)
|
||||
referer = f"{parsed_uri.scheme}://{parsed_uri.netloc}/"
|
||||
dbg(f"proxy_response target={target_url} referer={referer}")
|
||||
|
||||
safe_request_headers = build_upstream_headers(referer)
|
||||
if isinstance(upstream_headers, dict):
|
||||
for key, value in upstream_headers.items():
|
||||
if value:
|
||||
safe_request_headers[key] = value
|
||||
|
||||
# Pass through Range headers so the browser can 'sniff' the video
|
||||
if 'Range' in request.headers:
|
||||
safe_request_headers['Range'] = request.headers['Range']
|
||||
|
||||
resp = session.get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
|
||||
if debug_enabled:
|
||||
dbg(f"upstream status={resp.status_code} content_type={resp.headers.get('Content-Type')} content_length={resp.headers.get('Content-Length')}")
|
||||
|
||||
content_iter = None
|
||||
first_chunk = b""
|
||||
if request.method != 'HEAD':
|
||||
content_iter = resp.iter_content(chunk_size=1024 * 16)
|
||||
try:
|
||||
first_chunk = next(content_iter)
|
||||
except StopIteration:
|
||||
first_chunk = b""
|
||||
|
||||
if looks_like_m3u8_bytes(first_chunk):
|
||||
remaining = b"".join(chunk for chunk in content_iter if chunk)
|
||||
body_bytes = first_chunk + remaining
|
||||
base_url = resp.url
|
||||
encoding = resp.encoding
|
||||
resp.close()
|
||||
dbg("detected m3u8 by content sniff")
|
||||
upstream_for_playlist = dict(safe_request_headers)
|
||||
upstream_for_playlist.pop('Range', None)
|
||||
return proxy_hls_playlist(
|
||||
target_url,
|
||||
referer_hint=referer,
|
||||
upstream_headers=upstream_for_playlist,
|
||||
prefetched_body=body_bytes,
|
||||
prefetched_base_url=base_url,
|
||||
prefetched_encoding=encoding,
|
||||
)
|
||||
|
||||
forwarded_headers = build_forwarded_headers(
|
||||
resp,
|
||||
target_url=target_url,
|
||||
content_type_override=content_type_override,
|
||||
)
|
||||
|
||||
if request.method == 'HEAD':
|
||||
resp.close()
|
||||
return Response("", status=resp.status_code, headers=forwarded_headers)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
if first_chunk:
|
||||
yield first_chunk
|
||||
for chunk in content_iter or resp.iter_content(chunk_size=1024 * 16):
|
||||
if chunk:
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
yield f"Error: {str(e)}".encode()
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
return Response(generate(), mimetype='video/mp4')
|
||||
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
|
||||
|
||||
def decode_playlist_body(body_bytes, encoding=None):
|
||||
if not body_bytes:
|
||||
return ""
|
||||
enc = encoding or "utf-8"
|
||||
try:
|
||||
return body_bytes.decode(enc, errors="replace")
|
||||
except LookupError:
|
||||
return body_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
def rewrite_hls_playlist(body_text, base_url, referer):
|
||||
def proxied_url(target):
|
||||
absolute = urljoin(base_url, target)
|
||||
return f"/api/stream?url={urllib.parse.quote(absolute, safe='')}&referer={urllib.parse.quote(referer, safe='')}"
|
||||
|
||||
lines = body_text.splitlines()
|
||||
rewritten = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith('#'):
|
||||
# Rewrite URI attributes inside tags (keys/maps)
|
||||
if 'URI="' in line:
|
||||
def repl(match):
|
||||
uri = match.group(1)
|
||||
return f'URI="{proxied_url(uri)}"'
|
||||
import re
|
||||
line = re.sub(r'URI="([^"]+)"', repl, line)
|
||||
rewritten.append(line)
|
||||
continue
|
||||
rewritten.append(proxied_url(stripped))
|
||||
|
||||
body = "\n".join(rewritten)
|
||||
return Response(body, status=200, content_type='application/vnd.apple.mpegurl')
|
||||
|
||||
def proxy_hls_playlist(playlist_url, referer_hint=None, prefetched_body=None, prefetched_base_url=None, prefetched_encoding=None, upstream_headers=None):
|
||||
dbg(f"proxy_hls_playlist url={playlist_url} referer_hint={referer_hint}")
|
||||
base_url = prefetched_base_url or playlist_url
|
||||
body_text = None
|
||||
if prefetched_body is None:
|
||||
headers = build_upstream_headers(referer_hint or "")
|
||||
if isinstance(upstream_headers, dict):
|
||||
for key, value in upstream_headers.items():
|
||||
if value:
|
||||
headers[key] = value
|
||||
if 'User-Agent' not in headers:
|
||||
headers['User-Agent'] = 'Mozilla/5.0'
|
||||
if 'Accept' not in headers:
|
||||
headers['Accept'] = '*/*'
|
||||
resp = session.get(playlist_url, headers=headers, stream=True, timeout=30)
|
||||
base_url = resp.url
|
||||
|
||||
if resp.status_code >= 400:
|
||||
forwarded_headers = build_forwarded_headers(resp, target_url=base_url)
|
||||
if request.method == 'HEAD':
|
||||
resp.close()
|
||||
return Response("", status=resp.status_code, headers=forwarded_headers)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
for chunk in resp.iter_content(chunk_size=1024 * 16):
|
||||
if chunk:
|
||||
yield chunk
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
|
||||
|
||||
if request.method == 'HEAD':
|
||||
forwarded_headers = build_forwarded_headers(resp, target_url=base_url)
|
||||
resp.close()
|
||||
return Response("", status=resp.status_code, headers=forwarded_headers)
|
||||
|
||||
content_iter = resp.iter_content(chunk_size=1024 * 16)
|
||||
try:
|
||||
first_chunk = next(content_iter)
|
||||
except StopIteration:
|
||||
first_chunk = b""
|
||||
|
||||
if looks_like_m3u8_bytes(first_chunk):
|
||||
remaining = b"".join(chunk for chunk in content_iter if chunk)
|
||||
body_bytes = first_chunk + remaining
|
||||
body_text = decode_playlist_body(body_bytes, resp.encoding)
|
||||
resp.close()
|
||||
else:
|
||||
content_type_override = None
|
||||
if looks_like_mp4_bytes(first_chunk):
|
||||
content_type_override = 'video/mp4'
|
||||
forwarded_headers = build_forwarded_headers(
|
||||
resp,
|
||||
target_url=base_url,
|
||||
content_type_override=content_type_override,
|
||||
)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
if first_chunk:
|
||||
yield first_chunk
|
||||
for chunk in content_iter:
|
||||
if chunk:
|
||||
yield chunk
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
|
||||
else:
|
||||
body_text = decode_playlist_body(prefetched_body, prefetched_encoding)
|
||||
|
||||
if referer_hint:
|
||||
referer = referer_hint
|
||||
else:
|
||||
referer = f"{urllib.parse.urlparse(base_url).scheme}://{urllib.parse.urlparse(base_url).netloc}/"
|
||||
|
||||
if request.method == 'HEAD':
|
||||
return Response("", status=200, content_type='application/vnd.apple.mpegurl')
|
||||
return rewrite_hls_playlist(body_text, base_url, referer)
|
||||
|
||||
if is_hls(video_url):
|
||||
try:
|
||||
dbg("detected input as hls")
|
||||
referer_hint = request.args.get('referer')
|
||||
if not referer_hint:
|
||||
parsed = urllib.parse.urlparse(video_url)
|
||||
referer_hint = f"{parsed.scheme}://{parsed.netloc}/"
|
||||
return proxy_hls_playlist(video_url, referer_hint)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if is_direct_media(video_url):
|
||||
try:
|
||||
dbg("detected input as direct media")
|
||||
return proxy_response(video_url)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
def extract_referer(headers):
|
||||
if not isinstance(headers, dict):
|
||||
return None
|
||||
return headers.get('Referer') or headers.get('referer')
|
||||
|
||||
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',
|
||||
'format_sort': ['res', 'fps', 'vcodec:avc1', 'acodec:aac'],
|
||||
'quiet': False,
|
||||
'no_warnings': False,
|
||||
'http_headers': {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
},
|
||||
}
|
||||
|
||||
referer_url = ""
|
||||
if request.method == 'POST':
|
||||
referer_url = request.json.get('referer')
|
||||
else:
|
||||
referer_url = request.args.get('referer')
|
||||
if len(referer_url) > 0:
|
||||
ydl_opts['http_headers']["Referer"] = referer_url
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
# Extract the info
|
||||
info = ydl.extract_info(video_url, download=False)
|
||||
dbg(f"yt_dlp extractor={info.get('extractor')} protocol={info.get('protocol')}")
|
||||
|
||||
# Try to get the URL from the info dict (works for progressive downloads)
|
||||
stream_url = info.get('url')
|
||||
protocol = info.get('protocol')
|
||||
selected_format = None
|
||||
|
||||
# If no direct URL, try to get it from formats
|
||||
if 'formats' in info:
|
||||
if info.get('format_id'):
|
||||
for fmt in info['formats']:
|
||||
if fmt.get('format_id') == info.get('format_id'):
|
||||
selected_format = fmt
|
||||
break
|
||||
if not selected_format and stream_url:
|
||||
for fmt in info['formats']:
|
||||
if fmt.get('url') == stream_url:
|
||||
selected_format = fmt
|
||||
break
|
||||
if not selected_format:
|
||||
for fmt in info['formats']:
|
||||
if fmt.get('url'):
|
||||
selected_format = fmt
|
||||
break
|
||||
|
||||
if not stream_url and selected_format:
|
||||
stream_url = selected_format.get('url')
|
||||
|
||||
if not stream_url:
|
||||
return jsonify({"error": "Could not extract stream URL"}), 500
|
||||
|
||||
upstream_headers = None
|
||||
if selected_format and isinstance(selected_format.get('http_headers'), dict):
|
||||
upstream_headers = selected_format['http_headers']
|
||||
elif isinstance(info.get('http_headers'), dict):
|
||||
upstream_headers = info['http_headers']
|
||||
|
||||
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}/"
|
||||
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)
|
||||
|
||||
if is_hls(stream_url):
|
||||
dbg("stream_url is hls")
|
||||
return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers)
|
||||
|
||||
if is_dash(stream_url):
|
||||
dbg("stream_url is dash")
|
||||
return proxy_response(stream_url, content_type_override='application/dash+xml', referer_override=referer_hint, upstream_headers=upstream_headers)
|
||||
|
||||
dbg("stream_url is direct media")
|
||||
return proxy_response(stream_url, referer_override=referer_hint, upstream_headers=upstream_headers)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000)
|
||||
# threaded=True allows multiple segments to be proxied at once
|
||||
app.run(host='0.0.0.0', port=5000, threaded=True)
|
||||
|
||||
@@ -3,7 +3,7 @@ certifi==2026.1.4
|
||||
charset-normalizer==3.4.4
|
||||
click==8.3.1
|
||||
Flask==3.1.2
|
||||
Flask-Cors==4.0.0
|
||||
Flask-Cors==6.0.2
|
||||
idna==3.11
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
webserver:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
hottub-webclient:
|
||||
image: hottub-webclient:latest
|
||||
container_name: hottub-webclient
|
||||
entrypoint: python3
|
||||
command: ["backend/main.py"]
|
||||
volumes:
|
||||
- ./frontend:/frontend
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- /path/to/hottub-webclient:/app
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/ | grep -q 200"]
|
||||
interval: 300s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 1s
|
||||
151
frontend/app.js
151
frontend/app.js
@@ -1,151 +0,0 @@
|
||||
// 1. Global State and Constants (Declare these first!)
|
||||
let currentPage = 1;
|
||||
const perPage = 12;
|
||||
const renderedVideoIds = new Set();
|
||||
|
||||
// 2. Observer Definition (Must be defined before initApp uses it)
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) loadVideos();
|
||||
}, { threshold: 1.0 });
|
||||
|
||||
// 3. Logic Functions
|
||||
async function InitializeLocalStorage() {
|
||||
if (!localStorage.getItem('config')) {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] }));
|
||||
}
|
||||
// We always run this to make sure session is fresh
|
||||
await InitializeServerStatus();
|
||||
}
|
||||
|
||||
async function InitializeServerStatus() {
|
||||
const config = JSON.parse(localStorage.getItem('config'));
|
||||
if (!config || !config.servers) return;
|
||||
|
||||
const statusPromises = config.servers.map(async (serverObj) => {
|
||||
const server = Object.keys(serverObj)[0];
|
||||
try {
|
||||
const response = await fetch(`/api/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ server: server }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const status = await response.json();
|
||||
serverObj[server] = status;
|
||||
} catch (err) {
|
||||
serverObj[server] = { online: false, channels: [] };
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statusPromises);
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
|
||||
const firstServerKey = Object.keys(config.servers[0])[0];
|
||||
const serverData = config.servers[0][firstServerKey];
|
||||
|
||||
if (serverData.channels && serverData.channels.length > 0) {
|
||||
const channel = serverData.channels[0];
|
||||
let options = {};
|
||||
|
||||
if (channel.options) {
|
||||
channel.options.forEach(element => {
|
||||
// Ensure the options structure matches your API expectations
|
||||
options[element.id] = element.options[0];
|
||||
});
|
||||
}
|
||||
|
||||
const sessionData = {
|
||||
server: firstServerKey,
|
||||
channel: channel,
|
||||
options: options,
|
||||
};
|
||||
|
||||
localStorage.setItem('session', JSON.stringify(sessionData));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVideos() {
|
||||
const session = JSON.parse(localStorage.getItem('session'));
|
||||
if (!session) return;
|
||||
|
||||
// Build the request body
|
||||
let body = {
|
||||
channel: session.channel.id,
|
||||
query: "",
|
||||
page: currentPage,
|
||||
perPage: perPage,
|
||||
server: session.server
|
||||
};
|
||||
|
||||
// Correct way to loop through the options object
|
||||
Object.entries(session.options).forEach(([key, value]) => {
|
||||
body[key] = value.id;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/videos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const videos = await response.json();
|
||||
renderVideos(videos);
|
||||
currentPage++;
|
||||
} catch (err) {
|
||||
console.error("Failed to load videos:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVideos(videos) {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid) return;
|
||||
|
||||
videos.items.forEach(v => {
|
||||
if (renderedVideoIds.has(v.id)) return;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'video-card';
|
||||
const durationText = v.duration === 0 ? '' : ` • ${v.duration}s`;
|
||||
card.innerHTML = `
|
||||
<img src="${v.thumb}" alt="${v.title}">
|
||||
<h4>${v.title}</h4>
|
||||
<p>${v.channel}${durationText}</p>
|
||||
`;
|
||||
card.onclick = () => openPlayer(v.url);
|
||||
grid.appendChild(card);
|
||||
renderedVideoIds.add(v.id);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Initialization (Run this last)
|
||||
async function initApp() {
|
||||
// Clear old data if you want a fresh start every refresh
|
||||
// localStorage.clear();
|
||||
|
||||
await InitializeLocalStorage();
|
||||
|
||||
const sentinel = document.getElementById('sentinel');
|
||||
if (sentinel) {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
await loadVideos();
|
||||
}
|
||||
|
||||
function openPlayer(url) {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
video.src = `/api/stream?url=${encodeURIComponent(url)}`;
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
video.pause();
|
||||
video.src = '';
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
initApp();
|
||||
1361
frontend/css/style.css
Normal file
1361
frontend/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 523 B |
@@ -3,32 +3,41 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hottub</title>
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<title>Jacuzzi</title>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lobster&family=Space+Grotesk:wght@500;600&family=Sora:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-bar">
|
||||
<div class="logo">Hottub</div>
|
||||
<div class="logo">Jacuzzi</div>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search videos..." oninput="handleSearch(this.value)">
|
||||
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
|
||||
</button>
|
||||
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
|
||||
<span class="hamburger">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
|
||||
</button>
|
||||
<button class="icon-btn settings-toggle" onclick="toggleDrawer('settings')" title="Settings">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 1v6m0 6v6M4.22 4.22l4.24 4.24m5.08 0l4.24-4.24M1 12h6m6 0h6m-16.78 7.78l4.24-4.24m5.08 0l4.24 4.24"></path>
|
||||
</svg>
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/wrench.svg" alt="Settings">
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
|
||||
<div class="favorites-header">
|
||||
<h3>Favorites</h3>
|
||||
</div>
|
||||
<div id="favorites-list" class="favorites-list"></div>
|
||||
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
|
||||
</section>
|
||||
|
||||
<div class="sidebar-overlay" id="overlay" onclick="closeDrawers()"></div>
|
||||
|
||||
<aside id="drawer-menu" class="sidebar">
|
||||
@@ -36,11 +45,24 @@
|
||||
<h3>Menu</h3>
|
||||
<button class="close-btn" onclick="closeDrawers()">✕</button>
|
||||
</div>
|
||||
<nav class="sidebar-content">
|
||||
<a href="#" class="sidebar-item">Home</a>
|
||||
<a href="#" class="sidebar-item">Trending</a>
|
||||
<a href="#" class="sidebar-item">Subscriptions</a>
|
||||
</nav>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Network</h4>
|
||||
<div class="setting-item">
|
||||
<label for="source-select">Source</label>
|
||||
<select id="source-select"></select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="channel-select">Channel</label>
|
||||
<select id="channel-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Filters</h4>
|
||||
<div id="filters-container" class="filters-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside id="drawer-settings" class="sidebar">
|
||||
@@ -51,25 +73,68 @@
|
||||
<div class="sidebar-content">
|
||||
<div class="setting-item">
|
||||
<label>Theme</label>
|
||||
<select>
|
||||
<option>Dark</option>
|
||||
<option>Light</option>
|
||||
<select id="theme-select">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item setting-toggle">
|
||||
<div class="setting-label-row">
|
||||
<label for="favorites-toggle">Favorites Bar</label>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="favorites-toggle">
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Sources</h4>
|
||||
<div class="setting-item">
|
||||
<label for="source-input">Add Source URL</label>
|
||||
<div class="input-row">
|
||||
<input id="source-input" type="text" placeholder="https://example.com">
|
||||
<button id="add-source-btn" class="btn-secondary" type="button">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sources-list" class="sources-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main id="video-grid" class="grid-container"></main>
|
||||
<div id="sentinel"></div>
|
||||
<button id="load-more-btn" class="load-more-btn" title="Load More">
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/chevron-down.svg" alt="Load More">
|
||||
</button>
|
||||
|
||||
<div id="video-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closePlayer()">×</span>
|
||||
<video id="player" controls autoplay></video>
|
||||
<video id="player" controls autoplay playsinline webkit-playsinline></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="static/app.js"></script>
|
||||
<div id="info-modal" class="info-modal" aria-hidden="true">
|
||||
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
|
||||
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
||||
<h3 id="info-title">Video Info</h3>
|
||||
<div id="info-list" class="info-list"></div>
|
||||
<div id="info-empty" class="info-empty">No additional info available.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-toast" class="error-toast" role="alert" aria-live="assertive">
|
||||
<span id="error-toast-text"></span>
|
||||
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<script src="static/js/state.js"></script>
|
||||
<script src="static/js/storage.js"></script>
|
||||
<script src="static/js/player.js"></script>
|
||||
<script src="static/js/favorites.js"></script>
|
||||
<script src="static/js/videos.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>
|
||||
</html>
|
||||
|
||||
177
frontend/js/favorites.js
Normal file
177
frontend/js/favorites.js
Normal file
@@ -0,0 +1,177 @@
|
||||
window.App = window.App || {};
|
||||
App.favorites = App.favorites || {};
|
||||
|
||||
(function() {
|
||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
||||
|
||||
// Favorites storage helpers.
|
||||
App.favorites.getAll = function() {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
App.favorites.setAll = function(items) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
App.favorites.getKey = function(video) {
|
||||
if (!video) return null;
|
||||
const meta = video.meta || video;
|
||||
return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
|
||||
};
|
||||
|
||||
App.favorites.normalize = function(video) {
|
||||
const key = App.favorites.getKey(video);
|
||||
if (!key) return null;
|
||||
const meta = video && video.meta ? video.meta : video;
|
||||
return {
|
||||
key,
|
||||
id: video.id || null,
|
||||
url: video.url || '',
|
||||
title: video.title || '',
|
||||
thumb: video.thumb || '',
|
||||
channel: video.channel || (meta && meta.channel) || '',
|
||||
uploader: video.uploader || (meta && meta.uploader) || '',
|
||||
duration: video.duration || (meta && meta.duration) || 0,
|
||||
meta: meta
|
||||
};
|
||||
};
|
||||
|
||||
App.favorites.getSet = function() {
|
||||
return new Set(App.favorites.getAll().map((item) => item.key));
|
||||
};
|
||||
|
||||
App.favorites.isVisible = function() {
|
||||
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
||||
};
|
||||
|
||||
App.favorites.setVisible = function(isVisible) {
|
||||
localStorage.setItem(FAVORITES_VISIBILITY_KEY, isVisible ? 'true' : 'false');
|
||||
};
|
||||
|
||||
// UI helpers for rendering and syncing heart states.
|
||||
App.favorites.setButtonState = function(button, isFavorite) {
|
||||
button.classList.toggle('is-favorite', isFavorite);
|
||||
button.textContent = isFavorite ? '♥' : '♡';
|
||||
button.setAttribute('aria-pressed', isFavorite ? 'true' : 'false');
|
||||
button.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites');
|
||||
};
|
||||
|
||||
App.favorites.syncButtons = function() {
|
||||
const favoritesSet = App.favorites.getSet();
|
||||
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
|
||||
const key = button.dataset.favKey;
|
||||
if (!key) return;
|
||||
App.favorites.setButtonState(button, favoritesSet.has(key));
|
||||
});
|
||||
};
|
||||
|
||||
App.favorites.toggle = function(video) {
|
||||
const key = App.favorites.getKey(video);
|
||||
if (!key) return;
|
||||
const favorites = App.favorites.getAll();
|
||||
const existingIndex = favorites.findIndex((item) => item.key === key);
|
||||
if (existingIndex >= 0) {
|
||||
favorites.splice(existingIndex, 1);
|
||||
} else {
|
||||
const entry = App.favorites.normalize(video);
|
||||
if (entry) favorites.unshift(entry);
|
||||
}
|
||||
App.favorites.setAll(favorites);
|
||||
App.favorites.renderBar();
|
||||
App.favorites.syncButtons();
|
||||
};
|
||||
|
||||
App.favorites.renderBar = function() {
|
||||
const bar = document.getElementById('favorites-bar');
|
||||
const list = document.getElementById('favorites-list');
|
||||
const empty = document.getElementById('favorites-empty');
|
||||
if (!bar || !list) return;
|
||||
|
||||
const favorites = App.favorites.getAll();
|
||||
const visible = App.favorites.isVisible();
|
||||
bar.style.display = visible ? 'block' : 'none';
|
||||
|
||||
list.innerHTML = "";
|
||||
favorites.forEach((item) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'favorite-card';
|
||||
card.dataset.favKey = item.key;
|
||||
const uploaderText = item.uploader || '';
|
||||
card.innerHTML = `
|
||||
<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}">
|
||||
<div class="video-loading" aria-hidden="true">
|
||||
<div class="video-loading-spinner"></div>
|
||||
</div>
|
||||
<div class="favorite-info">
|
||||
<h4>${item.title}</h4>
|
||||
${uploaderText ? `<p><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') {
|
||||
App.videos.attachNoReferrerRetry(thumb);
|
||||
}
|
||||
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) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(item);
|
||||
};
|
||||
}
|
||||
const menuBtn = card.querySelector('.video-menu-btn');
|
||||
const menu = card.querySelector('.video-menu');
|
||||
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
|
||||
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
|
||||
if (menuBtn && menu) {
|
||||
menuBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.toggleMenu(menu, menuBtn);
|
||||
};
|
||||
}
|
||||
if (showInfoBtn) {
|
||||
showInfoBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.ui.showInfo(item.meta || item);
|
||||
App.videos.closeAllMenus();
|
||||
};
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.downloadVideo(item.meta || item);
|
||||
App.videos.closeAllMenus();
|
||||
};
|
||||
}
|
||||
const uploaderBtn = card.querySelector('.uploader-link');
|
||||
if (uploaderBtn) {
|
||||
uploaderBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
|
||||
App.videos.handleSearch(uploader);
|
||||
};
|
||||
}
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
})();
|
||||
38
frontend/js/main.js
Normal file
38
frontend/js/main.js
Normal file
@@ -0,0 +1,38 @@
|
||||
window.App = window.App || {};
|
||||
|
||||
(function() {
|
||||
// App bootstrap: initialize storage, render UI, and load the first page.
|
||||
async function initApp() {
|
||||
await App.storage.ensureDefaults();
|
||||
App.ui.applyTheme();
|
||||
App.ui.renderMenu();
|
||||
App.favorites.renderBar();
|
||||
App.ui.bindGlobalHandlers();
|
||||
|
||||
App.videos.observeSentinel();
|
||||
|
||||
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.onclick = () => {
|
||||
App.videos.loadVideos();
|
||||
};
|
||||
}
|
||||
|
||||
const errorToastClose = document.getElementById('error-toast-close');
|
||||
if (errorToastClose) {
|
||||
errorToastClose.onclick = () => {
|
||||
const toast = document.getElementById('error-toast');
|
||||
if (toast) toast.classList.remove('show');
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
App.videos.ensureViewportFilled();
|
||||
});
|
||||
|
||||
await App.videos.loadVideos();
|
||||
App.favorites.syncButtons();
|
||||
}
|
||||
|
||||
initApp();
|
||||
})();
|
||||
288
frontend/js/player.js
Normal file
288
frontend/js/player.js
Normal file
@@ -0,0 +1,288 @@
|
||||
window.App = window.App || {};
|
||||
App.player = App.player || {};
|
||||
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
// Playback heuristics for full-screen behavior on mobile/TV browsers.
|
||||
function isMobilePlayback() {
|
||||
if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
|
||||
return navigator.userAgentData.mobile;
|
||||
}
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/iPhone|iPad|iPod|Android/i.test(ua)) return true;
|
||||
return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
function isTvPlayback() {
|
||||
const ua = navigator.userAgent || '';
|
||||
return /SMART-TV|SmartTV|Smart TV|Internet\.TV|HbbTV|NetCast|Web0S|webOS|Tizen|AppleTV|Apple TV|GoogleTV|Android TV|AFTB|AFTS|AFTM|AFTT|AFTQ|AFTK|AFTN|AFTMM|AFTKR|Roku|DTV|BRAVIA|VIZIO|SHIELD|PhilipsTV|Hisense|VIDAA|TOSHIBA/i.test(ua);
|
||||
}
|
||||
|
||||
function getMobileVideoHost() {
|
||||
let host = document.getElementById('mobile-video-host');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.id = 'mobile-video-host';
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
App.player.open = async function(source, opts) {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
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;
|
||||
|
||||
if (!state.playerHome) {
|
||||
state.playerHome = video.parentElement;
|
||||
}
|
||||
|
||||
// Normalize stream URL + optional referer forwarding.
|
||||
let resolved = { url: '', referer: '' };
|
||||
if (App.videos && typeof App.videos.resolveStreamSource === 'function') {
|
||||
resolved = App.videos.resolveStreamSource(source);
|
||||
} else if (typeof source === 'string') {
|
||||
resolved.url = source;
|
||||
} else if (source && typeof source === 'object') {
|
||||
resolved.url = source.url || '';
|
||||
}
|
||||
if (!resolved.referer && resolved.url) {
|
||||
try {
|
||||
resolved.referer = `${new URL(resolved.url).origin}/`;
|
||||
} catch (err) {
|
||||
resolved.referer = '';
|
||||
}
|
||||
}
|
||||
if (!resolved.url) {
|
||||
if (App.ui && App.ui.showError) {
|
||||
App.ui.showError('Unable to play this stream.');
|
||||
}
|
||||
clearLoading();
|
||||
return;
|
||||
}
|
||||
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
||||
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`;
|
||||
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
||||
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
|
||||
|
||||
// Cleanup existing player instance to prevent aborted bindings.
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.stopLoad();
|
||||
state.hlsPlayer.detachMedia();
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
|
||||
// Reset the video element before re-binding a new source.
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
|
||||
if (!isHls) {
|
||||
try {
|
||||
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
||||
const contentType = headResp.headers.get('Content-Type') || '';
|
||||
if (contentType.includes('application/vnd.apple.mpegurl')) {
|
||||
isHls = true;
|
||||
} else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) {
|
||||
isDirectMedia = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to detect stream type', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (useMobileFullscreen) {
|
||||
const host = getMobileVideoHost();
|
||||
if (video.parentElement !== host) {
|
||||
host.appendChild(video);
|
||||
}
|
||||
state.playerMode = 'mobile';
|
||||
video.removeAttribute('playsinline');
|
||||
video.removeAttribute('webkit-playsinline');
|
||||
video.playsInline = false;
|
||||
} else {
|
||||
if (state.playerHome && video.parentElement !== state.playerHome) {
|
||||
state.playerHome.appendChild(video);
|
||||
}
|
||||
state.playerMode = 'modal';
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('webkit-playsinline', '');
|
||||
video.playsInline = true;
|
||||
}
|
||||
|
||||
const requestFullscreen = () => {
|
||||
if (state.playerMode !== 'mobile') return;
|
||||
if (typeof video.webkitEnterFullscreen === 'function') {
|
||||
try {
|
||||
video.webkitEnterFullscreen();
|
||||
} catch (err) {
|
||||
// Ignore if fullscreen is not allowed.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (video.requestFullscreen) {
|
||||
video.requestFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const startPlayback = () => {
|
||||
if (playbackStarted) return;
|
||||
playbackStarted = true;
|
||||
clearLoading();
|
||||
const playPromise = video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {});
|
||||
}
|
||||
if (state.playerMode === 'mobile') {
|
||||
if (video.readyState >= 1) {
|
||||
requestFullscreen();
|
||||
} else {
|
||||
video.addEventListener('loadedmetadata', requestFullscreen, { once: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canUseHls = !!(window.Hls && window.Hls.isSupported());
|
||||
const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl'));
|
||||
let hlsTried = false;
|
||||
let nativeTried = false;
|
||||
let usingHls = false;
|
||||
|
||||
const startNative = () => {
|
||||
if (nativeTried) return;
|
||||
nativeTried = true;
|
||||
usingHls = false;
|
||||
video.src = streamUrl;
|
||||
startPlayback();
|
||||
};
|
||||
|
||||
const startHls = (allowFallback) => {
|
||||
if (!canUseHls || hlsTried) return false;
|
||||
hlsTried = true;
|
||||
usingHls = true;
|
||||
state.hlsPlayer = new window.Hls();
|
||||
state.hlsPlayer.loadSource(streamUrl);
|
||||
state.hlsPlayer.attachMedia(video);
|
||||
state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, function() {
|
||||
startPlayback();
|
||||
});
|
||||
startPlayback();
|
||||
state.hlsPlayer.on(window.Hls.Events.ERROR, function(event, data) {
|
||||
if (data && data.fatal) {
|
||||
const shouldFallback = allowFallback && !nativeTried && !isHls;
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
if (shouldFallback) {
|
||||
startNative();
|
||||
return;
|
||||
}
|
||||
clearLoading();
|
||||
if (App.ui && App.ui.showError) {
|
||||
App.ui.showError('Unable to play this stream.');
|
||||
}
|
||||
App.player.close();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
if (prefersHls) {
|
||||
if (!startHls(true)) {
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
startNative();
|
||||
} else {
|
||||
console.error("HLS not supported in this browser.");
|
||||
if (App.ui && App.ui.showError) {
|
||||
App.ui.showError('HLS is not supported in this browser.');
|
||||
}
|
||||
clearLoading();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startNative();
|
||||
}
|
||||
|
||||
video.onerror = () => {
|
||||
if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) {
|
||||
if (startHls(true)) return;
|
||||
}
|
||||
clearLoading();
|
||||
if (App.ui && App.ui.showError) {
|
||||
App.ui.showError('Video failed to load.');
|
||||
}
|
||||
App.player.close();
|
||||
};
|
||||
|
||||
if (state.playerMode === 'modal') {
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
if (!state.onFullscreenChange) {
|
||||
state.onFullscreenChange = () => {
|
||||
if (state.playerMode === 'mobile' && !document.fullscreenElement) {
|
||||
App.player.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
document.addEventListener('fullscreenchange', state.onFullscreenChange);
|
||||
if (!state.onWebkitEndFullscreen) {
|
||||
state.onWebkitEndFullscreen = () => {
|
||||
if (state.playerMode === 'mobile') {
|
||||
App.player.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
video.addEventListener('webkitendfullscreen', state.onWebkitEndFullscreen);
|
||||
}
|
||||
};
|
||||
|
||||
App.player.close = function() {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
if (!modal || !video) return;
|
||||
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
if (document.fullscreenElement && document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
if (state.onFullscreenChange) {
|
||||
document.removeEventListener('fullscreenchange', state.onFullscreenChange);
|
||||
}
|
||||
if (state.onWebkitEndFullscreen) {
|
||||
video.removeEventListener('webkitendfullscreen', state.onWebkitEndFullscreen);
|
||||
}
|
||||
video.onerror = null;
|
||||
video.pause();
|
||||
video.src = '';
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
if (state.playerHome && video.parentElement !== state.playerHome) {
|
||||
state.playerHome.appendChild(video);
|
||||
}
|
||||
state.playerMode = 'modal';
|
||||
};
|
||||
})();
|
||||
23
frontend/js/state.js
Normal file
23
frontend/js/state.js
Normal file
@@ -0,0 +1,23 @@
|
||||
window.App = window.App || {};
|
||||
|
||||
// Centralized runtime state for pagination, player, and UI behavior.
|
||||
App.state = {
|
||||
currentPage: 1,
|
||||
perPage: 12,
|
||||
renderedVideoIds: new Set(),
|
||||
hasNextPage: true,
|
||||
isLoading: false,
|
||||
hlsPlayer: null,
|
||||
currentLoadController: null,
|
||||
errorToastTimer: null,
|
||||
playerMode: 'modal',
|
||||
playerHome: null,
|
||||
onFullscreenChange: null,
|
||||
onWebkitEndFullscreen: null
|
||||
};
|
||||
|
||||
// Local storage keys used across modules.
|
||||
App.constants = {
|
||||
FAVORITES_KEY: 'favorites',
|
||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible'
|
||||
};
|
||||
182
frontend/js/storage.js
Normal file
182
frontend/js/storage.js
Normal file
@@ -0,0 +1,182 @@
|
||||
window.App = window.App || {};
|
||||
App.storage = App.storage || {};
|
||||
App.session = App.session || {};
|
||||
|
||||
(function() {
|
||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
||||
|
||||
// Basic localStorage helpers.
|
||||
App.storage.getConfig = function() {
|
||||
return JSON.parse(localStorage.getItem('config')) || { servers: [] };
|
||||
};
|
||||
|
||||
App.storage.setConfig = function(nextConfig) {
|
||||
localStorage.setItem('config', JSON.stringify(nextConfig));
|
||||
};
|
||||
|
||||
App.storage.getSession = function() {
|
||||
return JSON.parse(localStorage.getItem('session')) || null;
|
||||
};
|
||||
|
||||
App.storage.setSession = function(nextSession) {
|
||||
localStorage.setItem('session', JSON.stringify(nextSession));
|
||||
};
|
||||
|
||||
App.storage.getPreferences = function() {
|
||||
return JSON.parse(localStorage.getItem('preferences')) || {};
|
||||
};
|
||||
|
||||
App.storage.setPreferences = function(nextPreferences) {
|
||||
localStorage.setItem('preferences', JSON.stringify(nextPreferences));
|
||||
};
|
||||
|
||||
App.storage.getServerEntries = function() {
|
||||
const config = App.storage.getConfig();
|
||||
if (!config.servers || !Array.isArray(config.servers)) return [];
|
||||
return config.servers.map((serverObj) => {
|
||||
const server = Object.keys(serverObj)[0];
|
||||
return {
|
||||
url: server,
|
||||
data: serverObj[server] || null
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Options/session helpers that power channel selection and filters.
|
||||
App.session.serializeOptions = function(options) {
|
||||
const serialized = {};
|
||||
Object.entries(options || {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
serialized[key] = value.map((entry) => entry.id);
|
||||
} else if (value && value.id) {
|
||||
serialized[key] = value.id;
|
||||
}
|
||||
});
|
||||
return serialized;
|
||||
};
|
||||
|
||||
App.session.hydrateOptions = function(channel, savedOptions) {
|
||||
const hydrated = {};
|
||||
if (!channel || !Array.isArray(channel.options)) return hydrated;
|
||||
const saved = savedOptions || {};
|
||||
channel.options.forEach((optionGroup) => {
|
||||
const allOptions = optionGroup.options || [];
|
||||
const savedValue = saved[optionGroup.id];
|
||||
if (optionGroup.multiSelect) {
|
||||
const selectedIds = Array.isArray(savedValue) ? savedValue : [];
|
||||
const selected = allOptions.filter((opt) => selectedIds.includes(opt.id));
|
||||
hydrated[optionGroup.id] = selected.length > 0 ? selected : allOptions.slice(0, 1);
|
||||
} else {
|
||||
const selected = allOptions.find((opt) => opt.id === savedValue) || allOptions[0];
|
||||
if (selected) hydrated[optionGroup.id] = selected;
|
||||
}
|
||||
});
|
||||
return hydrated;
|
||||
};
|
||||
|
||||
App.session.savePreference = function(session) {
|
||||
if (!session || !session.server || !session.channel) return;
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[session.server] || {};
|
||||
serverPrefs.channelId = session.channel.id;
|
||||
serverPrefs.optionsByChannel = serverPrefs.optionsByChannel || {};
|
||||
serverPrefs.optionsByChannel[session.channel.id] = App.session.serializeOptions(session.options);
|
||||
prefs[session.server] = serverPrefs;
|
||||
App.storage.setPreferences(prefs);
|
||||
};
|
||||
|
||||
App.session.buildDefaultOptions = function(channel) {
|
||||
const selected = {};
|
||||
if (!channel || !Array.isArray(channel.options)) return selected;
|
||||
channel.options.forEach((optionGroup) => {
|
||||
if (!optionGroup.options || optionGroup.options.length === 0) return;
|
||||
if (optionGroup.multiSelect) {
|
||||
selected[optionGroup.id] = [optionGroup.options[0]];
|
||||
} else {
|
||||
selected[optionGroup.id] = optionGroup.options[0];
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
};
|
||||
|
||||
// Ensures defaults exist and refreshes server status.
|
||||
App.storage.ensureDefaults = async function() {
|
||||
if (!localStorage.getItem('config')) {
|
||||
localStorage.setItem('config', JSON.stringify({
|
||||
servers: [
|
||||
{ "https://getfigleaf.com": {} },
|
||||
{ "https://hottubapp.io": {} },
|
||||
{ "https://hottub.spacemoehre.de": {} }
|
||||
]
|
||||
}));
|
||||
}
|
||||
if (!localStorage.getItem('theme')) {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
if (!localStorage.getItem(FAVORITES_KEY)) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify([]));
|
||||
}
|
||||
if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) {
|
||||
localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true');
|
||||
}
|
||||
await App.storage.initializeServerStatus();
|
||||
};
|
||||
|
||||
// Fetches server status and keeps the session pointing to a valid channel/options.
|
||||
App.storage.initializeServerStatus = async function() {
|
||||
const config = JSON.parse(localStorage.getItem('config'));
|
||||
if (!config || !config.servers) return;
|
||||
|
||||
const statusPromises = config.servers.map(async (serverObj) => {
|
||||
const server = Object.keys(serverObj)[0];
|
||||
try {
|
||||
const response = await fetch(`/api/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
server: server
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
});
|
||||
const status = await response.json();
|
||||
serverObj[server] = status;
|
||||
} catch (err) {
|
||||
serverObj[server] = {
|
||||
online: false,
|
||||
channels: []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statusPromises);
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
|
||||
const existingSession = App.storage.getSession();
|
||||
const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]);
|
||||
if (serverKeys.length === 0) return;
|
||||
const selectedServerKey = existingSession && serverKeys.includes(existingSession.server)
|
||||
? existingSession.server
|
||||
: serverKeys[0];
|
||||
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey);
|
||||
const serverData = serverEntry ? serverEntry[selectedServerKey] : null;
|
||||
|
||||
if (serverData && serverData.channels && serverData.channels.length > 0) {
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[selectedServerKey] || {};
|
||||
const preferredChannelId = serverPrefs.channelId;
|
||||
const channel = serverData.channels.find((ch) => ch.id === preferredChannelId) || serverData.channels[0];
|
||||
const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null;
|
||||
const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel);
|
||||
|
||||
const sessionData = {
|
||||
server: selectedServerKey,
|
||||
channel: channel,
|
||||
options: options,
|
||||
};
|
||||
|
||||
App.storage.setSession(sessionData);
|
||||
App.session.savePreference(sessionData);
|
||||
}
|
||||
};
|
||||
})();
|
||||
542
frontend/js/ui.js
Normal file
542
frontend/js/ui.js
Normal file
@@ -0,0 +1,542 @@
|
||||
window.App = window.App || {};
|
||||
App.ui = App.ui || {};
|
||||
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
App.ui.applyTheme = function() {
|
||||
const theme = localStorage.getItem('theme') || 'dark';
|
||||
document.body.classList.toggle('theme-light', theme === 'light');
|
||||
const select = document.getElementById('theme-select');
|
||||
if (select) select.value = theme;
|
||||
};
|
||||
|
||||
// Toast helper for playback + network errors.
|
||||
App.ui.showError = function(message) {
|
||||
const toast = document.getElementById('error-toast');
|
||||
const text = document.getElementById('error-toast-text');
|
||||
if (!toast || !text) return;
|
||||
text.textContent = message;
|
||||
toast.classList.add('show');
|
||||
if (state.errorToastTimer) {
|
||||
clearTimeout(state.errorToastTimer);
|
||||
}
|
||||
state.errorToastTimer = setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
App.ui.showInfo = function(video) {
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
const title = document.getElementById('info-title');
|
||||
const list = document.getElementById('info-list');
|
||||
const empty = document.getElementById('info-empty');
|
||||
|
||||
const data = video && video.meta ? video.meta : video;
|
||||
const titleText = data && data.title ? data.title : 'Video Info';
|
||||
if (title) title.textContent = titleText;
|
||||
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
}
|
||||
|
||||
let hasRows = false;
|
||||
if (data && typeof data === 'object') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (!list) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'info-row';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'info-label';
|
||||
label.textContent = key;
|
||||
|
||||
let valueNode;
|
||||
if (value && typeof value === 'object') {
|
||||
valueNode = document.createElement('pre');
|
||||
valueNode.className = 'info-json';
|
||||
valueNode.textContent = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
valueNode = document.createElement('span');
|
||||
valueNode.className = 'info-value';
|
||||
valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
|
||||
}
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(valueNode);
|
||||
list.appendChild(row);
|
||||
hasRows = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = hasRows ? 'none' : 'block';
|
||||
}
|
||||
|
||||
modal.classList.add('open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
};
|
||||
|
||||
App.ui.closeInfo = function() {
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
modal.classList.remove('open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
// Drawer controls shared by the inline HTML handlers.
|
||||
App.ui.closeDrawers = function() {
|
||||
const menuDrawer = document.getElementById('drawer-menu');
|
||||
const settingsDrawer = document.getElementById('drawer-settings');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const menuBtn = document.querySelector('.menu-toggle');
|
||||
const settingsBtn = document.querySelector('.settings-toggle');
|
||||
|
||||
if (menuDrawer) menuDrawer.classList.remove('open');
|
||||
if (settingsDrawer) settingsDrawer.classList.remove('open');
|
||||
if (overlay) overlay.classList.remove('open');
|
||||
if (menuBtn) menuBtn.classList.remove('active');
|
||||
if (settingsBtn) settingsBtn.classList.remove('active');
|
||||
document.body.classList.remove('drawer-open');
|
||||
};
|
||||
|
||||
App.ui.toggleDrawer = function(type) {
|
||||
const menuDrawer = document.getElementById('drawer-menu');
|
||||
const settingsDrawer = document.getElementById('drawer-settings');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const menuBtn = document.querySelector('.menu-toggle');
|
||||
const settingsBtn = document.querySelector('.settings-toggle');
|
||||
|
||||
const isMenu = type === 'menu';
|
||||
const targetDrawer = isMenu ? menuDrawer : settingsDrawer;
|
||||
const otherDrawer = isMenu ? settingsDrawer : menuDrawer;
|
||||
const targetBtn = isMenu ? menuBtn : settingsBtn;
|
||||
const otherBtn = isMenu ? settingsBtn : menuBtn;
|
||||
|
||||
if (!targetDrawer || !overlay) return;
|
||||
|
||||
const willOpen = !targetDrawer.classList.contains('open');
|
||||
|
||||
if (otherDrawer) otherDrawer.classList.remove('open');
|
||||
if (otherBtn) otherBtn.classList.remove('active');
|
||||
|
||||
if (willOpen) {
|
||||
targetDrawer.classList.add('open');
|
||||
if (targetBtn) targetBtn.classList.add('active');
|
||||
overlay.classList.add('open');
|
||||
document.body.classList.add('drawer-open');
|
||||
} else {
|
||||
App.ui.closeDrawers();
|
||||
}
|
||||
};
|
||||
|
||||
// Settings + menu rendering.
|
||||
App.ui.renderMenu = function() {
|
||||
const session = App.storage.getSession();
|
||||
const serverEntries = App.storage.getServerEntries();
|
||||
const sourceSelect = document.getElementById('source-select');
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
const filtersContainer = document.getElementById('filters-container');
|
||||
const sourcesList = document.getElementById('sources-list');
|
||||
const addSourceBtn = document.getElementById('add-source-btn');
|
||||
const sourceInput = document.getElementById('source-input');
|
||||
const reloadChannelBtn = document.getElementById('reload-channel-btn');
|
||||
const favoritesToggle = document.getElementById('favorites-toggle');
|
||||
|
||||
if (!sourceSelect || !channelSelect || !filtersContainer) return;
|
||||
|
||||
sourceSelect.innerHTML = "";
|
||||
serverEntries.forEach((entry) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = entry.url;
|
||||
option.textContent = entry.url;
|
||||
sourceSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (session && session.server) {
|
||||
sourceSelect.value = session.server;
|
||||
}
|
||||
|
||||
sourceSelect.onchange = () => {
|
||||
const selectedServerUrl = sourceSelect.value;
|
||||
const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl);
|
||||
const channels = selectedServer && selectedServer.data && selectedServer.data.channels ?
|
||||
selectedServer.data.channels :
|
||||
[];
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[selectedServerUrl] || {};
|
||||
const preferredChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || null;
|
||||
const nextChannel = preferredChannel || (channels.length > 0 ? channels[0] : null);
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: selectedServerUrl,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
|
||||
const availableChannels = activeServer && activeServer.data && activeServer.data.channels ?
|
||||
[...activeServer.data.channels] :
|
||||
[];
|
||||
availableChannels.sort((a, b) => {
|
||||
const nameA = (a.name || a.id || '').toLowerCase();
|
||||
const nameB = (b.name || b.id || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
channelSelect.innerHTML = "";
|
||||
availableChannels.forEach((channel) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.id;
|
||||
option.textContent = channel.name || channel.id;
|
||||
channelSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (session && session.channel) {
|
||||
channelSelect.value = session.channel.id;
|
||||
}
|
||||
|
||||
channelSelect.onchange = () => {
|
||||
const selectedId = channelSelect.value;
|
||||
const nextChannel = availableChannels.find((channel) => channel.id === selectedId) || null;
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[session.server] || {};
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: session.server,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
App.ui.renderFilters(filtersContainer, session);
|
||||
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect) {
|
||||
themeSelect.onchange = () => {
|
||||
const nextTheme = themeSelect.value === 'light' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', nextTheme);
|
||||
App.ui.applyTheme();
|
||||
};
|
||||
}
|
||||
|
||||
if (favoritesToggle) {
|
||||
favoritesToggle.checked = App.favorites.isVisible();
|
||||
favoritesToggle.onchange = () => {
|
||||
App.favorites.setVisible(favoritesToggle.checked);
|
||||
App.favorites.renderBar();
|
||||
};
|
||||
}
|
||||
|
||||
if (sourcesList) {
|
||||
sourcesList.innerHTML = "";
|
||||
serverEntries.forEach((entry) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'source-item';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = entry.url;
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.onclick = async () => {
|
||||
const config = App.storage.getConfig();
|
||||
config.servers = (config.servers || []).filter((serverObj) => {
|
||||
const key = Object.keys(serverObj)[0];
|
||||
return key !== entry.url;
|
||||
});
|
||||
App.storage.setConfig(config);
|
||||
const prefs = App.storage.getPreferences();
|
||||
if (prefs[entry.url]) {
|
||||
delete prefs[entry.url];
|
||||
App.storage.setPreferences(prefs);
|
||||
}
|
||||
|
||||
const remaining = App.storage.getServerEntries();
|
||||
if (remaining.length === 0) {
|
||||
localStorage.removeItem('session');
|
||||
} else {
|
||||
const nextServerUrl = remaining[0].url;
|
||||
const nextServer = remaining[0];
|
||||
const serverPrefs = prefs[nextServerUrl] || {};
|
||||
const channels = nextServer.data && nextServer.data.channels ? nextServer.data.channels : [];
|
||||
const nextChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || channels[0] || null;
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null;
|
||||
const nextSession = {
|
||||
server: nextServerUrl,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
}
|
||||
|
||||
await App.storage.initializeServerStatus();
|
||||
App.videos.resetAndReload();
|
||||
App.ui.renderMenu();
|
||||
};
|
||||
|
||||
row.appendChild(text);
|
||||
row.appendChild(removeBtn);
|
||||
sourcesList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
if (addSourceBtn && sourceInput) {
|
||||
addSourceBtn.onclick = async () => {
|
||||
const raw = sourceInput.value.trim();
|
||||
if (!raw) return;
|
||||
const normalized = raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
||||
|
||||
const config = App.storage.getConfig();
|
||||
const exists = (config.servers || []).some((serverObj) => Object.keys(serverObj)[0] === normalized);
|
||||
if (!exists) {
|
||||
config.servers = config.servers || [];
|
||||
config.servers.push({
|
||||
[normalized]: {}
|
||||
});
|
||||
App.storage.setConfig(config);
|
||||
sourceInput.value = '';
|
||||
await App.storage.initializeServerStatus();
|
||||
|
||||
const session = App.storage.getSession();
|
||||
if (!session || session.server !== normalized) {
|
||||
const entries = App.storage.getServerEntries();
|
||||
const addedEntry = entries.find((entry) => entry.url === normalized);
|
||||
const nextChannel = addedEntry && addedEntry.data && addedEntry.data.channels ?
|
||||
addedEntry.data.channels[0] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: normalized,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? App.session.buildDefaultOptions(nextChannel) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
}
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (reloadChannelBtn) {
|
||||
reloadChannelBtn.onclick = () => {
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
App.ui.renderFilters = function(container, session) {
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!session || !session.channel || !Array.isArray(session.channel.options)) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'filters-empty';
|
||||
empty.textContent = 'No filters available for this channel.';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
session.channel.options.forEach((optionGroup) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'setting-item';
|
||||
|
||||
const labelRow = document.createElement('div');
|
||||
labelRow.className = 'setting-label-row';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.textContent = optionGroup.title || optionGroup.id;
|
||||
labelRow.appendChild(label);
|
||||
const options = optionGroup.options || [];
|
||||
const currentSelection = session.options ? session.options[optionGroup.id] : null;
|
||||
|
||||
if (optionGroup.multiSelect) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.className = 'btn-link';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'multi-select';
|
||||
|
||||
const selectedIds = new Set(
|
||||
Array.isArray(currentSelection)
|
||||
? currentSelection.map((item) => item.id)
|
||||
: []
|
||||
);
|
||||
|
||||
const updateActionLabel = () => {
|
||||
const allChecked = options.length > 0 &&
|
||||
Array.from(list.querySelectorAll('input[type="checkbox"]'))
|
||||
.every((cb) => cb.checked);
|
||||
actionBtn.textContent = allChecked ? 'Deselect all' : 'Select all';
|
||||
actionBtn.disabled = options.length === 0;
|
||||
};
|
||||
|
||||
options.forEach((opt) => {
|
||||
const item = document.createElement('label');
|
||||
item.className = 'multi-select-item';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = opt.id;
|
||||
checkbox.checked = selectedIds.has(opt.id);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = opt.title || opt.id;
|
||||
|
||||
checkbox.onchange = () => {
|
||||
const nextSession = App.storage.getSession();
|
||||
if (!nextSession || !nextSession.channel) return;
|
||||
const selected = [];
|
||||
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||
if (cb.checked) {
|
||||
const found = options.find((item) => item.id === cb.value);
|
||||
if (found) selected.push(found);
|
||||
}
|
||||
});
|
||||
nextSession.options[optionGroup.id] = selected;
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.videos.resetAndReload();
|
||||
updateActionLabel();
|
||||
};
|
||||
|
||||
item.appendChild(checkbox);
|
||||
item.appendChild(text);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
updateActionLabel();
|
||||
|
||||
actionBtn.onclick = () => {
|
||||
const checkboxes = Array.from(list.querySelectorAll('input[type="checkbox"]'));
|
||||
const allChecked = checkboxes.length > 0 && checkboxes.every((cb) => cb.checked);
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.checked = !allChecked;
|
||||
});
|
||||
|
||||
const nextSession = App.storage.getSession();
|
||||
if (!nextSession || !nextSession.channel) return;
|
||||
const selected = [];
|
||||
if (!allChecked) {
|
||||
options.forEach((opt) => selected.push(opt));
|
||||
}
|
||||
nextSession.options[optionGroup.id] = selected;
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.videos.resetAndReload();
|
||||
updateActionLabel();
|
||||
};
|
||||
|
||||
labelRow.appendChild(actionBtn);
|
||||
wrapper.appendChild(labelRow);
|
||||
wrapper.appendChild(list);
|
||||
container.appendChild(wrapper);
|
||||
return;
|
||||
}
|
||||
|
||||
const select = document.createElement('select');
|
||||
options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt.id;
|
||||
option.textContent = opt.title || opt.id;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentSelection && currentSelection.id) {
|
||||
select.value = currentSelection.id;
|
||||
}
|
||||
|
||||
select.onchange = () => {
|
||||
const nextSession = App.storage.getSession();
|
||||
if (!nextSession || !nextSession.channel) return;
|
||||
const selected = options.find((item) => item.id === select.value);
|
||||
if (selected) {
|
||||
nextSession.options[optionGroup.id] = selected;
|
||||
}
|
||||
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
wrapper.appendChild(labelRow);
|
||||
wrapper.appendChild(select);
|
||||
container.appendChild(wrapper);
|
||||
});
|
||||
};
|
||||
|
||||
// Expose inline handlers + keyboard shortcuts.
|
||||
App.ui.bindGlobalHandlers = function() {
|
||||
window.toggleDrawer = App.ui.toggleDrawer;
|
||||
window.closeDrawers = App.ui.closeDrawers;
|
||||
window.closePlayer = App.player.close;
|
||||
window.handleSearch = App.videos.handleSearch;
|
||||
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const clearSearchBtn = document.getElementById('search-clear-btn');
|
||||
if (searchInput && clearSearchBtn) {
|
||||
const updateClearVisibility = () => {
|
||||
const hasValue = searchInput.value.trim().length > 0;
|
||||
clearSearchBtn.classList.toggle('is-visible', hasValue);
|
||||
clearSearchBtn.disabled = !hasValue;
|
||||
};
|
||||
|
||||
clearSearchBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
if (!searchInput.value) return;
|
||||
searchInput.value = '';
|
||||
updateClearVisibility();
|
||||
App.videos.handleSearch('');
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', updateClearVisibility);
|
||||
updateClearVisibility();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
App.ui.closeDrawers();
|
||||
App.ui.closeInfo();
|
||||
App.videos.closeAllMenus();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
App.videos.closeAllMenus();
|
||||
});
|
||||
|
||||
const infoModal = document.getElementById('info-modal');
|
||||
if (infoModal) {
|
||||
infoModal.addEventListener('click', (event) => {
|
||||
if (event.target === infoModal) {
|
||||
App.ui.closeInfo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const infoClose = document.getElementById('info-close');
|
||||
if (infoClose) {
|
||||
infoClose.addEventListener('click', () => {
|
||||
App.ui.closeInfo();
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
513
frontend/js/videos.js
Normal file
513
frontend/js/videos.js
Normal file
@@ -0,0 +1,513 @@
|
||||
window.App = window.App || {};
|
||||
App.videos = App.videos || {};
|
||||
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) App.videos.loadVideos();
|
||||
}, {
|
||||
threshold: 1.0
|
||||
});
|
||||
|
||||
const titleEnv = {
|
||||
useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||
};
|
||||
|
||||
const titleVisibility = new Map();
|
||||
let titleObserver = null;
|
||||
if (!titleEnv.useHoverFocus) {
|
||||
titleObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
titleVisibility.set(entry.target, entry.intersectionRatio || 0);
|
||||
} else {
|
||||
titleVisibility.delete(entry.target);
|
||||
entry.target.dataset.titlePrimary = '0';
|
||||
updateTitleActive(entry.target);
|
||||
}
|
||||
});
|
||||
let topCard = null;
|
||||
let topRatio = 0;
|
||||
titleVisibility.forEach((ratio, card) => {
|
||||
if (ratio > topRatio) {
|
||||
topRatio = ratio;
|
||||
topCard = card;
|
||||
}
|
||||
});
|
||||
titleVisibility.forEach((ratio, card) => {
|
||||
card.dataset.titlePrimary = card === topCard && ratio >= 0.55 ? '1' : '0';
|
||||
updateTitleActive(card);
|
||||
});
|
||||
}, {
|
||||
threshold: [0, 0.25, 0.55, 0.8, 1.0]
|
||||
});
|
||||
}
|
||||
|
||||
App.videos.observeSentinel = function() {
|
||||
const sentinel = document.getElementById('sentinel');
|
||||
if (sentinel) {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTitleActive = function(card) {
|
||||
if (!card || !card.classList.contains('has-marquee')) {
|
||||
if (card) card.classList.remove('is-title-active');
|
||||
return;
|
||||
}
|
||||
const hovered = card.dataset.titleHovered === '1';
|
||||
const focused = card.dataset.titleFocused === '1';
|
||||
const primary = card.dataset.titlePrimary === '1';
|
||||
const active = titleEnv.useHoverFocus ? (hovered || focused) : (focused || primary);
|
||||
card.classList.toggle('is-title-active', active);
|
||||
};
|
||||
|
||||
const measureTitle = function(card) {
|
||||
if (!card) return;
|
||||
const titleWrap = card.querySelector('.video-title');
|
||||
const titleText = card.querySelector('.video-title-text');
|
||||
if (!titleWrap || !titleText) return;
|
||||
const overflow = titleText.scrollWidth - titleWrap.clientWidth;
|
||||
if (overflow > 4) {
|
||||
card.classList.add('has-marquee');
|
||||
titleText.style.setProperty('--marquee-distance', `${overflow + 12}px`);
|
||||
} else {
|
||||
card.classList.remove('has-marquee', 'is-title-active');
|
||||
titleText.style.removeProperty('--marquee-distance');
|
||||
}
|
||||
updateTitleActive(card);
|
||||
};
|
||||
|
||||
let titleMeasureRaf = null;
|
||||
const scheduleTitleMeasure = function() {
|
||||
if (titleMeasureRaf) return;
|
||||
titleMeasureRaf = requestAnimationFrame(() => {
|
||||
titleMeasureRaf = null;
|
||||
document.querySelectorAll('.video-card').forEach((card) => {
|
||||
measureTitle(card);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', scheduleTitleMeasure);
|
||||
|
||||
App.videos.formatDuration = function(seconds) {
|
||||
if (!seconds || seconds <= 0) return '';
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return `${secs}`;
|
||||
};
|
||||
|
||||
App.videos.buildImageProxyUrl = function(imageUrl) {
|
||||
if (!imageUrl) return '';
|
||||
try {
|
||||
return `/api/image?url=${encodeURIComponent(imageUrl)}&ts=${Date.now()}`;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
App.videos.attachNoReferrerRetry = function(img) {
|
||||
if (!img) return;
|
||||
if (!img.dataset.originalSrc) {
|
||||
img.dataset.originalSrc = img.currentSrc || img.src || '';
|
||||
}
|
||||
img.dataset.noReferrerRetry = '0';
|
||||
img.addEventListener('error', () => {
|
||||
if (img.dataset.noReferrerRetry === '1') return;
|
||||
img.dataset.noReferrerRetry = '1';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.removeAttribute('crossorigin');
|
||||
const original = img.dataset.originalSrc || img.currentSrc || img.src || '';
|
||||
const proxyUrl = App.videos.buildImageProxyUrl(original);
|
||||
if (proxyUrl) {
|
||||
img.src = proxyUrl;
|
||||
} else if (original) {
|
||||
img.src = original;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fetches the next page of videos and renders them into the grid.
|
||||
App.videos.loadVideos = async function() {
|
||||
const session = App.storage.getSession();
|
||||
if (!session) return;
|
||||
if (state.isLoading || !state.hasNextPage) return;
|
||||
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const query = searchInput ? searchInput.value : "";
|
||||
|
||||
let body = {
|
||||
channel: session.channel.id,
|
||||
query: query || "",
|
||||
page: state.currentPage,
|
||||
perPage: state.perPage,
|
||||
server: session.server
|
||||
};
|
||||
|
||||
Object.entries(session.options).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
body[key] = value.map((entry) => entry.id).join(", ");
|
||||
} else if (value && value.id) {
|
||||
body[key] = value.id;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
state.isLoading = true;
|
||||
App.videos.updateLoadMoreState();
|
||||
state.currentLoadController = new AbortController();
|
||||
const response = await fetch('/api/videos', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: state.currentLoadController.signal
|
||||
});
|
||||
const videos = await response.json();
|
||||
App.videos.renderVideos(videos);
|
||||
state.hasNextPage = videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true;
|
||||
state.currentPage++;
|
||||
App.videos.ensureViewportFilled();
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error("Failed to load videos:", err);
|
||||
}
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
state.currentLoadController = null;
|
||||
App.videos.updateLoadMoreState();
|
||||
}
|
||||
};
|
||||
|
||||
// Renders new cards for videos, wiring favorites + playback behavior.
|
||||
App.videos.renderVideos = function(videos) {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const items = videos && Array.isArray(videos.items) ? videos.items : [];
|
||||
const favoritesSet = App.favorites.getSet();
|
||||
items.forEach(v => {
|
||||
if (state.renderedVideoIds.has(v.id)) return;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'video-card';
|
||||
const durationText = App.videos.formatDuration(v.duration);
|
||||
const favoriteKey = App.favorites.getKey(v);
|
||||
const uploaderText = v.uploader || '';
|
||||
const tags = Array.isArray(v.tags) ? v.tags.filter(tag => tag) : [];
|
||||
const tagsMarkup = tags.length
|
||||
? `<div class="video-tags">${tags.map(tag => `<button class="video-tag" type="button" data-tag="${tag}">${tag}</button>`).join('')}</div>`
|
||||
: '';
|
||||
card.innerHTML = `
|
||||
<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}">
|
||||
<div class="video-loading" aria-hidden="true">
|
||||
<div class="video-loading-spinner"></div>
|
||||
</div>
|
||||
<h4 class="video-title"><span class="video-title-text">${v.title}</span></h4>
|
||||
${tagsMarkup}
|
||||
${uploaderText ? `<p class="video-meta"><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
|
||||
${durationText ? `<p class="video-duration">${durationText}</p>` : ''}
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
App.videos.attachNoReferrerRetry(thumb);
|
||||
if (thumb) {
|
||||
thumb.addEventListener('load', App.videos.scheduleMasonryLayout);
|
||||
}
|
||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||
if (favoriteBtn && favoriteKey) {
|
||||
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
|
||||
favoriteBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(v);
|
||||
};
|
||||
}
|
||||
const titleWrap = card.querySelector('.video-title');
|
||||
const titleText = card.querySelector('.video-title-text');
|
||||
if (titleWrap && titleText) {
|
||||
requestAnimationFrame(() => {
|
||||
measureTitle(card);
|
||||
});
|
||||
card.addEventListener('focusin', () => {
|
||||
card.dataset.titleFocused = '1';
|
||||
updateTitleActive(card);
|
||||
});
|
||||
card.addEventListener('focusout', () => {
|
||||
card.dataset.titleFocused = '0';
|
||||
updateTitleActive(card);
|
||||
});
|
||||
if (titleEnv.useHoverFocus) {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.dataset.titleHovered = '1';
|
||||
updateTitleActive(card);
|
||||
});
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.dataset.titleHovered = '0';
|
||||
updateTitleActive(card);
|
||||
});
|
||||
} else if (titleObserver) {
|
||||
titleObserver.observe(card);
|
||||
}
|
||||
}
|
||||
const uploaderBtn = card.querySelector('.uploader-link');
|
||||
if (uploaderBtn) {
|
||||
uploaderBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
|
||||
App.videos.handleSearch(uploader);
|
||||
};
|
||||
}
|
||||
const tagButtons = card.querySelectorAll('.video-tag');
|
||||
if (tagButtons.length) {
|
||||
tagButtons.forEach((tagBtn) => {
|
||||
tagBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
const tag = tagBtn.dataset.tag || tagBtn.textContent || '';
|
||||
App.videos.handleSearch(tag);
|
||||
};
|
||||
});
|
||||
}
|
||||
const menuBtn = card.querySelector('.video-menu-btn');
|
||||
const menu = card.querySelector('.video-menu');
|
||||
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
|
||||
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
|
||||
if (menuBtn && menu) {
|
||||
menuBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.toggleMenu(menu, menuBtn);
|
||||
};
|
||||
}
|
||||
if (showInfoBtn) {
|
||||
showInfoBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.ui.showInfo(v);
|
||||
App.videos.closeAllMenus();
|
||||
};
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.downloadVideo(v);
|
||||
App.videos.closeAllMenus();
|
||||
};
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
App.videos.scheduleMasonryLayout();
|
||||
App.videos.ensureViewportFilled();
|
||||
};
|
||||
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
state.currentPage = 1;
|
||||
state.hasNextPage = true;
|
||||
state.renderedVideoIds.clear();
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (grid) grid.innerHTML = "";
|
||||
App.videos.updateLoadMoreState();
|
||||
App.videos.loadVideos();
|
||||
};
|
||||
|
||||
App.videos.resetAndReload = function() {
|
||||
if (state.currentLoadController) {
|
||||
state.currentLoadController.abort();
|
||||
state.currentLoadController = null;
|
||||
state.isLoading = false;
|
||||
}
|
||||
state.currentPage = 1;
|
||||
state.hasNextPage = true;
|
||||
state.renderedVideoIds.clear();
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (grid) grid.innerHTML = "";
|
||||
App.videos.updateLoadMoreState();
|
||||
App.videos.loadVideos();
|
||||
};
|
||||
|
||||
App.videos.ensureViewportFilled = function() {
|
||||
if (!state.hasNextPage || state.isLoading) return;
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid) return;
|
||||
const docHeight = document.documentElement.scrollHeight;
|
||||
if (docHeight <= window.innerHeight + 120) {
|
||||
window.setTimeout(() => App.videos.loadVideos(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
let masonryRaf = null;
|
||||
App.videos.scheduleMasonryLayout = function() {
|
||||
if (masonryRaf) {
|
||||
cancelAnimationFrame(masonryRaf);
|
||||
}
|
||||
masonryRaf = requestAnimationFrame(() => {
|
||||
masonryRaf = null;
|
||||
App.videos.applyMasonryLayout();
|
||||
});
|
||||
};
|
||||
|
||||
App.videos.applyMasonryLayout = function() {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid) return;
|
||||
const styles = window.getComputedStyle(grid);
|
||||
if (styles.display !== 'grid') return;
|
||||
const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10);
|
||||
const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0;
|
||||
if (!rowHeight) return;
|
||||
Array.from(grid.children).forEach((item) => {
|
||||
const itemHeight = item.getBoundingClientRect().height;
|
||||
const span = Math.ceil((itemHeight + rowGap) / (rowHeight + rowGap));
|
||||
item.style.gridRowEnd = `span ${span}`;
|
||||
});
|
||||
};
|
||||
|
||||
App.videos.updateLoadMoreState = function() {
|
||||
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||
if (!loadMoreBtn) return;
|
||||
loadMoreBtn.disabled = state.isLoading || !state.hasNextPage;
|
||||
loadMoreBtn.style.display = state.hasNextPage ? 'flex' : 'none';
|
||||
};
|
||||
|
||||
// Context menu helpers for per-card actions.
|
||||
App.videos.closeAllMenus = function() {
|
||||
document.querySelectorAll('.video-menu.open').forEach((menu) => {
|
||||
menu.classList.remove('open');
|
||||
});
|
||||
document.querySelectorAll('.video-menu-btn[aria-expanded="true"]').forEach((btn) => {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
};
|
||||
|
||||
App.videos.toggleMenu = function(menu, button) {
|
||||
const isOpen = menu.classList.contains('open');
|
||||
App.videos.closeAllMenus();
|
||||
if (!isOpen) {
|
||||
menu.classList.add('open');
|
||||
if (button) {
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
App.videos.coerceNumber = function(value) {
|
||||
if (value === null || value === undefined) return 0;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
App.videos.pickBestFormat = function(formats) {
|
||||
if (!Array.isArray(formats) || formats.length === 0) return null;
|
||||
const candidates = formats.filter((fmt) => fmt && fmt.url);
|
||||
if (!candidates.length) return null;
|
||||
const videoCandidates = candidates.filter((fmt) => {
|
||||
const videoExt = String(fmt.video_ext || '').toLowerCase();
|
||||
const vcodec = String(fmt.vcodec || '').toLowerCase();
|
||||
if (videoExt && videoExt !== 'none') return true;
|
||||
if (vcodec && vcodec !== 'none') return true;
|
||||
return false;
|
||||
});
|
||||
const pool = videoCandidates.length ? videoCandidates : candidates;
|
||||
const score = (fmt) => {
|
||||
const height = App.videos.coerceNumber(fmt.height || fmt.quality);
|
||||
const width = App.videos.coerceNumber(fmt.width);
|
||||
const size = height || width;
|
||||
const bitrate = App.videos.coerceNumber(fmt.tbr || fmt.bitrate);
|
||||
const fps = App.videos.coerceNumber(fmt.fps);
|
||||
return [size, bitrate, fps];
|
||||
};
|
||||
return pool.reduce((best, fmt) => {
|
||||
if (!best) return fmt;
|
||||
const bestScore = score(best);
|
||||
const curScore = score(fmt);
|
||||
for (let i = 0; i < curScore.length; i++) {
|
||||
if (curScore[i] > bestScore[i]) return fmt;
|
||||
if (curScore[i] < bestScore[i]) return best;
|
||||
}
|
||||
return best;
|
||||
}, null);
|
||||
};
|
||||
|
||||
App.videos.resolveStreamSource = function(videoOrUrl) {
|
||||
let sourceUrl = '';
|
||||
let referer = '';
|
||||
if (typeof videoOrUrl === 'string') {
|
||||
sourceUrl = videoOrUrl;
|
||||
} else if (videoOrUrl && typeof videoOrUrl === 'object') {
|
||||
const meta = videoOrUrl.meta || videoOrUrl;
|
||||
sourceUrl = meta.url || videoOrUrl.url || '';
|
||||
const best = App.videos.pickBestFormat(meta.formats);
|
||||
if (best && best.url) {
|
||||
sourceUrl = best.url;
|
||||
if (best.http_headers && (best.http_headers.Referer || best.http_headers.referer)) {
|
||||
referer = best.http_headers.Referer || best.http_headers.referer;
|
||||
}
|
||||
}
|
||||
if (!referer && meta.http_headers && (meta.http_headers.Referer || meta.http_headers.referer)) {
|
||||
referer = meta.http_headers.Referer || meta.http_headers.referer;
|
||||
}
|
||||
}
|
||||
if (!referer && sourceUrl) {
|
||||
try {
|
||||
referer = `${new URL(sourceUrl).origin}/`;
|
||||
} catch (err) {
|
||||
referer = '';
|
||||
}
|
||||
}
|
||||
return { url: sourceUrl, referer };
|
||||
};
|
||||
|
||||
// Builds a proxied stream URL with an optional referer parameter.
|
||||
App.videos.buildStreamUrl = function(videoOrUrl) {
|
||||
const resolved = App.videos.resolveStreamSource(videoOrUrl);
|
||||
if (!resolved.url) return '';
|
||||
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
||||
return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`;
|
||||
};
|
||||
|
||||
App.videos.downloadVideo = function(video) {
|
||||
if (!video) return;
|
||||
const streamUrl = App.videos.buildStreamUrl(video);
|
||||
if (!streamUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = streamUrl;
|
||||
const rawName = (video.title || video.id || 'video').toString();
|
||||
const safeName = rawName.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
||||
link.download = safeName ? `${safeName}.mp4` : 'video.mp4';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
};
|
||||
})();
|
||||
@@ -1,371 +0,0 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0e27;
|
||||
--bg-secondary: #141829;
|
||||
--bg-tertiary: #1a1f3a;
|
||||
--text-primary: #e8eaf6;
|
||||
--text-secondary: #b0b0c0;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--border: #2a2f4a;
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
overflow-x: hidden;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Top Bar */
|
||||
.top-bar {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
background: var(--bg-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-tertiary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.search-container input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Hamburger Menu */
|
||||
.hamburger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hamburger span {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-toggle.active .hamburger span:nth-child(1) {
|
||||
transform: translateY(6px) rotate(45deg);
|
||||
}
|
||||
|
||||
.menu-toggle.active .hamburger span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.menu-toggle.active .hamburger span:nth-child(3) {
|
||||
transform: translateY(-6px) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -100%;
|
||||
width: 320px;
|
||||
height: 100vh;
|
||||
background: var(--bg-secondary);
|
||||
z-index: 1001;
|
||||
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
padding: 12px 24px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-left-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.setting-item select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Grid Container */
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-container {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video Card */
|
||||
.video-card {
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.video-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-card h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-card p {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 12px 12px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2001;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
Reference in New Issue
Block a user