Compare commits
48 Commits
3a9011690c
...
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 | ||
|
|
fc68035a79 | ||
|
|
273e7c61f3 | ||
|
|
6762fb9513 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
*/__pycache__
|
||||||
|
*/__pycache__/*
|
||||||
|
.tmp
|
||||||
|
frontend/dist/*
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.9-slim
|
FROM python:3.13
|
||||||
|
|
||||||
# Install yt-dlp and dependencies
|
# Install yt-dlp and dependencies
|
||||||
RUN apt-get update && apt-get install -y ffmpeg curl && \
|
RUN apt-get update && apt-get install -y ffmpeg curl && \
|
||||||
@@ -6,8 +6,8 @@ RUN apt-get update && apt-get install -y ffmpeg curl && \
|
|||||||
chmod a+rx /usr/local/bin/yt-dlp
|
chmod a+rx /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
COPY . .
|
RUN rm requirements.txt
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "backend/main.py"]
|
||||||
561
backend/main.py
561
backend/main.py
@@ -1,10 +1,28 @@
|
|||||||
from flask import Flask, request, Response, send_from_directory, jsonify
|
from flask import Flask, request, Response, send_from_directory, jsonify
|
||||||
import subprocess
|
import os
|
||||||
import requests
|
import requests
|
||||||
|
from flask_cors import CORS
|
||||||
|
import urllib.parse
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util import Retry
|
||||||
|
import yt_dlp
|
||||||
|
import io
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
app = Flask(__name__, static_folder='../frontend', static_url_path='')
|
# Serve frontend static files under `/static` to avoid colliding with API routes
|
||||||
|
app = Flask(__name__, static_folder='../frontend', static_url_path='/static')
|
||||||
app.url_map.strict_slashes = False
|
app.url_map.strict_slashes = False
|
||||||
|
|
||||||
|
# Use flask-cors for API routes
|
||||||
|
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||||
|
|
||||||
|
# Configure a requests session with retries
|
||||||
|
session = requests.Session()
|
||||||
|
retries = Retry(total=2, backoff_factor=0.2, status_forcelist=(500, 502, 503, 504))
|
||||||
|
adapter = HTTPAdapter(max_retries=retries)
|
||||||
|
session.mount('http://', adapter)
|
||||||
|
session.mount('https://', adapter)
|
||||||
|
|
||||||
@app.route('/api/status', methods=['POST', 'GET'])
|
@app.route('/api/status', methods=['POST', 'GET'])
|
||||||
def proxy_status():
|
def proxy_status():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -16,49 +34,548 @@ def proxy_status():
|
|||||||
|
|
||||||
if not target_server:
|
if not target_server:
|
||||||
return jsonify({"error": "No server provided"}), 400
|
return jsonify({"error": "No server provided"}), 400
|
||||||
|
if target_server.endswith('/'):
|
||||||
|
target_server = target_server[:-1]
|
||||||
|
target_server = f"{target_server.strip()}/api/status"
|
||||||
|
# Validate target URL
|
||||||
|
parsed = urllib.parse.urlparse(target_server)
|
||||||
|
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
||||||
|
return jsonify({"error": "Invalid target URL"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use the data gathered above
|
# Forward a small set of safe request headers
|
||||||
response = requests.post(target_server, json=client_data if request.method == 'POST' else {}, timeout=5)
|
safe_request_headers = {}
|
||||||
return (response.content, response.status_code, response.headers.items())
|
for k in ('User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language', 'Range'):
|
||||||
|
if k in request.headers:
|
||||||
|
safe_request_headers[k] = request.headers[k]
|
||||||
|
|
||||||
|
# Remove hop-by-hop request headers per RFC
|
||||||
|
for hop in ('Connection', 'Keep-Alive', 'Proxy-Authenticate', 'Proxy-Authorization', 'TE', 'Trailers', 'Transfer-Encoding', 'Upgrade'):
|
||||||
|
safe_request_headers.pop(hop, None)
|
||||||
|
|
||||||
|
# Stream the GET via a session with small retry policy
|
||||||
|
resp = session.get(target_server, headers=safe_request_headers, timeout=5, stream=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
|
||||||
|
if name.lower() == 'content-length':
|
||||||
|
# Let Flask set Content-Length if needed for the assembled response
|
||||||
|
continue
|
||||||
|
forwarded_headers.append((name, value))
|
||||||
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/videos', methods=['POST'])
|
||||||
|
def videos_proxy():
|
||||||
|
client_data = request.get_json() or {}
|
||||||
|
target_server = client_data.get('server')
|
||||||
|
client_data.pop('server', None) # Remove server from payload
|
||||||
|
if not target_server:
|
||||||
|
return jsonify({"error": "No server provided"}), 400
|
||||||
|
if target_server.endswith('/'):
|
||||||
|
target_server = target_server[:-1]
|
||||||
|
target_server = f"{target_server.strip()}/api/videos"
|
||||||
|
# Validate target URL
|
||||||
|
parsed = urllib.parse.urlparse(target_server)
|
||||||
|
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
||||||
|
return jsonify({"error": "Invalid target URL"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = session.post(target_server, json=client_data,timeout=5)
|
||||||
|
return Response(resp.content, status=resp.status_code, content_type=resp.headers.get('Content-Type', 'application/json'))
|
||||||
|
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('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return send_from_directory(app.static_folder, 'index.html')
|
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():
|
def stream_video():
|
||||||
# Note: <video> tags perform GET. To support your POST requirement,
|
# Note: <video> tags perform GET. To support your POST requirement,
|
||||||
# we handle the URL via JSON post or URL params.
|
# 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 = ""
|
video_url = ""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
video_url = request.json.get('url')
|
video_url = request.json.get('url')
|
||||||
else:
|
else:
|
||||||
video_url = request.args.get('url')
|
video_url = request.args.get('url')
|
||||||
|
|
||||||
def generate():
|
if not video_url:
|
||||||
# yt-dlp command to get the stream and pipe to stdout
|
return jsonify({"error": "No URL provided"}), 400
|
||||||
cmd = [
|
|
||||||
'yt-dlp',
|
dbg(f"method={request.method} url={video_url}")
|
||||||
'-o', '-', # output to stdout
|
|
||||||
'-f', 'best[ext=mp4]/best',
|
def is_hls(url):
|
||||||
video_url
|
return '.m3u8' in urllib.parse.urlparse(url).path
|
||||||
]
|
|
||||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
def is_dash(url):
|
||||||
try:
|
return urllib.parse.urlparse(url).path.lower().endswith('.mpd')
|
||||||
while True:
|
|
||||||
chunk = process.stdout.read(1024 * 16)
|
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:
|
if not chunk:
|
||||||
break
|
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
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
process.kill()
|
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__':
|
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)
|
||||||
|
|||||||
15
backend/requirements.txt
Normal file
15
backend/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
blinker==1.9.0
|
||||||
|
certifi==2026.1.4
|
||||||
|
charset-normalizer==3.4.4
|
||||||
|
click==8.3.1
|
||||||
|
Flask==3.1.2
|
||||||
|
Flask-Cors==6.0.2
|
||||||
|
idna==3.11
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
jsonify==0.5
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
requests==2.32.5
|
||||||
|
urllib3==2.6.3
|
||||||
|
Werkzeug==3.1.5
|
||||||
|
yt-dlp==2026.1.29
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
webserver:
|
hottub-webclient:
|
||||||
build: ./backend
|
image: hottub-webclient:latest
|
||||||
ports:
|
container_name: hottub-webclient
|
||||||
- "5000:5000"
|
entrypoint: python3
|
||||||
|
command: ["backend/main.py"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/frontend
|
- /path/to/hottub-webclient:/app
|
||||||
environment:
|
restart: unless-stopped
|
||||||
- PYTHONUNBUFFERED=1
|
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
|
||||||
106
frontend/app.js
106
frontend/app.js
@@ -1,106 +0,0 @@
|
|||||||
let currentPage = 1;
|
|
||||||
const perPage = 12;
|
|
||||||
|
|
||||||
localStorage.clear();
|
|
||||||
|
|
||||||
function InitializeLocalStorage() {
|
|
||||||
if (!localStorage.getItem('config')) {
|
|
||||||
localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] }));
|
|
||||||
InitializeServerStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function InitializeServerStatus() {
|
|
||||||
const config = JSON.parse(localStorage.getItem('config'));
|
|
||||||
config.servers.forEach(serverObj => {
|
|
||||||
const server = Object.keys(serverObj)[0];
|
|
||||||
fetch(`/api/status`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ server: server }),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(status => {
|
|
||||||
serverObj[server] = status;
|
|
||||||
localStorage.setItem('config', JSON.stringify(config));
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
serverObj[server] = { online: false };
|
|
||||||
localStorage.setItem('config', JSON.stringify(config));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadVideos() {
|
|
||||||
const config = JSON.parse(localStorage.getItem('config'));
|
|
||||||
const response = await fetch('/api/videos', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
channel: config.channel,
|
|
||||||
sort: "latest",
|
|
||||||
query: "",
|
|
||||||
page: currentPage,
|
|
||||||
perPage: perPage
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const videos = await response.json();
|
|
||||||
renderVideos(videos);
|
|
||||||
currentPage++;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderVideos(videos) {
|
|
||||||
const grid = document.getElementById('video-grid');
|
|
||||||
videos.forEach(v => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'video-card';
|
|
||||||
card.innerHTML = `
|
|
||||||
<img src="${v.thumb}" alt="${v.title}">
|
|
||||||
<h4>${v.title}</h4>
|
|
||||||
<p>${v.channel} • ${v.duration}s</p>
|
|
||||||
`;
|
|
||||||
card.onclick = () => openPlayer(v.url);
|
|
||||||
grid.appendChild(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPlayer(videoUrl) {
|
|
||||||
const modal = document.getElementById('video-modal');
|
|
||||||
const player = document.getElementById('player');
|
|
||||||
// Using GET for the video tag src as it's the standard for streaming
|
|
||||||
player.src = `/api/stream?url=${encodeURIComponent(videoUrl)}`;
|
|
||||||
modal.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePlayer() {
|
|
||||||
const modal = document.getElementById('video-modal');
|
|
||||||
const player = document.getElementById('player');
|
|
||||||
player.pause();
|
|
||||||
player.src = "";
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI Helpers
|
|
||||||
function toggleDrawer(id) {
|
|
||||||
document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open'));
|
|
||||||
document.getElementById(`drawer-${id}`).classList.add('open');
|
|
||||||
document.getElementById('overlay').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDrawers() {
|
|
||||||
document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open'));
|
|
||||||
document.getElementById('overlay').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infinite Scroll Observer
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
if (entries[0].isIntersecting) loadVideos();
|
|
||||||
}, { threshold: 1.0 });
|
|
||||||
|
|
||||||
observer.observe(document.getElementById('sentinel'));
|
|
||||||
|
|
||||||
// Init
|
|
||||||
InitializeLocalStorage();
|
|
||||||
loadVideos();
|
|
||||||
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,35 +3,138 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Hottub</title>
|
<title>Jacuzzi</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<div class="logo">Hottub</div>
|
<div class="logo">Jacuzzi</div>
|
||||||
<div class="actions">
|
|
||||||
<button onclick="toggleDrawer('settings')">Settings</button>
|
|
||||||
<button onclick="toggleDrawer('menu')" class="menu-btn">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="search-input" placeholder="Search videos..." oninput="handleSearch(this.value)">
|
<input type="text" id="search-input" placeholder="Search videos..." 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">
|
||||||
|
<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">
|
||||||
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/wrench.svg" alt="Settings">
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>Menu</h3>
|
||||||
|
<button class="close-btn" onclick="closeDrawers()">✕</button>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>Settings</h3>
|
||||||
|
<button class="close-btn" onclick="closeDrawers()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>Theme</label>
|
||||||
|
<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>
|
<main id="video-grid" class="grid-container"></main>
|
||||||
<div id="sentinel"></div> <div id="drawer-menu" class="drawer"><h3>Menu</h3></div>
|
<div id="sentinel"></div>
|
||||||
<div id="drawer-settings" class="drawer"><h3>Settings</h3></div>
|
<button id="load-more-btn" class="load-more-btn" title="Load More">
|
||||||
<div id="overlay" onclick="closeDrawers()"></div>
|
<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 id="video-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close" onclick="closePlayer()">×</span>
|
<span class="close" onclick="closePlayer()">×</span>
|
||||||
<video id="player" controls autoplay></video>
|
<video id="player" controls autoplay playsinline webkit-playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="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>
|
</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,30 +0,0 @@
|
|||||||
:root { --bg: #0f0f0f; --text: #fff; --accent: #3d3d3d; }
|
|
||||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: sans-serif; overflow-x: hidden; }
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
height: 60px; display: flex; justify-content: space-between; align-items: center;
|
|
||||||
padding: 0 20px; background: #202020; position: sticky; top: 0; z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-container {
|
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 20px; padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-card { cursor: pointer; transition: transform 0.2s; }
|
|
||||||
.video-card img { width: 100%; border-radius: 12px; }
|
|
||||||
|
|
||||||
.drawer {
|
|
||||||
position: fixed; top: 0; right: -300px; width: 300px; height: 100%;
|
|
||||||
background: #1e1e1e; z-index: 1000; transition: 0.3s; padding: 20px;
|
|
||||||
}
|
|
||||||
.drawer.open { right: 0; }
|
|
||||||
|
|
||||||
#overlay {
|
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
|
||||||
display: none; z-index: 999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal { display: none; position: fixed; inset: 0; background: #000; z-index: 2000; }
|
|
||||||
.modal-content { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
|
||||||
video { width: 80%; max-height: 80vh; }
|
|
||||||
Reference in New Issue
Block a user