Compare commits

...

42 Commits

Author SHA1 Message Date
Simon
d73e413352 backend improvements 2026-02-12 17:40:45 +00:00
Simon
7ba8896405 fix player bug with apple mpegurl 2026-02-12 08:23:27 +00:00
Simon
ece4852d4f searchbar X Button visibility fix 2026-02-11 15:23:45 +00:00
Simon
a06a952a28 timestamp fix 2026-02-11 15:21:02 +00:00
Simon
24a2c9f738 tags and title fix 2026-02-11 15:16:23 +00:00
Simon
1f5910a996 added loading indicator 2026-02-11 07:04:59 +00:00
Simon
081493d13f request information with referer 2026-02-10 17:57:59 +00:00
Simon
81597c3bb2 correct video element order 2026-02-09 22:08:28 +00:00
Simon
3d81b6aae7 clear search button 2026-02-09 21:40:34 +00:00
Simon
d2e1e3adea honoring formats and http headers 2026-02-09 19:16:44 +00:00
Simon
c2872c1883 detect m3u8 is actually mp4 2026-02-09 18:35:39 +00:00
Simon
5baca567cb beeg fixed 2026-02-09 18:27:26 +00:00
Simon
7b90c05a29 load image fallback 2026-02-09 16:28:01 +00:00
Simon
16e42cf318 visual improvements and bugfixes 2026-02-09 16:09:42 +00:00
Simon
e6d36711b1 Download functionality 2026-02-09 14:50:17 +00:00
Simon
257e19e9db display uploader 2026-02-09 13:32:47 +00:00
Simon
ee1cb511df broke up monolithic structure 2026-02-09 13:27:08 +00:00
Simon
f06a7cd3d0 favorites function 2026-02-09 13:13:46 +00:00
Simon
437d42ea3d better top bar 2026-02-09 13:05:35 +00:00
Simon
bd07bdef3c better ui 2026-02-09 13:04:02 +00:00
Simon
c2289bf3ec expanded for TV devices 2026-02-09 12:38:49 +00:00
Simon
1651e5a375 on mobile go directly to full screen video 2026-02-09 12:31:07 +00:00
Simon
df8aaa5f9f rename to jacuzzi 2026-02-09 12:08:39 +00:00
Simon
a9949e452f removed .vscode 2026-02-09 10:13:11 +00:00
Simon
6915da7f85 improved video play 2026-02-08 20:44:36 +00:00
Simon
407e3bf9c6 improved yt-dlp 2026-02-08 20:11:07 +00:00
Simon
313ba70fec improved video streams 2026-02-08 20:08:23 +00:00
Simon
10ebcc87c0 error message 2026-02-08 19:47:47 +00:00
Simon
1becdce9ff favicon 2026-02-08 17:16:48 +00:00
Simon
1dc6048d9c (de-) select all 2026-02-08 17:14:20 +00:00
Simon
88997a7527 updated docker-compose 2026-02-08 16:19:28 +00:00
Simon
8ebbaeab1c better multiselect 2026-02-08 16:18:02 +00:00
Simon
395b7e2c6d bugfix 2026-02-08 16:09:48 +00:00
Simon
f62cae1508 added initial sources 2026-02-08 16:05:01 +00:00
Simon
7f5ada3a82 updated python 2026-02-08 15:58:50 +00:00
Simon
da53e6cc88 we will mount the folder in the container so no need to copy all 2026-02-08 15:56:12 +00:00
Simon
5a2021580d load more button and other device support 2026-02-08 15:54:55 +00:00
Simon
f71d8e3ee1 visual upgrade 2026-02-08 15:48:45 +00:00
Simon
c67a5cde16 handle application/vnd.apple.mpegurl. 2026-02-08 15:23:01 +00:00
Simon
b9f49530e4 more upgrade 2026-02-08 14:43:00 +00:00
Simon
18cb317730 add/remove server 2026-02-08 14:11:02 +00:00
Simon
8273bc335d light/dark mode 2026-02-08 14:03:43 +00:00
17 changed files with 3664 additions and 932 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
*/__pycache__
*/__pycache__/*
.tmp
frontend/dist/*

View File

@@ -1,3 +0,0 @@
{
"compile-hero.disable-compile-files-on-did-save-code": false
}

View File

@@ -1,4 +1,4 @@
FROM python:3.9-slim
FROM python:3.13
# Install yt-dlp and dependencies
RUN apt-get update && apt-get install -y ffmpeg curl && \
@@ -6,7 +6,8 @@ RUN apt-get update && apt-get install -y ffmpeg curl && \
chmod a+rx /usr/local/bin/yt-dlp
WORKDIR /app
COPY . .
RUN pip install -r backend/requirements.txt
COPY backend/requirements.txt .
RUN pip install -r requirements.txt
RUN rm requirements.txt
CMD ["python", "backend/main.py"]

View File

@@ -1,4 +1,5 @@
from flask import Flask, request, Response, send_from_directory, jsonify
import os
import requests
from flask_cors import CORS
import urllib.parse
@@ -6,6 +7,7 @@ from requests.adapters import HTTPAdapter
from urllib3.util import Retry
import yt_dlp
import io
from urllib.parse import urljoin
# Serve frontend static files under `/static` to avoid colliding with API routes
app = Flask(__name__, static_folder='../frontend', static_url_path='/static')
@@ -101,14 +103,71 @@ def videos_proxy():
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/image', methods=['GET', 'HEAD'])
def image_proxy():
image_url = request.args.get('url')
if not image_url:
return jsonify({"error": "No URL provided"}), 400
parsed = urllib.parse.urlparse(image_url)
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
return jsonify({"error": "Invalid target URL"}), 400
try:
safe_request_headers = {}
for k in ('User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language'):
if k in request.headers:
safe_request_headers[k] = request.headers[k]
resp = session.get(image_url, headers=safe_request_headers, stream=True, timeout=15, allow_redirects=True)
hop_by_hop = {
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailers', 'transfer-encoding', 'upgrade'
}
forwarded_headers = []
for name, value in resp.headers.items():
if name.lower() in hop_by_hop:
continue
forwarded_headers.append((name, value))
if request.method == 'HEAD':
resp.close()
return Response("", status=resp.status_code, headers=forwarded_headers)
def generate():
try:
for chunk in resp.iter_content(1024 * 16):
if chunk:
yield chunk
finally:
resp.close()
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/')
def index():
return send_from_directory(app.static_folder, 'index.html')
@app.route('/api/stream', methods=['POST', 'GET'])
@app.route('/favicon.ico')
def favicon():
return send_from_directory(app.static_folder, 'favicon.ico')
@app.route('/api/stream', methods=['POST', 'GET', 'HEAD'])
def stream_video():
# Note: <video> tags perform GET. To support your POST requirement,
# we handle the URL via JSON post or URL params.
debug_param = os.getenv('STREAM_DEBUG', '').strip().lower()
debug_enabled = debug_param in ('1', 'true', 'yes', 'on')
cookie_param = os.getenv('STREAM_FORWARD_COOKIES', '').strip().lower()
forward_cookies = cookie_param in ('1', 'true', 'yes', 'on')
def dbg(message):
if debug_enabled:
app.logger.info("[stream_video] %s", message)
video_url = ""
if request.method == 'POST':
video_url = request.json.get('url')
@@ -118,62 +177,405 @@ def stream_video():
if not video_url:
return jsonify({"error": "No URL provided"}), 400
def generate():
try:
# Configure yt-dlp options
ydl_opts = {
'format': 'best[ext=mp4]/best[vcodec^=avc1]/best[vcodec^=vp]/best',
'quiet': True,
'no_warnings': True,
'socket_timeout': 30,
'retries': 3,
'fragment_retries': 3,
'http_headers': {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
'skip_unavailable_fragments': True
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# Extract the info
info = ydl.extract_info(video_url, download=False)
# Try to get the URL from the info dict (works for progressive downloads)
stream_url = info.get('url')
format_id = info.get('format_id')
# If no direct URL, try to get it from formats
if not stream_url and 'formats' in info:
# Find the best format that has a URL
for fmt in info['formats']:
if fmt.get('url'):
stream_url = fmt.get('url')
break
if not stream_url:
yield b"Error: Could not extract stream URL"
return
# Prepare headers for the stream request
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
# Add any cookies or authentication headers from yt-dlp
if 'http_headers' in info:
headers.update(info['http_headers'])
# Stream the video from the extracted URL
resp = session.get(stream_url, headers=headers, stream=True, timeout=30, allow_redirects=True)
resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=1024 * 16):
dbg(f"method={request.method} url={video_url}")
def is_hls(url):
return '.m3u8' in urllib.parse.urlparse(url).path
def is_dash(url):
return urllib.parse.urlparse(url).path.lower().endswith('.mpd')
def guess_content_type(url):
path = urllib.parse.urlparse(url).path.lower()
if path.endswith('.m3u8'):
return 'application/vnd.apple.mpegurl'
if path.endswith('.mpd'):
return 'application/dash+xml'
if path.endswith('.mp4') or path.endswith('.m4v') or path.endswith('.m4s'):
return 'video/mp4'
if path.endswith('.webm'):
return 'video/webm'
if path.endswith('.ts'):
return 'video/mp2t'
if path.endswith('.mov'):
return 'video/quicktime'
if path.endswith('.m4a'):
return 'audio/mp4'
if path.endswith('.mp3'):
return 'audio/mpeg'
if path.endswith('.ogg') or path.endswith('.oga'):
return 'audio/ogg'
return None
def is_direct_media(url):
path = urllib.parse.urlparse(url).path.lower()
return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov'))
def looks_like_m3u8_bytes(chunk):
if not chunk:
return False
sample = chunk.lstrip(b'\xef\xbb\xbf')
return b'#EXTM3U' in sample[:1024]
def looks_like_mp4_bytes(chunk):
if not chunk or len(chunk) < 8:
return False
return chunk[4:8] == b'ftyp'
def build_upstream_headers(referer):
headers = {
'User-Agent': request.headers.get('User-Agent'),
'Accept': request.headers.get('Accept'),
'Accept-Language': request.headers.get('Accept-Language'),
'Accept-Encoding': request.headers.get('Accept-Encoding'),
'Referer': referer,
'Origin': referer,
}
# Pass through fetch metadata and client hints when present
for key in (
'Sec-Fetch-Mode', 'Sec-Fetch-Site', 'Sec-Fetch-Dest', 'Sec-Fetch-User',
'Sec-CH-UA', 'Sec-CH-UA-Mobile', 'Sec-CH-UA-Platform', 'DNT'
):
if key in request.headers:
headers[key] = request.headers[key]
if forward_cookies and 'Cookie' in request.headers:
headers['Cookie'] = request.headers['Cookie']
dbg("forwarding cookies")
# Remove keys with None values
return {k: v for k, v in headers.items() if v}
def build_forwarded_headers(resp, target_url=None, content_type_override=None):
hop_by_hop = {
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailers', 'transfer-encoding', 'upgrade'
}
forwarded_headers = []
response_content_type = None
for name, value in resp.headers.items():
if name.lower() in hop_by_hop:
continue
if name.lower() == 'content-length':
forwarded_headers.append((name, value))
continue
if name.lower() == 'content-type':
response_content_type = value
if name.lower() == 'content-type' and content_type_override:
continue
forwarded_headers.append((name, value))
if not content_type_override:
if not response_content_type or 'application/octet-stream' in response_content_type:
content_type_override = guess_content_type(target_url or resp.url)
if content_type_override:
forwarded_headers.append(('Content-Type', content_type_override))
dbg(f"content_type_override={content_type_override}")
return forwarded_headers
def proxy_response(target_url, content_type_override=None, referer_override=None, upstream_headers=None):
# Extract the base domain to spoof the referer
request_referer = request.args.get('referer')
if referer_override:
referer = referer_override
elif request_referer:
referer = request_referer
else:
parsed_uri = urllib.parse.urlparse(target_url)
referer = f"{parsed_uri.scheme}://{parsed_uri.netloc}/"
dbg(f"proxy_response target={target_url} referer={referer}")
safe_request_headers = build_upstream_headers(referer)
if isinstance(upstream_headers, dict):
for key, value in upstream_headers.items():
if value:
safe_request_headers[key] = value
# Pass through Range headers so the browser can 'sniff' the video
if 'Range' in request.headers:
safe_request_headers['Range'] = request.headers['Range']
resp = session.get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
if debug_enabled:
dbg(f"upstream status={resp.status_code} content_type={resp.headers.get('Content-Type')} content_length={resp.headers.get('Content-Length')}")
content_iter = None
first_chunk = b""
if request.method != 'HEAD':
content_iter = resp.iter_content(chunk_size=1024 * 16)
try:
first_chunk = next(content_iter)
except StopIteration:
first_chunk = b""
if looks_like_m3u8_bytes(first_chunk):
remaining = b"".join(chunk for chunk in content_iter if chunk)
body_bytes = first_chunk + remaining
base_url = resp.url
encoding = resp.encoding
resp.close()
dbg("detected m3u8 by content sniff")
upstream_for_playlist = dict(safe_request_headers)
upstream_for_playlist.pop('Range', None)
return proxy_hls_playlist(
target_url,
referer_hint=referer,
upstream_headers=upstream_for_playlist,
prefetched_body=body_bytes,
prefetched_base_url=base_url,
prefetched_encoding=encoding,
)
forwarded_headers = build_forwarded_headers(
resp,
target_url=target_url,
content_type_override=content_type_override,
)
if request.method == 'HEAD':
resp.close()
return Response("", status=resp.status_code, headers=forwarded_headers)
def generate():
try:
if first_chunk:
yield first_chunk
for chunk in content_iter or resp.iter_content(chunk_size=1024 * 16):
if chunk:
yield chunk
except Exception as e:
yield f"Error: {str(e)}".encode()
finally:
resp.close()
return Response(generate(), mimetype='video/mp4')
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
def decode_playlist_body(body_bytes, encoding=None):
if not body_bytes:
return ""
enc = encoding or "utf-8"
try:
return body_bytes.decode(enc, errors="replace")
except LookupError:
return body_bytes.decode("utf-8", errors="replace")
def rewrite_hls_playlist(body_text, base_url, referer):
def proxied_url(target):
absolute = urljoin(base_url, target)
return f"/api/stream?url={urllib.parse.quote(absolute, safe='')}&referer={urllib.parse.quote(referer, safe='')}"
lines = body_text.splitlines()
rewritten = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
# Rewrite URI attributes inside tags (keys/maps)
if 'URI="' in line:
def repl(match):
uri = match.group(1)
return f'URI="{proxied_url(uri)}"'
import re
line = re.sub(r'URI="([^"]+)"', repl, line)
rewritten.append(line)
continue
rewritten.append(proxied_url(stripped))
body = "\n".join(rewritten)
return Response(body, status=200, content_type='application/vnd.apple.mpegurl')
def proxy_hls_playlist(playlist_url, referer_hint=None, prefetched_body=None, prefetched_base_url=None, prefetched_encoding=None, upstream_headers=None):
dbg(f"proxy_hls_playlist url={playlist_url} referer_hint={referer_hint}")
base_url = prefetched_base_url or playlist_url
body_text = None
if prefetched_body is None:
headers = build_upstream_headers(referer_hint or "")
if isinstance(upstream_headers, dict):
for key, value in upstream_headers.items():
if value:
headers[key] = value
if 'User-Agent' not in headers:
headers['User-Agent'] = 'Mozilla/5.0'
if 'Accept' not in headers:
headers['Accept'] = '*/*'
resp = session.get(playlist_url, headers=headers, stream=True, timeout=30)
base_url = resp.url
if resp.status_code >= 400:
forwarded_headers = build_forwarded_headers(resp, target_url=base_url)
if request.method == 'HEAD':
resp.close()
return Response("", status=resp.status_code, headers=forwarded_headers)
def generate():
try:
for chunk in resp.iter_content(chunk_size=1024 * 16):
if chunk:
yield chunk
finally:
resp.close()
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
if request.method == 'HEAD':
forwarded_headers = build_forwarded_headers(resp, target_url=base_url)
resp.close()
return Response("", status=resp.status_code, headers=forwarded_headers)
content_iter = resp.iter_content(chunk_size=1024 * 16)
try:
first_chunk = next(content_iter)
except StopIteration:
first_chunk = b""
if looks_like_m3u8_bytes(first_chunk):
remaining = b"".join(chunk for chunk in content_iter if chunk)
body_bytes = first_chunk + remaining
body_text = decode_playlist_body(body_bytes, resp.encoding)
resp.close()
else:
content_type_override = None
if looks_like_mp4_bytes(first_chunk):
content_type_override = 'video/mp4'
forwarded_headers = build_forwarded_headers(
resp,
target_url=base_url,
content_type_override=content_type_override,
)
def generate():
try:
if first_chunk:
yield first_chunk
for chunk in content_iter:
if chunk:
yield chunk
finally:
resp.close()
return Response(generate(), status=resp.status_code, headers=forwarded_headers)
else:
body_text = decode_playlist_body(prefetched_body, prefetched_encoding)
if referer_hint:
referer = referer_hint
else:
referer = f"{urllib.parse.urlparse(base_url).scheme}://{urllib.parse.urlparse(base_url).netloc}/"
if request.method == 'HEAD':
return Response("", status=200, content_type='application/vnd.apple.mpegurl')
return rewrite_hls_playlist(body_text, base_url, referer)
if is_hls(video_url):
try:
dbg("detected input as hls")
referer_hint = request.args.get('referer')
if not referer_hint:
parsed = urllib.parse.urlparse(video_url)
referer_hint = f"{parsed.scheme}://{parsed.netloc}/"
return proxy_hls_playlist(video_url, referer_hint)
except Exception as e:
return jsonify({"error": str(e)}), 500
if is_direct_media(video_url):
try:
dbg("detected input as direct media")
return proxy_response(video_url)
except Exception as e:
return jsonify({"error": str(e)}), 500
def extract_referer(headers):
if not isinstance(headers, dict):
return None
return headers.get('Referer') or headers.get('referer')
try:
# Configure yt-dlp options
ydl_opts = {
# Prefer HLS when available to enable chunked streaming in the browser.
'format': 'best[protocol*=m3u8]/best[ext=mp4]/best',
'format_sort': ['res', 'fps', 'vcodec:avc1', 'acodec:aac'],
'quiet': False,
'no_warnings': False,
'http_headers': {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
}
referer_url = ""
if request.method == 'POST':
referer_url = request.json.get('referer')
else:
referer_url = request.args.get('referer')
if len(referer_url) > 0:
ydl_opts['http_headers']["Referer"] = referer_url
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# Extract the info
info = ydl.extract_info(video_url, download=False)
dbg(f"yt_dlp extractor={info.get('extractor')} protocol={info.get('protocol')}")
# Try to get the URL from the info dict (works for progressive downloads)
stream_url = info.get('url')
protocol = info.get('protocol')
selected_format = None
# If no direct URL, try to get it from formats
if 'formats' in info:
if info.get('format_id'):
for fmt in info['formats']:
if fmt.get('format_id') == info.get('format_id'):
selected_format = fmt
break
if not selected_format and stream_url:
for fmt in info['formats']:
if fmt.get('url') == stream_url:
selected_format = fmt
break
if not selected_format:
for fmt in info['formats']:
if fmt.get('url'):
selected_format = fmt
break
if not stream_url and selected_format:
stream_url = selected_format.get('url')
if not stream_url:
return jsonify({"error": "Could not extract stream URL"}), 500
upstream_headers = None
if selected_format and isinstance(selected_format.get('http_headers'), dict):
upstream_headers = selected_format['http_headers']
elif isinstance(info.get('http_headers'), dict):
upstream_headers = info['http_headers']
referer_hint = None
if upstream_headers:
referer_hint = extract_referer(upstream_headers)
if not referer_hint:
parsed = urllib.parse.urlparse(video_url)
referer_hint = f"{parsed.scheme}://{parsed.netloc}/"
dbg(f"resolved stream_url={stream_url} referer_hint={referer_hint}")
if protocol and 'm3u8' in protocol:
dbg("protocol indicates hls")
return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers)
if is_hls(stream_url):
dbg("stream_url is hls")
return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers)
if is_dash(stream_url):
dbg("stream_url is dash")
return proxy_response(stream_url, content_type_override='application/dash+xml', referer_override=referer_hint, upstream_headers=upstream_headers)
dbg("stream_url is direct media")
return proxy_response(stream_url, referer_override=referer_hint, upstream_headers=upstream_headers)
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
# threaded=True allows multiple segments to be proxied at once
app.run(host='0.0.0.0', port=5000, threaded=True)

View File

@@ -1,10 +1,17 @@
version: '3.8'
services:
webserver:
build: ./backend
ports:
- "5000:5000"
hottub-webclient:
image: hottub-webclient:latest
container_name: hottub-webclient
entrypoint: python3
command: ["backend/main.py"]
volumes:
- ./frontend:/frontend
environment:
- PYTHONUNBUFFERED=1
- /path/to/hottub-webclient:/app
restart: unless-stopped
working_dir: /app
healthcheck:
test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/ | grep -q 200"]
interval: 300s
timeout: 5s
retries: 3
start_period: 1s

View File

@@ -1,418 +0,0 @@
// 1. Global State and Constants (Declare these first!)
let currentPage = 1;
const perPage = 12;
const renderedVideoIds = new Set();
let currentQuery = "";
// 2. Observer Definition (Must be defined before initApp uses it)
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadVideos();
}, { threshold: 1.0 });
// 3. Logic Functions
async function InitializeLocalStorage() {
if (!localStorage.getItem('config')) {
localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] }));
}
if (!localStorage.getItem('theme')) {
localStorage.setItem('theme', 'dark');
}
// We always run this to make sure session is fresh
await InitializeServerStatus();
}
async function InitializeServerStatus() {
const config = JSON.parse(localStorage.getItem('config'));
if (!config || !config.servers) return;
const statusPromises = config.servers.map(async (serverObj) => {
const server = Object.keys(serverObj)[0];
try {
const response = await fetch(`/api/status`, {
method: "POST",
body: JSON.stringify({ server: server }),
headers: { "Content-Type": "application/json" },
});
const status = await response.json();
serverObj[server] = status;
} catch (err) {
serverObj[server] = { online: false, channels: [] };
}
});
await Promise.all(statusPromises);
localStorage.setItem('config', JSON.stringify(config));
const firstServerKey = Object.keys(config.servers[0])[0];
const serverData = config.servers[0][firstServerKey];
if (serverData.channels && serverData.channels.length > 0) {
const channel = serverData.channels[0];
let options = {};
if (channel.options) {
channel.options.forEach(element => {
// Ensure the options structure matches your API expectations
if (element.multiSelect) {
options[element.id] = element.options.length > 0 ? [element.options[0]] : [];
} else {
options[element.id] = element.options[0];
}
});
}
const sessionData = {
server: firstServerKey,
channel: channel,
options: options,
};
localStorage.setItem('session', JSON.stringify(sessionData));
}
}
async function loadVideos() {
const session = JSON.parse(localStorage.getItem('session'));
if (!session) return;
// Build the request body
let body = {
channel: session.channel.id,
query: currentQuery,
page: currentPage,
perPage: perPage,
server: session.server
};
// Correct way to loop through the options object
Object.entries(session.options).forEach(([key, value]) => {
if (Array.isArray(value)) {
body[key] = value.map((entry) => entry.id);
} else if (value && value.id) {
body[key] = value.id;
}
});
try {
const response = await fetch('/api/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const videos = await response.json();
renderVideos(videos);
currentPage++;
} catch (err) {
console.error("Failed to load videos:", err);
}
}
function renderVideos(videos) {
const grid = document.getElementById('video-grid');
if (!grid) return;
videos.items.forEach(v => {
if (renderedVideoIds.has(v.id)) return;
const card = document.createElement('div');
card.className = 'video-card';
const durationText = v.duration === 0 ? '' : `${v.duration}s`;
card.innerHTML = `
<img src="${v.thumb}" alt="${v.title}">
<h4>${v.title}</h4>
<p>${v.channel}${durationText}</p>
`;
card.onclick = () => openPlayer(v.url);
grid.appendChild(card);
renderedVideoIds.add(v.id);
});
}
// 4. Initialization (Run this last)
async function initApp() {
// Clear old data if you want a fresh start every refresh
// localStorage.clear();
await InitializeLocalStorage();
applyTheme();
renderMenu();
const sentinel = document.getElementById('sentinel');
if (sentinel) {
observer.observe(sentinel);
}
await loadVideos();
}
function applyTheme() {
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;
}
function openPlayer(url) {
const modal = document.getElementById('video-modal');
const video = document.getElementById('player');
video.src = `/api/stream?url=${encodeURIComponent(url)}`;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closePlayer() {
const modal = document.getElementById('video-modal');
const video = document.getElementById('player');
video.pause();
video.src = '';
modal.style.display = 'none';
document.body.style.overflow = 'auto';
}
function handleSearch(value) {
currentQuery = value || "";
currentPage = 1;
renderedVideoIds.clear();
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
loadVideos();
}
function getConfig() {
return JSON.parse(localStorage.getItem('config')) || { servers: [] };
}
function getSession() {
return JSON.parse(localStorage.getItem('session')) || null;
}
function setSession(nextSession) {
localStorage.setItem('session', JSON.stringify(nextSession));
}
function getServerEntries() {
const config = 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 };
});
}
function buildDefaultOptions(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;
}
function resetAndReload() {
currentPage = 1;
renderedVideoIds.clear();
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
loadVideos();
}
function renderMenu() {
const session = getSession();
const serverEntries = getServerEntries();
const sourceSelect = document.getElementById('source-select');
const channelSelect = document.getElementById('channel-select');
const filtersContainer = document.getElementById('filters-container');
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 nextChannel = channels.length > 0 ? channels[0] : null;
const nextSession = {
server: selectedServerUrl,
channel: nextChannel,
options: nextChannel ? buildDefaultOptions(nextChannel) : {}
};
setSession(nextSession);
renderMenu();
resetAndReload();
};
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
const availableChannels = activeServer && activeServer.data && activeServer.data.channels
? activeServer.data.channels
: [];
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 nextSession = {
server: session.server,
channel: nextChannel,
options: nextChannel ? buildDefaultOptions(nextChannel) : {}
};
setSession(nextSession);
renderMenu();
resetAndReload();
};
renderFilters(filtersContainer, session);
const themeSelect = document.getElementById('theme-select');
if (themeSelect) {
themeSelect.onchange = () => {
const nextTheme = themeSelect.value === 'light' ? 'light' : 'dark';
localStorage.setItem('theme', nextTheme);
applyTheme();
};
}
}
function renderFilters(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 label = document.createElement('label');
label.textContent = optionGroup.title || optionGroup.id;
const select = document.createElement('select');
select.multiple = Boolean(optionGroup.multiSelect);
(optionGroup.options || []).forEach((opt) => {
const option = document.createElement('option');
option.value = opt.id;
option.textContent = opt.title || opt.id;
select.appendChild(option);
});
const currentSelection = session.options ? session.options[optionGroup.id] : null;
if (Array.isArray(currentSelection)) {
const ids = new Set(currentSelection.map((item) => item.id));
Array.from(select.options).forEach((opt) => {
opt.selected = ids.has(opt.value);
});
} else if (currentSelection && currentSelection.id) {
select.value = currentSelection.id;
}
select.onchange = () => {
const nextSession = getSession();
if (!nextSession || !nextSession.channel) return;
const selectedOptions = optionGroup.options || [];
if (optionGroup.multiSelect) {
const selected = Array.from(select.selectedOptions).map((opt) =>
selectedOptions.find((item) => item.id === opt.value)
).filter(Boolean);
nextSession.options[optionGroup.id] = selected;
} else {
const selected = selectedOptions.find((item) => item.id === select.value);
if (selected) {
nextSession.options[optionGroup.id] = selected;
}
}
setSession(nextSession);
resetAndReload();
};
wrapper.appendChild(label);
wrapper.appendChild(select);
container.appendChild(wrapper);
});
}
function closeDrawers() {
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');
}
function toggleDrawer(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 {
closeDrawers();
}
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeDrawers();
});
initApp();

1361
frontend/css/style.css Normal file

File diff suppressed because it is too large Load Diff

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 B

View File

@@ -3,16 +3,24 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hottub</title>
<link rel="stylesheet" href="static/style.css">
<title>Jacuzzi</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lobster&family=Space+Grotesk:wght@500;600&family=Sora:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="static/css/style.css">
</head>
<body>
<header class="top-bar">
<div class="logo">Hottub</div>
<div class="logo">Jacuzzi</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Search videos..." oninput="handleSearch(this.value)">
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search"></button>
</div>
<div class="actions">
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
</button>
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
</button>
@@ -22,6 +30,14 @@
</div>
</header>
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
<div class="favorites-header">
<h3>Favorites</h3>
</div>
<div id="favorites-list" class="favorites-list"></div>
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
</section>
<div class="sidebar-overlay" id="overlay" onclick="closeDrawers()"></div>
<aside id="drawer-menu" class="sidebar">
@@ -62,20 +78,63 @@
<option value="light">Light</option>
</select>
</div>
<div class="setting-item setting-toggle">
<div class="setting-label-row">
<label for="favorites-toggle">Favorites Bar</label>
</div>
<label class="toggle">
<input type="checkbox" id="favorites-toggle">
<span class="toggle-track"></span>
</label>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Sources</h4>
<div class="setting-item">
<label for="source-input">Add Source URL</label>
<div class="input-row">
<input id="source-input" type="text" placeholder="https://example.com">
<button id="add-source-btn" class="btn-secondary" type="button">Add</button>
</div>
</div>
<div id="sources-list" class="sources-list"></div>
</div>
</div>
</aside>
<main id="video-grid" class="grid-container"></main>
<div id="sentinel"></div>
<button id="load-more-btn" class="load-more-btn" title="Load More">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/chevron-down.svg" alt="Load More">
</button>
<div id="video-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closePlayer()">&times;</span>
<video id="player" controls autoplay></video>
<video id="player" controls autoplay playsinline webkit-playsinline></video>
</div>
</div>
<script src="static/app.js"></script>
<div id="info-modal" class="info-modal" aria-hidden="true">
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
<button id="info-close" class="info-close" type="button" aria-label="Close"></button>
<h3 id="info-title">Video Info</h3>
<div id="info-list" class="info-list"></div>
<div id="info-empty" class="info-empty">No additional info available.</div>
</div>
</div>
<div id="error-toast" class="error-toast" role="alert" aria-live="assertive">
<span id="error-toast-text"></span>
<button id="error-toast-close" type="button" aria-label="Close"></button>
</div>
<script src="static/js/state.js"></script>
<script src="static/js/storage.js"></script>
<script src="static/js/player.js"></script>
<script src="static/js/favorites.js"></script>
<script src="static/js/videos.js"></script>
<script src="static/js/ui.js"></script>
<script src="static/js/main.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</body>
</html>

177
frontend/js/favorites.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
};
})();

View File

@@ -1,441 +0,0 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-primary: #0a0e27;
--bg-secondary: #141829;
--bg-tertiary: #1a1f3a;
--text-primary: #e8eaf6;
--text-secondary: #b0b0c0;
--accent: #6366f1;
--accent-hover: #818cf8;
--border: #2a2f4a;
--shadow: rgba(0, 0, 0, 0.4);
}
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
overflow-x: hidden;
font-size: 14px;
}
body.drawer-open {
overflow: hidden;
}
body.theme-light {
--bg-primary: #f6f7fb;
--bg-secondary: #ffffff;
--bg-tertiary: #eef1f7;
--text-primary: #0f172a;
--text-secondary: #475569;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--border: #e2e8f0;
--shadow: rgba(15, 23, 42, 0.12);
}
/* Top Bar */
.top-bar {
height: 64px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
background: var(--bg-secondary);
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1px solid var(--border);
gap: 24px;
}
.logo {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
color: var(--accent);
flex-shrink: 0;
}
.search-container {
flex: 1;
max-width: 500px;
}
.search-container input {
width: 100%;
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: all 0.2s ease;
}
.search-container input:focus {
outline: none;
border-color: var(--accent);
background: var(--bg-tertiary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.search-container input::placeholder {
color: var(--text-secondary);
}
.actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.icon-btn {
width: 48px;
height: 48px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: all 0.2s ease;
font-size: 20px;
}
.icon-btn:hover {
background: var(--bg-tertiary);
color: var(--accent);
}
/* CDN-served icon images (Heroicons) */
.icon-svg {
width: 24px;
height: 24px;
display: block;
filter: invert(100%) saturate(0%);
}
.icon-btn:hover .icon-svg {
filter: none;
}
body.theme-light .icon-svg {
filter: invert(20%) saturate(0%);
}
body.theme-light .icon-btn:hover .icon-svg {
filter: none;
}
/* Hamburger Menu */
.hamburger {
display: flex;
flex-direction: column;
gap: 4px;
}
.hamburger span {
width: 24px;
height: 2px;
background: currentColor;
border-radius: 1px;
transition: all 0.3s ease;
}
.menu-toggle.active .hamburger span:nth-child(1) {
transform: translateY(6px) rotate(45deg);
}
.menu-toggle.active .hamburger span:nth-child(2) {
opacity: 0;
}
.menu-toggle.active .hamburger span:nth-child(3) {
transform: translateY(-6px) rotate(-45deg);
}
/* Sidebar */
.sidebar {
position: fixed;
top: 0;
right: -100%;
width: 320px;
height: 100vh;
background: var(--bg-secondary);
z-index: 1001;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar.open {
right: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h3 {
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 24px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.close-btn:hover {
color: var(--accent);
}
.sidebar-content {
flex: 1;
padding: 12px 0;
overflow-y: auto;
}
.sidebar-section {
padding: 8px 0 4px 0;
border-bottom: 1px solid var(--border);
}
.sidebar-section:last-child {
border-bottom: none;
}
.sidebar-subtitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 8px 24px 4px 24px;
}
.sidebar-item {
display: block;
padding: 12px 24px;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.sidebar-item:hover {
background: var(--bg-tertiary);
border-left-color: var(--accent);
color: var(--accent);
}
.setting-item {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
}
.filters-container .setting-item {
padding-top: 12px;
padding-bottom: 12px;
}
.filters-empty {
padding: 12px 24px 20px 24px;
color: var(--text-secondary);
font-size: 13px;
}
.setting-item label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.setting-item select {
width: 100%;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
font-size: 14px;
}
/* Overlay */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: none;
z-index: 1000;
transition: opacity 0.3s ease;
}
.sidebar-overlay.open {
display: block;
}
/* Grid Container */
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
padding: 24px;
max-width: 100%;
}
@media (max-width: 768px) {
.grid-container {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
}
}
/* Video Card */
.video-card {
cursor: pointer;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
height: 100%;
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px var(--shadow);
}
.video-card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
}
.video-card h4 {
font-size: 14px;
font-weight: 500;
padding: 12px;
line-height: 1.4;
color: var(--text-primary);
margin: 0;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-card p {
font-size: 12px;
color: var(--text-secondary);
padding: 0 12px 12px 12px;
margin: 0;
}
/* Modal */
.modal {
display: none;
position: fixed;
inset: 0;
background: #000;
z-index: 2000;
}
body.theme-light .modal {
background: #0b0b0b;
}
.modal.open {
display: flex;
}
.modal-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
position: relative;
}
.close {
position: absolute;
top: 20px;
right: 20px;
color: #fff;
font-size: 32px;
cursor: pointer;
background: rgba(0, 0, 0, 0.5);
border: none;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2001;
transition: all 0.2s ease;
}
.close:hover {
background: rgba(255, 255, 255, 0.2);
}
video {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100vh;
object-fit: contain;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}