Compare commits
95 Commits
273e7c61f3
...
thumbnail-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b719b2ea | ||
|
|
e2632c962d | ||
|
|
0009574b77 | ||
|
|
d4ed9dce5d | ||
|
|
c54d0889c1 | ||
|
|
b48d7aa161 | ||
|
|
52d7802491 | ||
|
|
d508263946 | ||
|
|
59f7c33ebd | ||
|
|
acfffb3a91 | ||
|
|
0f7e27fd77 | ||
|
|
6631447acc | ||
|
|
0f30480af4 | ||
|
|
a9893068cd | ||
|
|
d7086ead27 | ||
|
|
25dad88ed9 | ||
|
|
b6b17b1f52 | ||
|
|
7207e36510 | ||
|
|
5d739bec12 | ||
|
|
2e6e74b959 | ||
|
|
1d0b435e87 | ||
|
|
138c3224de | ||
|
|
382a637b95 | ||
|
|
455e5cf8d8 | ||
|
|
b931765c06 | ||
|
|
e3eeaacc53 | ||
|
|
48a15759fc | ||
|
|
9fe7511b4d | ||
|
|
17f3161d55 | ||
|
|
5aa95e90d4 | ||
|
|
f5bb33521e | ||
|
|
b9ff61244c | ||
|
|
55828c9726 | ||
|
|
d6865d7c35 | ||
|
|
80476a8a42 | ||
|
|
785b991d01 | ||
|
|
a251b274db | ||
|
|
bec981a262 | ||
|
|
3d4b90b0e1 | ||
|
|
b5b3e13dd0 | ||
|
|
a97f7e7b0f | ||
|
|
95b75bf999 | ||
|
|
f637309699 | ||
|
|
5826ba6b8a | ||
|
|
6b36b97bd1 | ||
|
|
988e11b159 | ||
|
|
1b6fa5f924 | ||
|
|
08c96d5903 | ||
|
|
f8072884b2 | ||
|
|
d73e413352 | ||
|
|
7ba8896405 | ||
|
|
ece4852d4f | ||
|
|
a06a952a28 | ||
|
|
24a2c9f738 | ||
|
|
1f5910a996 | ||
|
|
081493d13f | ||
|
|
81597c3bb2 | ||
|
|
3d81b6aae7 | ||
|
|
d2e1e3adea | ||
|
|
c2872c1883 | ||
|
|
5baca567cb | ||
|
|
7b90c05a29 | ||
|
|
16e42cf318 | ||
|
|
e6d36711b1 | ||
|
|
257e19e9db | ||
|
|
ee1cb511df | ||
|
|
f06a7cd3d0 | ||
|
|
437d42ea3d | ||
|
|
bd07bdef3c | ||
|
|
c2289bf3ec | ||
|
|
1651e5a375 | ||
|
|
df8aaa5f9f | ||
|
|
a9949e452f | ||
|
|
6915da7f85 | ||
|
|
407e3bf9c6 | ||
|
|
313ba70fec | ||
|
|
10ebcc87c0 | ||
|
|
1becdce9ff | ||
|
|
1dc6048d9c | ||
|
|
88997a7527 | ||
|
|
8ebbaeab1c | ||
|
|
395b7e2c6d | ||
|
|
f62cae1508 | ||
|
|
7f5ada3a82 | ||
|
|
da53e6cc88 | ||
|
|
5a2021580d | ||
|
|
f71d8e3ee1 | ||
|
|
c67a5cde16 | ||
|
|
b9f49530e4 | ||
|
|
18cb317730 | ||
|
|
8273bc335d | ||
|
|
af2572090d | ||
|
|
c9a7dc4e82 | ||
|
|
62c7bfd694 | ||
|
|
fc68035a79 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
*/__pycache__
|
||||
*/__pycache__/*
|
||||
.tmp
|
||||
frontend/dist/*
|
||||
.playwright-mcp/*
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"compile-hero.disable-compile-files-on-did-save-code": false
|
||||
}
|
||||
11
Dockerfile
11
Dockerfile
@@ -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,12 @@ 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
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod a+rx /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["python", "backend/main.py"]
|
||||
1167
backend/main.py
1167
backend/main.py
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ certifi==2026.1.4
|
||||
charset-normalizer==3.4.4
|
||||
click==8.3.1
|
||||
Flask==3.1.2
|
||||
Flask-Cors==4.0.0
|
||||
Flask-Cors==6.0.2
|
||||
idna==3.11
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
@@ -12,4 +12,4 @@ MarkupSafe==3.0.3
|
||||
requests==2.32.5
|
||||
urllib3==2.6.3
|
||||
Werkzeug==3.1.5
|
||||
yt-dlp==2026.1.29
|
||||
yt-dlp[default,curl-cffi]
|
||||
|
||||
@@ -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: ["/app/docker-entrypoint.sh", "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
|
||||
84
docker-entrypoint.sh
Executable file
84
docker-entrypoint.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
# Supervise the app and keep yt-dlp fresh.
|
||||
#
|
||||
# yt-dlp breaks frequently against site changes, so we check PyPI once a day
|
||||
# for a newer release. The app imports yt_dlp at startup, so a new version
|
||||
# only takes effect on (re)start -- we therefore restart the app *only* when
|
||||
# an update was actually installed. If the app exits on its own, we propagate
|
||||
# its exit status so Docker's restart policy can handle it.
|
||||
set -e
|
||||
|
||||
CHECK_INTERVAL=86400 # check for a new yt-dlp once per day
|
||||
POLL=60 # how often to check on the app
|
||||
|
||||
# Upgrade yt-dlp only if PyPI has a newer version.
|
||||
# Returns 0 if an update was installed, 1 otherwise (incl. errors / up to date).
|
||||
# Versions are compared numerically: the installed version can be zero-padded
|
||||
# (e.g. 2026.06.09) while PyPI reports the normalized form (e.g. 2026.6.9).
|
||||
check_and_update() {
|
||||
if python3 - <<'PY'
|
||||
import json, sys, urllib.request
|
||||
try:
|
||||
import yt_dlp
|
||||
current = yt_dlp.version.__version__
|
||||
latest = json.load(urllib.request.urlopen(
|
||||
'https://pypi.org/pypi/yt-dlp/json', timeout=30))['info']['version']
|
||||
except Exception as e:
|
||||
print(f"Could not check for yt-dlp updates ({e}); will retry.")
|
||||
sys.exit(1)
|
||||
|
||||
def key(v):
|
||||
try:
|
||||
return tuple(int(p) for p in v.split('.'))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
ck, lk = key(current), key(latest)
|
||||
newer = (lk > ck) if (ck and lk) else (current != latest)
|
||||
if newer:
|
||||
print(f"New yt-dlp available: {current} -> {latest}. Updating...")
|
||||
sys.exit(0)
|
||||
print(f"yt-dlp is up to date ({current}).")
|
||||
sys.exit(1)
|
||||
PY
|
||||
then
|
||||
pip install --upgrade --quiet --root-user-action=ignore "yt-dlp[default,curl-cffi]" \
|
||||
&& return 0
|
||||
echo "yt-dlp update failed; continuing with the installed version."
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Forward termination to the app so the container stops cleanly.
|
||||
trap 'kill "$APP_PID" 2>/dev/null; exit 0' TERM INT
|
||||
|
||||
while true; do
|
||||
# Make sure we launch with the latest available yt-dlp.
|
||||
check_and_update || true
|
||||
|
||||
"$@" &
|
||||
APP_PID=$!
|
||||
|
||||
# Periodically check for updates; restart the app only when one is applied.
|
||||
while true; do
|
||||
waited=0
|
||||
while [ "$waited" -lt "$CHECK_INTERVAL" ] && kill -0 "$APP_PID" 2>/dev/null; do
|
||||
sleep "$POLL"
|
||||
waited=$((waited + POLL))
|
||||
done
|
||||
|
||||
if ! kill -0 "$APP_PID" 2>/dev/null; then
|
||||
wait "$APP_PID"
|
||||
status=$?
|
||||
echo "App exited with status $status"
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
if check_and_update; then
|
||||
echo "Restarting app to apply yt-dlp update..."
|
||||
kill "$APP_PID" 2>/dev/null || true
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
break # outer loop relaunches the app
|
||||
fi
|
||||
done
|
||||
done
|
||||
151
frontend/app.js
151
frontend/app.js
@@ -1,151 +0,0 @@
|
||||
// 1. Global State and Constants (Declare these first!)
|
||||
let currentPage = 1;
|
||||
const perPage = 12;
|
||||
const renderedVideoIds = new Set();
|
||||
|
||||
// 2. Observer Definition (Must be defined before initApp uses it)
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) loadVideos();
|
||||
}, { threshold: 1.0 });
|
||||
|
||||
// 3. Logic Functions
|
||||
async function InitializeLocalStorage() {
|
||||
if (!localStorage.getItem('config')) {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ "https://getfigleaf.com": {} }] }));
|
||||
}
|
||||
// We always run this to make sure session is fresh
|
||||
await InitializeServerStatus();
|
||||
}
|
||||
|
||||
async function InitializeServerStatus() {
|
||||
const config = JSON.parse(localStorage.getItem('config'));
|
||||
if (!config || !config.servers) return;
|
||||
|
||||
const statusPromises = config.servers.map(async (serverObj) => {
|
||||
const server = Object.keys(serverObj)[0];
|
||||
try {
|
||||
const response = await fetch(`/api/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ server: server }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const status = await response.json();
|
||||
serverObj[server] = status;
|
||||
} catch (err) {
|
||||
serverObj[server] = { online: false, channels: [] };
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statusPromises);
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
|
||||
const firstServerKey = Object.keys(config.servers[0])[0];
|
||||
const serverData = config.servers[0][firstServerKey];
|
||||
|
||||
if (serverData.channels && serverData.channels.length > 0) {
|
||||
const channel = serverData.channels[0];
|
||||
let options = {};
|
||||
|
||||
if (channel.options) {
|
||||
channel.options.forEach(element => {
|
||||
// Ensure the options structure matches your API expectations
|
||||
options[element.id] = element.options[0];
|
||||
});
|
||||
}
|
||||
|
||||
const sessionData = {
|
||||
server: firstServerKey,
|
||||
channel: channel,
|
||||
options: options,
|
||||
};
|
||||
|
||||
localStorage.setItem('session', JSON.stringify(sessionData));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVideos() {
|
||||
const session = JSON.parse(localStorage.getItem('session'));
|
||||
if (!session) return;
|
||||
|
||||
// Build the request body
|
||||
let body = {
|
||||
channel: session.channel.id,
|
||||
query: "",
|
||||
page: currentPage,
|
||||
perPage: perPage,
|
||||
server: session.server
|
||||
};
|
||||
|
||||
// Correct way to loop through the options object
|
||||
Object.entries(session.options).forEach(([key, value]) => {
|
||||
body[key] = value.id;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/videos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const videos = await response.json();
|
||||
renderVideos(videos);
|
||||
currentPage++;
|
||||
} catch (err) {
|
||||
console.error("Failed to load videos:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVideos(videos) {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid) return;
|
||||
|
||||
videos.items.forEach(v => {
|
||||
if (renderedVideoIds.has(v.id)) return;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'video-card';
|
||||
const durationText = v.duration === 0 ? '' : ` • ${v.duration}s`;
|
||||
card.innerHTML = `
|
||||
<img src="${v.thumb}" alt="${v.title}">
|
||||
<h4>${v.title}</h4>
|
||||
<p>${v.channel}${durationText}</p>
|
||||
`;
|
||||
card.onclick = () => openPlayer(v.url);
|
||||
grid.appendChild(card);
|
||||
renderedVideoIds.add(v.id);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Initialization (Run this last)
|
||||
async function initApp() {
|
||||
// Clear old data if you want a fresh start every refresh
|
||||
// localStorage.clear();
|
||||
|
||||
await InitializeLocalStorage();
|
||||
|
||||
const sentinel = document.getElementById('sentinel');
|
||||
if (sentinel) {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
await loadVideos();
|
||||
}
|
||||
|
||||
function openPlayer(url) {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
video.src = `/api/stream?url=${encodeURIComponent(url)}`;
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
video.pause();
|
||||
video.src = '';
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
initApp();
|
||||
2629
frontend/css/style.css
Normal file
2629
frontend/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 523 B |
@@ -3,35 +3,218 @@
|
||||
<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=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400&family=Sora:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="scroll-progress" class="scroll-progress" aria-hidden="true"></div>
|
||||
<header class="top-bar">
|
||||
<div class="logo">Hottub</div>
|
||||
<div class="actions">
|
||||
<button onclick="toggleDrawer('settings')">Settings</button>
|
||||
<button onclick="toggleDrawer('menu')" class="menu-btn">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="logo">Jacuzzi</div>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search videos..." oninput="handleSearch(this.value)">
|
||||
<input type="text" id="search-input" placeholder="Search videos...">
|
||||
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
|
||||
</button>
|
||||
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
|
||||
</button>
|
||||
<button class="icon-btn settings-toggle" onclick="toggleDrawer('settings')" title="Settings">
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/wrench.svg" alt="Settings">
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="video-grid" class="grid-container"></main>
|
||||
<div id="sentinel"></div> <div id="drawer-menu" class="drawer"><h3>Menu</h3></div>
|
||||
<div id="drawer-settings" class="drawer"><h3>Settings</h3></div>
|
||||
<div id="overlay" onclick="closeDrawers()"></div>
|
||||
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
|
||||
<div class="favorites-header">
|
||||
<h3>Favorites</h3>
|
||||
<div class="favorites-actions">
|
||||
<select id="favorites-sort" class="favorites-sort" aria-label="Sort favorites"></select>
|
||||
<button id="favorites-browse-btn" class="btn-secondary" type="button" aria-pressed="false">Browse all</button>
|
||||
</div>
|
||||
</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 id="video-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closePlayer()">×</span>
|
||||
<video id="player" controls autoplay></video>
|
||||
<div class="sidebar-overlay" id="overlay" onclick="closeDrawers()"></div>
|
||||
|
||||
<aside id="drawer-menu" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>Menu</h3>
|
||||
<button class="close-btn" onclick="closeDrawers()">✕</button>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Network</h4>
|
||||
<div class="setting-item">
|
||||
<label for="source-select">Source</label>
|
||||
<select id="source-select"></select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="channel-select">Channel</label>
|
||||
<select id="channel-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Filters</h4>
|
||||
<div id="filters-container" class="filters-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside id="drawer-settings" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>Settings</h3>
|
||||
<button class="close-btn" onclick="closeDrawers()">✕</button>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="setting-item">
|
||||
<label>Theme</label>
|
||||
<select id="theme-select">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label>Preferred Quality</label>
|
||||
<select id="quality-select">
|
||||
<option value="auto">Best Available</option>
|
||||
<option value="2160">2160p</option>
|
||||
<option value="1440">1440p</option>
|
||||
<option value="1080">1080p</option>
|
||||
<option value="720">720p</option>
|
||||
<option value="480">480p</option>
|
||||
<option value="360">360p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="density-select">Grid Density</label>
|
||||
<select id="density-select">
|
||||
<option value="comfortable">Comfortable</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="card-size-range">Card Size</label>
|
||||
<input type="range" id="card-size-range" min="0.7" max="1.5" step="0.1" value="1">
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="text-size-range">Text Size</label>
|
||||
<input type="range" id="text-size-range" min="0.8" max="1.4" step="0.1" value="1">
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="feed-end-select">Reels: On Video End</label>
|
||||
<select id="feed-end-select">
|
||||
<option value="loop">Loop</option>
|
||||
<option value="scroll">Scroll to Next</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 class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
|
||||
<div class="setting-item">
|
||||
<label for="import-favorites-btn">Import Favorites</label>
|
||||
<input id="import-favorites-file" type="file" accept=".sqlite3,.sqlite,.db" hidden>
|
||||
<button id="import-favorites-btn" class="btn-secondary" type="button">Choose backup file…</button>
|
||||
<p id="import-favorites-status" class="setting-note" role="status" aria-live="polite">
|
||||
Reads the favorites out of an exported Hot Tub database. The file stays on this device.
|
||||
</p>
|
||||
</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="custom-player" class="custom-player" aria-hidden="true"></div>
|
||||
|
||||
<button id="mode-toggle-btn" class="mode-toggle-btn" type="button" title="Switch to Reels view" aria-pressed="false">
|
||||
<img class="icon-svg" id="mode-toggle-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg" alt="Switch to Reels view">
|
||||
</button>
|
||||
|
||||
<div id="feed-view" class="feed-view" aria-hidden="true">
|
||||
<button id="feed-mute-btn" class="feed-mute-btn" type="button" title="Toggle mute">
|
||||
<img class="icon-svg" id="feed-mute-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg" alt="Unmute">
|
||||
</button>
|
||||
<div id="feed-scroll" class="feed-scroll">
|
||||
<div id="feed-top-spacer" class="feed-top-spacer"></div>
|
||||
<div id="feed-sentinel" class="feed-sentinel"></div>
|
||||
</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>
|
||||
|
||||
<div id="update-banner" class="update-banner" role="status" aria-live="polite">
|
||||
<span>A new version is available.</span>
|
||||
<button id="update-banner-btn" type="button">Refresh</button>
|
||||
</div>
|
||||
|
||||
<button id="back-to-top" class="back-to-top" type="button" title="Back to top" aria-label="Back to top">↑</button>
|
||||
|
||||
<div id="command-palette" class="command-palette" aria-hidden="true">
|
||||
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<input id="cmdk-input" class="cmdk-input" type="text" placeholder="Type a command or search… (⌘K)" autocomplete="off" spellcheck="false">
|
||||
<div id="cmdk-list" class="cmdk-list" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="static/js/state.js"></script>
|
||||
<script src="static/js/marquee.js"></script>
|
||||
<script src="static/js/storage.js"></script>
|
||||
<script src="static/js/customPlayer.js"></script>
|
||||
<script src="static/js/player.js"></script>
|
||||
<script src="static/js/favorites.js"></script>
|
||||
<script src="static/js/favoritesView.js"></script>
|
||||
<script src="static/js/sqlite.js"></script>
|
||||
<script src="static/js/hottubBackup.js"></script>
|
||||
<script src="static/js/videos.js"></script>
|
||||
<script src="static/js/feed.js"></script>
|
||||
<script src="static/js/ui.js"></script>
|
||||
<script src="static/js/version.js"></script>
|
||||
<script src="static/js/enhance.js"></script>
|
||||
<script src="static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
338
frontend/js/customPlayer.js
Normal file
338
frontend/js/customPlayer.js
Normal file
@@ -0,0 +1,338 @@
|
||||
window.App = window.App || {};
|
||||
App.customPlayer = App.customPlayer || {};
|
||||
|
||||
// Shared building blocks for the custom video player HUD, reused by the
|
||||
// standalone fullscreen player (player.js) and the reels feed (feed.js) so
|
||||
// both present identical skip/format/gesture/PiP behavior.
|
||||
(function() {
|
||||
// -----------------------------------------------------------------
|
||||
// Skip escalation: tapping skip-forward/back repeatedly ramps the skip
|
||||
// duration up (5 -> 10 -> 20 -> 40 -> 60s), independently per direction,
|
||||
// so mashing forward doesn't also ramp up backward. A tap lands as
|
||||
// "rapid" (and escalates further) only if it arrives within
|
||||
// RAPID_WINDOW_MS of the previous same-direction tap. After GRACE_MS of
|
||||
// silence the level steps back down by one every DECAY_STEP_MS.
|
||||
// -----------------------------------------------------------------
|
||||
const LEVELS = [5, 10, 20, 40, 60];
|
||||
const RAPID_WINDOW_MS = 1500;
|
||||
const GRACE_MS = 2000;
|
||||
const DECAY_STEP_MS = 1000;
|
||||
|
||||
App.customPlayer.createSkipEscalator = function() {
|
||||
const dirs = {
|
||||
back: { levelIndex: 0, lastTapAt: 0, decayTimer: null },
|
||||
forward: { levelIndex: 0, lastTapAt: 0, decayTimer: null }
|
||||
};
|
||||
|
||||
const clearDecay = (d) => {
|
||||
if (d.decayTimer) {
|
||||
clearTimeout(d.decayTimer);
|
||||
d.decayTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDecay = (d) => {
|
||||
clearDecay(d);
|
||||
d.decayTimer = setTimeout(function tick() {
|
||||
d.decayTimer = null;
|
||||
if (d.levelIndex > 0) {
|
||||
d.levelIndex -= 1;
|
||||
d.decayTimer = setTimeout(tick, DECAY_STEP_MS);
|
||||
}
|
||||
}, GRACE_MS);
|
||||
};
|
||||
|
||||
// Advances the state for a tap in `direction` and returns the number
|
||||
// of seconds that tap should skip.
|
||||
const trigger = function(direction) {
|
||||
const d = dirs[direction];
|
||||
if (!d) return LEVELS[0];
|
||||
const now = Date.now();
|
||||
if (now - d.lastTapAt <= RAPID_WINDOW_MS && d.levelIndex < LEVELS.length - 1) {
|
||||
d.levelIndex += 1;
|
||||
}
|
||||
d.lastTapAt = now;
|
||||
scheduleDecay(d);
|
||||
return LEVELS[d.levelIndex];
|
||||
};
|
||||
|
||||
const destroy = function() {
|
||||
clearDecay(dirs.back);
|
||||
clearDecay(dirs.forward);
|
||||
};
|
||||
|
||||
return { trigger, destroy };
|
||||
};
|
||||
|
||||
// Applies one skip tap to `video` using `escalator`, clamped to the
|
||||
// media's bounds. Returns the number of seconds skipped (for HUD flash
|
||||
// feedback), or 0 if the video has no usable duration yet.
|
||||
App.customPlayer.skip = function(video, direction, escalator) {
|
||||
if (!video) return 0;
|
||||
const amount = escalator.trigger(direction);
|
||||
const delta = direction === 'forward' ? amount : -amount;
|
||||
let target = video.currentTime + delta;
|
||||
if (isFinite(video.duration) && video.duration > 0) {
|
||||
target = Math.min(target, Math.max(0, video.duration - 0.1));
|
||||
}
|
||||
video.currentTime = Math.max(0, target);
|
||||
return amount;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Format switching: builds a labeled, ranked list of a video's playable
|
||||
// formats (quality/codec/container variants) for a picker menu. Reuses
|
||||
// App.videos.rankFormats (same ranking as automatic selection) with no
|
||||
// preferred-height ceiling, since a manual pick overrides that entirely.
|
||||
// -----------------------------------------------------------------
|
||||
App.customPlayer.formatLabel = function(fmt) {
|
||||
if (!fmt) return 'Auto';
|
||||
const parts = [];
|
||||
const height = App.videos.coerceNumber(fmt.height);
|
||||
const fps = App.videos.coerceNumber(fmt.fps);
|
||||
if (height) parts.push(`${height}p${fps > 30 ? Math.round(fps) : ''}`);
|
||||
// The container (mp4/webm) tells the viewer nothing useful about a
|
||||
// quality choice. The extractor's own note does -- but only when it
|
||||
// says something the quality doesn't already ("HDR", "source", a
|
||||
// codec), so drop one that merely restates it ("1080p", "1080p60").
|
||||
const note = (fmt.format_note || '').toString().trim();
|
||||
if (note && note.toLowerCase() !== (parts[0] || '').toLowerCase()) parts.push(note);
|
||||
if (!parts.length) {
|
||||
const vcodec = (fmt.vcodec || '').toString();
|
||||
parts.push(vcodec && vcodec !== 'none' ? vcodec : 'Auto');
|
||||
}
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
// Returns [] when there's nothing to pick from (no formats, or only one
|
||||
// usable variant) so callers know to hide the format-switch button.
|
||||
App.customPlayer.buildFormatOptions = function(video) {
|
||||
const meta = video && (video.meta || video);
|
||||
if (!meta || !Array.isArray(meta.formats) || meta.formats.length < 2) return [];
|
||||
const ranked = App.videos.rankFormats(meta.formats, null);
|
||||
if (ranked.length < 2) return [];
|
||||
return ranked.map((fmt) => ({ fmt, label: App.customPlayer.formatLabel(fmt) }));
|
||||
};
|
||||
|
||||
// Wires a format-switch button + its dropdown menu against `videoData`,
|
||||
// calling onSelect(fmt) when the user picks one. Hides the button when
|
||||
// there's nothing to pick from. Shared by the standalone player and the
|
||||
// reels feed so both present an identical menu. Returns a destroy() fn.
|
||||
// `options.getCurrentUrl` (optional) returns the URL the player is actually
|
||||
// feeding to the media element right now; the matching entry is marked
|
||||
// active every time the menu opens. Reading it live rather than at bind
|
||||
// time keeps the mark honest when playback moved on by itself -- an
|
||||
// automatic pick, a fallback to the next candidate after a failure, or a
|
||||
// re-resolve -- not just when the viewer chose from this menu.
|
||||
App.customPlayer.bindFormatMenu = function(btn, menu, videoData, onSelect, opts) {
|
||||
if (!btn || !menu) return function destroy() {};
|
||||
const getCurrentUrl = (opts && opts.getCurrentUrl) || null;
|
||||
const options = App.customPlayer.buildFormatOptions(videoData);
|
||||
if (!options.length) {
|
||||
btn.hidden = true;
|
||||
menu.hidden = true;
|
||||
menu.innerHTML = '';
|
||||
return function destroy() {};
|
||||
}
|
||||
btn.hidden = false;
|
||||
menu.hidden = true;
|
||||
menu.innerHTML = options.map((opt, i) =>
|
||||
`<button class="cp-format-option" type="button" role="menuitemradio" aria-checked="false" data-index="${i}">${opt.label}</button>`
|
||||
).join('');
|
||||
const markActive = (activeBtn) => {
|
||||
menu.querySelectorAll('.cp-format-option').forEach((b) => {
|
||||
const isActive = b === activeBtn;
|
||||
b.classList.toggle('is-active', isActive);
|
||||
b.setAttribute('aria-checked', isActive ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
const syncActive = () => {
|
||||
const current = getCurrentUrl ? (getCurrentUrl() || '') : '';
|
||||
let match = null;
|
||||
if (current) {
|
||||
options.forEach((opt, i) => {
|
||||
if (!match && opt.fmt && opt.fmt.url === current) {
|
||||
match = menu.querySelector(`.cp-format-option[data-index="${i}"]`);
|
||||
}
|
||||
});
|
||||
}
|
||||
markActive(match);
|
||||
};
|
||||
const cleanups = [];
|
||||
menu.querySelectorAll('.cp-format-option').forEach((optBtn) => {
|
||||
const onClick = (event) => {
|
||||
event.stopPropagation();
|
||||
const idx = parseInt(optBtn.dataset.index, 10);
|
||||
const opt = options[idx];
|
||||
menu.hidden = true;
|
||||
markActive(optBtn);
|
||||
if (opt) onSelect(opt.fmt);
|
||||
};
|
||||
optBtn.addEventListener('click', onClick);
|
||||
cleanups.push(() => optBtn.removeEventListener('click', onClick));
|
||||
});
|
||||
const onBtnClick = (event) => {
|
||||
event.stopPropagation();
|
||||
if (menu.hidden) {
|
||||
syncActive();
|
||||
// Opening the menu restarts the HUD's idle countdown: the menu
|
||||
// hides with the HUD, and the viewer needs the full window to
|
||||
// read the list, not whatever was left of the previous one.
|
||||
if (opts && opts.onOpen) opts.onOpen();
|
||||
}
|
||||
menu.hidden = !menu.hidden;
|
||||
};
|
||||
btn.addEventListener('click', onBtnClick);
|
||||
cleanups.push(() => btn.removeEventListener('click', onBtnClick));
|
||||
return function destroy() {
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Picture-in-Picture: a manual toggle plus best-effort auto-PiP when the
|
||||
// tab/app is backgrounded while a video is playing (Safari does this
|
||||
// natively for inline video; Chrome/Android need an explicit call).
|
||||
// -----------------------------------------------------------------
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return !!(document.pictureInPictureEnabled);
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
|
||||
try {
|
||||
if (document.pictureInPictureElement === video) {
|
||||
await document.exitPictureInPicture();
|
||||
} else {
|
||||
await video.requestPictureInPicture();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
App.customPlayer.bindAutoPiP = function(video) {
|
||||
if (!video) return function destroy() {};
|
||||
const trigger = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
|
||||
if (document.pictureInPictureElement) return;
|
||||
if (video.paused || video.ended) return;
|
||||
video.requestPictureInPicture().catch(() => {});
|
||||
};
|
||||
document.addEventListener('visibilitychange', trigger);
|
||||
window.addEventListener('pagehide', trigger);
|
||||
return function destroy() {
|
||||
document.removeEventListener('visibilitychange', trigger);
|
||||
window.removeEventListener('pagehide', trigger);
|
||||
};
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Unified pointer-gesture recognizer for the video surface: a single
|
||||
// pointer stream is classified into exactly one of tap / double-tap /
|
||||
// volume-drag (right column) / dismiss-drag (top strip), so the gestures
|
||||
// never fight each other over the same touch.
|
||||
// -----------------------------------------------------------------
|
||||
App.customPlayer.attachGestures = function(surfaceEl, handlers) {
|
||||
handlers = handlers || {};
|
||||
const TAP_MAX_MOVE = 10;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const DISMISS_ZONE_FRACTION = 0.2; // top strip that owns swipe-to-dismiss
|
||||
const VOLUME_ZONE_START = 0.66; // right column that owns volume swipe
|
||||
const SKIP_ZONE_LEFT_END = 0.34;
|
||||
const SKIP_ZONE_RIGHT_START = 0.66;
|
||||
|
||||
let pointerId = null;
|
||||
let startX = 0, startY = 0, lastY = 0;
|
||||
let moved = false;
|
||||
let mode = null; // 'dismiss-candidate' | 'dismiss' | 'volume-candidate' | 'volume' | 'ignore'
|
||||
let volumeStartValue = 0;
|
||||
let lastTapTime = 0;
|
||||
let lastTapSide = null;
|
||||
|
||||
const rectOf = () => surfaceEl.getBoundingClientRect();
|
||||
|
||||
const ignoreSelector = handlers.ignoreSelector || 'button, input, a, .cp-format-menu';
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
if (pointerId != null || e.button != null && e.button !== 0) return;
|
||||
if (e.target && e.target.closest && e.target.closest(ignoreSelector)) return;
|
||||
pointerId = e.pointerId;
|
||||
startX = e.clientX;
|
||||
startY = lastY = e.clientY;
|
||||
moved = false;
|
||||
mode = null;
|
||||
const rect = rectOf();
|
||||
const relX = rect.width ? (e.clientX - rect.left) / rect.width : 0;
|
||||
const relY = rect.height ? (e.clientY - rect.top) / rect.height : 0;
|
||||
if (relY <= DISMISS_ZONE_FRACTION && handlers.onDismissDrag) {
|
||||
mode = 'dismiss-candidate';
|
||||
} else if (relX >= VOLUME_ZONE_START && handlers.onVolumeDrag) {
|
||||
mode = 'volume-candidate';
|
||||
volumeStartValue = handlers.onVolumeStart ? handlers.onVolumeStart() : 0;
|
||||
}
|
||||
try { surfaceEl.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
|
||||
};
|
||||
|
||||
const onPointerMove = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!moved && Math.hypot(dx, dy) > TAP_MAX_MOVE) moved = true;
|
||||
if (moved) {
|
||||
if (mode === 'dismiss-candidate') mode = 'dismiss';
|
||||
else if (mode === 'volume-candidate') mode = 'volume';
|
||||
else if (mode === null) mode = 'ignore';
|
||||
|
||||
if (mode === 'dismiss') {
|
||||
handlers.onDismissDrag(dy, rectOf());
|
||||
} else if (mode === 'volume') {
|
||||
const rect = rectOf();
|
||||
const deltaRatio = rect.height ? (startY - e.clientY) / rect.height : 0;
|
||||
handlers.onVolumeDrag(Math.min(1, Math.max(0, volumeStartValue + deltaRatio)));
|
||||
}
|
||||
}
|
||||
lastY = e.clientY;
|
||||
};
|
||||
|
||||
const endGesture = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
try { surfaceEl.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
|
||||
if (moved) {
|
||||
if (mode === 'dismiss' && handlers.onDismissEnd) handlers.onDismissEnd(lastY - startY);
|
||||
else if (mode === 'volume' && handlers.onVolumeEnd) handlers.onVolumeEnd();
|
||||
} else {
|
||||
if (handlers.onSingleTap) handlers.onSingleTap();
|
||||
const rect = rectOf();
|
||||
const relX = rect.width ? (startX - rect.left) / rect.width : 0.5;
|
||||
const side = relX <= SKIP_ZONE_LEFT_END ? 'left' : (relX >= SKIP_ZONE_RIGHT_START ? 'right' : 'center');
|
||||
const now = Date.now();
|
||||
if (side !== 'center' && lastTapSide === side && (now - lastTapTime) <= DOUBLE_TAP_MS) {
|
||||
lastTapTime = 0;
|
||||
lastTapSide = null;
|
||||
if (side === 'left' && handlers.onDoubleTapLeft) handlers.onDoubleTapLeft();
|
||||
if (side === 'right' && handlers.onDoubleTapRight) handlers.onDoubleTapRight();
|
||||
} else {
|
||||
lastTapTime = now;
|
||||
lastTapSide = side;
|
||||
}
|
||||
}
|
||||
pointerId = null;
|
||||
mode = null;
|
||||
};
|
||||
|
||||
surfaceEl.addEventListener('pointerdown', onPointerDown);
|
||||
surfaceEl.addEventListener('pointermove', onPointerMove);
|
||||
surfaceEl.addEventListener('pointerup', endGesture);
|
||||
surfaceEl.addEventListener('pointercancel', endGesture);
|
||||
|
||||
return function destroy() {
|
||||
surfaceEl.removeEventListener('pointerdown', onPointerDown);
|
||||
surfaceEl.removeEventListener('pointermove', onPointerMove);
|
||||
surfaceEl.removeEventListener('pointerup', endGesture);
|
||||
surfaceEl.removeEventListener('pointercancel', endGesture);
|
||||
};
|
||||
};
|
||||
})();
|
||||
272
frontend/js/enhance.js
Normal file
272
frontend/js/enhance.js
Normal file
@@ -0,0 +1,272 @@
|
||||
window.App = window.App || {};
|
||||
App.enhance = App.enhance || {};
|
||||
|
||||
// Progressive UI enhancements layered on top of the core app. Everything here
|
||||
// is non-essential polish: cursor-tracking card spotlight, a scroll-progress
|
||||
// bar, a back-to-top button, a ⌘K command palette, and hover video previews.
|
||||
// None of it is required for the app to function, so each piece fails soft.
|
||||
(function() {
|
||||
const fineHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
const reduceMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// ---- Cursor-tracking spotlight border on grid cards ----------------------
|
||||
// One delegated listener keeps per-card vars (--mx/--my) updated; the brass
|
||||
// border gradient that reads them lives in CSS (.video-card::after).
|
||||
function initSpotlight() {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid || !fineHover) return;
|
||||
grid.addEventListener('pointermove', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
if (!card) return;
|
||||
const r = card.getBoundingClientRect();
|
||||
card.style.setProperty('--mx', (e.clientX - r.left) + 'px');
|
||||
card.style.setProperty('--my', (e.clientY - r.top) + 'px');
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// ---- Scroll progress bar + back-to-top FAB -------------------------------
|
||||
function initScrollAffordances() {
|
||||
const bar = document.getElementById('scroll-progress');
|
||||
const fab = document.getElementById('back-to-top');
|
||||
let ticking = false;
|
||||
const onScroll = () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
ticking = false;
|
||||
const doc = document.documentElement;
|
||||
const max = doc.scrollHeight - window.innerHeight;
|
||||
const pct = max > 0 ? (window.scrollY / max) * 100 : 0;
|
||||
if (bar) bar.style.width = pct.toFixed(2) + '%';
|
||||
if (fab) fab.classList.toggle('is-visible', window.scrollY > 700);
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
if (fab) {
|
||||
fab.addEventListener('click', () => {
|
||||
window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Hover video preview --------------------------------------------------
|
||||
// After a short dwell over a card, play a muted inline clip in place of the
|
||||
// poster — but only if the video's real formats are already resolved (the
|
||||
// grid resolves them lazily on hover/scroll anyway), so we never block or
|
||||
// hammer the backend just to preview.
|
||||
function initHoverPreview() {
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (!grid || !fineHover) return;
|
||||
let dwellTimer = null;
|
||||
let activeCard = null;
|
||||
|
||||
const clearPreview = () => {
|
||||
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
||||
if (activeCard) {
|
||||
const vid = activeCard.querySelector('.card-preview');
|
||||
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
||||
activeCard.classList.remove('is-previewing');
|
||||
activeCard = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPreview = (card) => {
|
||||
if (!App.videos || typeof App.videos.getVideoForCard !== 'function') return;
|
||||
const v = App.videos.getVideoForCard(card);
|
||||
if (!v) return;
|
||||
const meta = v.meta;
|
||||
const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
|
||||
if (!ready) {
|
||||
// Not resolved yet: kick it off so the *next* hover can preview.
|
||||
if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v);
|
||||
return;
|
||||
}
|
||||
let url = '';
|
||||
try { url = App.videos.buildStreamUrl(v); } catch (e) { return; }
|
||||
if (!url) return;
|
||||
const img = card.querySelector('img');
|
||||
const vid = document.createElement('video');
|
||||
vid.className = 'card-preview';
|
||||
vid.muted = true;
|
||||
vid.loop = true;
|
||||
vid.playsInline = true;
|
||||
vid.setAttribute('playsinline', '');
|
||||
vid.setAttribute('webkit-playsinline', '');
|
||||
vid.preload = 'auto';
|
||||
if (img) vid.style.height = img.getBoundingClientRect().height + 'px';
|
||||
vid.src = url;
|
||||
vid.addEventListener('error', () => { if (vid.isConnected) vid.remove(); }, { once: true });
|
||||
card.appendChild(vid);
|
||||
card.classList.add('is-previewing');
|
||||
const p = vid.play();
|
||||
if (p && p.catch) p.catch(() => {});
|
||||
};
|
||||
|
||||
grid.addEventListener('pointerover', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
if (!card || card === activeCard) return;
|
||||
clearPreview();
|
||||
activeCard = card;
|
||||
dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === card) startPreview(card); }, 600);
|
||||
});
|
||||
grid.addEventListener('pointerout', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
if (!card) return;
|
||||
const to = e.relatedTarget;
|
||||
if (to && card.contains(to)) return; // still inside the same card
|
||||
if (card === activeCard) clearPreview();
|
||||
});
|
||||
window.addEventListener('scroll', clearPreview, { passive: true });
|
||||
}
|
||||
|
||||
// ---- Command palette (⌘K / Ctrl+K) ---------------------------------------
|
||||
function initCommandPalette() {
|
||||
const palette = document.getElementById('command-palette');
|
||||
const input = document.getElementById('cmdk-input');
|
||||
const list = document.getElementById('cmdk-list');
|
||||
if (!palette || !input || !list) return;
|
||||
|
||||
let actions = [];
|
||||
let filtered = [];
|
||||
let activeIndex = 0;
|
||||
|
||||
const fireChange = (el) => el && el.dispatchEvent(new Event('change'));
|
||||
|
||||
const buildActions = () => {
|
||||
const out = [];
|
||||
out.push({ label: 'Search videos', hint: 'Focus the search box', run: () => {
|
||||
const s = document.getElementById('search-input'); if (s) { s.focus(); s.select(); }
|
||||
}});
|
||||
const theme = (localStorage.getItem('theme') || 'dark');
|
||||
out.push({ label: `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`, hint: 'Appearance', run: () => {
|
||||
localStorage.setItem('theme', theme === 'light' ? 'dark' : 'light');
|
||||
if (App.ui && App.ui.applyTheme) App.ui.applyTheme();
|
||||
}});
|
||||
const density = (App.storage && App.storage.getDensity) ? App.storage.getDensity() : 'comfortable';
|
||||
out.push({ label: `Grid density: ${density === 'compact' ? 'comfortable' : 'compact'}`, hint: 'Layout', run: () => {
|
||||
if (!App.storage) return;
|
||||
App.storage.setDensity(density === 'compact' ? 'comfortable' : 'compact');
|
||||
if (App.ui && App.ui.applyDensity) App.ui.applyDensity();
|
||||
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
|
||||
}});
|
||||
out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } });
|
||||
|
||||
// The favorites bar carries these controls, but it can be switched
|
||||
// off in settings -- in which case the palette is the way in.
|
||||
if (App.favoritesView && App.favorites) {
|
||||
const browsing = App.favoritesView.isActive();
|
||||
out.push({
|
||||
label: browsing ? 'Back to videos' : 'Browse favorites',
|
||||
hint: browsing ? 'Leave the favorites grid' : 'All favorites as a grid',
|
||||
run: () => App.favoritesView.toggle()
|
||||
});
|
||||
const currentSort = App.favorites.getSort();
|
||||
App.favorites.SORTS.forEach((sort) => {
|
||||
if (sort.id === currentSort) return;
|
||||
out.push({
|
||||
label: `Sort favorites: ${sort.label}`,
|
||||
hint: 'Favorites',
|
||||
run: () => App.favoritesView.applySort(sort.id)
|
||||
});
|
||||
});
|
||||
}
|
||||
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
|
||||
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
|
||||
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
||||
|
||||
const sourceSelect = document.getElementById('source-select');
|
||||
if (sourceSelect) {
|
||||
Array.from(sourceSelect.options).forEach((opt) => {
|
||||
if (opt.value === sourceSelect.value) return;
|
||||
out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } });
|
||||
});
|
||||
}
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
if (channelSelect) {
|
||||
Array.from(channelSelect.options).forEach((opt) => {
|
||||
if (opt.value === channelSelect.value) return;
|
||||
out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
list.innerHTML = '';
|
||||
filtered.forEach((a, i) => {
|
||||
const li = document.createElement('button');
|
||||
li.type = 'button';
|
||||
li.className = 'cmdk-item' + (i === activeIndex ? ' is-active' : '');
|
||||
li.innerHTML = `<span class="cmdk-label"></span><span class="cmdk-hint"></span>`;
|
||||
li.querySelector('.cmdk-label').textContent = a.label;
|
||||
li.querySelector('.cmdk-hint').textContent = a.hint || '';
|
||||
li.addEventListener('click', () => choose(i));
|
||||
li.addEventListener('pointermove', () => { if (activeIndex !== i) { activeIndex = i; render(); } });
|
||||
list.appendChild(li);
|
||||
});
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
filtered = q
|
||||
? actions.filter((a) => (a.label + ' ' + (a.hint || '')).toLowerCase().includes(q))
|
||||
: actions.slice();
|
||||
activeIndex = 0;
|
||||
render();
|
||||
};
|
||||
|
||||
const choose = (i) => {
|
||||
const a = filtered[i];
|
||||
close();
|
||||
if (a && a.run) a.run();
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
actions = buildActions();
|
||||
input.value = '';
|
||||
applyFilter();
|
||||
palette.classList.add('open');
|
||||
palette.setAttribute('aria-hidden', 'false');
|
||||
requestAnimationFrame(() => input.focus());
|
||||
};
|
||||
const close = () => {
|
||||
palette.classList.remove('open');
|
||||
palette.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
App.enhance.openPalette = open;
|
||||
|
||||
input.addEventListener('input', applyFilter);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); scrollActive(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); scrollActive(); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); choose(activeIndex); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
});
|
||||
const scrollActive = () => {
|
||||
const el = list.children[activeIndex];
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
};
|
||||
palette.addEventListener('click', (e) => { if (e.target === palette) close(); });
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
if (palette.classList.contains('open')) close(); else open();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
initSpotlight();
|
||||
initScrollAffordances();
|
||||
initHoverPreview();
|
||||
initCommandPalette();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
475
frontend/js/favorites.js
Normal file
475
frontend/js/favorites.js
Normal file
@@ -0,0 +1,475 @@
|
||||
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) : [];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
// Two things are repaired on the way in, and written back once if
|
||||
// anything changed, so the fix happens exactly one time:
|
||||
//
|
||||
// `meta`, from older versions, is a blob of resolved formats whose
|
||||
// URLs are signed and long expired -- dropped so no code path can
|
||||
// reach for one; everything re-resolves from `url` at play time.
|
||||
//
|
||||
// `favoriteDate` didn't exist before sorting needed it. There's no
|
||||
// way to recover when an old favorite was actually saved, so it
|
||||
// gets now: they sort together, as one batch, at the point the
|
||||
// client learned to keep dates.
|
||||
let repaired = false;
|
||||
const now = new Date().toISOString();
|
||||
const items = parsed.map((item) => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
if (!item.meta && item.favoriteDate) return item;
|
||||
const clean = Object.assign({}, item);
|
||||
delete clean.meta;
|
||||
if (!clean.favoriteDate) clean.favoriteDate = now;
|
||||
repaired = true;
|
||||
return clean;
|
||||
});
|
||||
if (repaired) App.favorites.setAll(items);
|
||||
return items;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Sort orders offered for the favorites bar and the favorites grid.
|
||||
// `random` reshuffles on every read by design -- it's for rediscovering a
|
||||
// long list, so landing somewhere different each time is the point.
|
||||
App.favorites.SORTS = [
|
||||
{ id: 'recent', label: 'Recently added' },
|
||||
{ id: 'oldest', label: 'Oldest first' },
|
||||
{ id: 'title', label: 'Title A-Z' },
|
||||
{ id: 'longest', label: 'Longest' },
|
||||
{ id: 'shortest', label: 'Shortest' },
|
||||
{ id: 'random', label: 'Shuffle' }
|
||||
];
|
||||
App.favorites.DEFAULT_SORT = 'recent';
|
||||
|
||||
App.favorites.getSort = function() {
|
||||
const stored = localStorage.getItem(App.constants.FAVORITES_SORT_KEY);
|
||||
return App.favorites.SORTS.some((sort) => sort.id === stored) ? stored : App.favorites.DEFAULT_SORT;
|
||||
};
|
||||
|
||||
App.favorites.setSort = function(sort) {
|
||||
localStorage.setItem(App.constants.FAVORITES_SORT_KEY, sort);
|
||||
};
|
||||
|
||||
const dateValue = function(item) {
|
||||
const parsed = Date.parse((item && item.favoriteDate) || '');
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
App.favorites.sorted = function(sort) {
|
||||
const items = App.favorites.getAll();
|
||||
const mode = sort || App.favorites.getSort();
|
||||
if (mode === 'random') {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
const comparators = {
|
||||
recent: (a, b) => dateValue(b) - dateValue(a),
|
||||
oldest: (a, b) => dateValue(a) - dateValue(b),
|
||||
title: (a, b) => String(a.title || '').localeCompare(String(b.title || ''), undefined, { sensitivity: 'base' }),
|
||||
longest: (a, b) => (Number(b.duration) || 0) - (Number(a.duration) || 0),
|
||||
shortest: (a, b) => (Number(a.duration) || 0) - (Number(b.duration) || 0)
|
||||
};
|
||||
return items.sort(comparators[mode] || comparators.recent);
|
||||
};
|
||||
|
||||
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,
|
||||
// The page/source URL (e.g. the YouTube watch URL), not a resolved
|
||||
// CDN media URL -- those expire, so favorites must always re-resolve
|
||||
// via the server at play time instead of caching a stream link.
|
||||
url: video.url || (meta && meta.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,
|
||||
isLive: !!(video.isLive || (meta && meta.isLive)),
|
||||
// When it was saved. An import carries the date the other client
|
||||
// recorded; anything saved here is saved now.
|
||||
favoriteDate: video.favoriteDate || new Date().toISOString()
|
||||
// No `meta` field: persisting resolved formats would freeze their
|
||||
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
|
||||
// favorite look like a fresh, unresolved listing item again, so
|
||||
// playback/download/info all re-resolve through the backend from
|
||||
// `url` -- see resolveStreamSources' no-formats fallback, which the
|
||||
// backend resolves live via yt-dlp (main.py stream_video).
|
||||
};
|
||||
};
|
||||
|
||||
// Identity across sources. Favorites added here are keyed by the server's
|
||||
// id; ones imported from a Hot Tub backup can only be keyed by URL (the app
|
||||
// keys videos by a hash of its own). Comparing normalized URLs is what
|
||||
// stops the same video being listed twice under two different keys.
|
||||
App.favorites.urlKey = function(url) {
|
||||
const raw = String(url || '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = new URL(raw, window.location.href);
|
||||
const host = parsed.host.replace(/^www\./i, '').toLowerCase();
|
||||
const path = parsed.pathname.replace(/\/+$/, '');
|
||||
return `${host}${path}${parsed.search}`;
|
||||
} catch (err) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
// Adds favorites from an import, skipping any this client already has.
|
||||
// Existing entries are left exactly as they are -- they carry the server id
|
||||
// that makes a listing card's heart light up, which an imported entry has
|
||||
// no way to know -- and new ones are appended after them.
|
||||
App.favorites.mergeImported = function(entries) {
|
||||
const incoming = Array.isArray(entries) ? entries : [];
|
||||
const favorites = App.favorites.getAll();
|
||||
const keys = new Set();
|
||||
const urls = new Set();
|
||||
favorites.forEach((item) => {
|
||||
if (!item) return;
|
||||
if (item.key) keys.add(item.key);
|
||||
const urlKey = App.favorites.urlKey(item.url);
|
||||
if (urlKey) urls.add(urlKey);
|
||||
});
|
||||
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
incoming.forEach((entry) => {
|
||||
if (!entry || !entry.key) return;
|
||||
const urlKey = App.favorites.urlKey(entry.url);
|
||||
if (keys.has(entry.key) || (urlKey && urls.has(urlKey))) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
keys.add(entry.key);
|
||||
if (urlKey) urls.add(urlKey);
|
||||
favorites.push(entry);
|
||||
added++;
|
||||
});
|
||||
|
||||
if (added) {
|
||||
App.favorites.setAll(favorites);
|
||||
App.favorites.renderBar();
|
||||
App.favorites.syncButtons();
|
||||
}
|
||||
return { added, skipped, total: favorites.length };
|
||||
};
|
||||
|
||||
App.favorites.getSet = function() {
|
||||
return new Set(App.favorites.getAll().map((item) => item.key));
|
||||
};
|
||||
|
||||
// Same set, addressed by URL. Imported favorites are keyed by URL rather
|
||||
// than by a server id, so a listing card can only recognise one this way.
|
||||
App.favorites.getUrlSet = function() {
|
||||
const urls = new Set();
|
||||
App.favorites.getAll().forEach((item) => {
|
||||
const urlKey = item && App.favorites.urlKey(item.url);
|
||||
if (urlKey) urls.add(urlKey);
|
||||
});
|
||||
return urls;
|
||||
};
|
||||
|
||||
// Is this video already a favorite, whichever way it got saved? Checked by
|
||||
// key first, then by URL, so a card and an imported entry for the same
|
||||
// video are recognised as one thing.
|
||||
App.favorites.indexOfEntry = function(favorites, video) {
|
||||
const key = App.favorites.getKey(video);
|
||||
const byKey = key ? favorites.findIndex((item) => item && item.key === key) : -1;
|
||||
if (byKey >= 0) return byKey;
|
||||
const meta = (video && video.meta) || video || {};
|
||||
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
|
||||
if (!urlKey) return -1;
|
||||
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
|
||||
};
|
||||
|
||||
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();
|
||||
const favoriteUrls = App.favorites.getUrlSet();
|
||||
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
|
||||
const key = button.dataset.favKey;
|
||||
const urlKey = App.favorites.urlKey(button.dataset.favUrl);
|
||||
if (!key && !urlKey) return;
|
||||
App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey)));
|
||||
});
|
||||
};
|
||||
|
||||
App.favorites.toggle = function(video) {
|
||||
const key = App.favorites.getKey(video);
|
||||
if (!key) return;
|
||||
const favorites = App.favorites.getAll();
|
||||
// By key or by URL: unfavoriting a card whose video came in from a
|
||||
// backup must remove that entry, not add a second one beside it.
|
||||
const existingIndex = App.favorites.indexOfEntry(favorites, video);
|
||||
const becameFavorite = existingIndex < 0;
|
||||
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();
|
||||
// Celebrate an add with a brass pop + ring on every button for this key.
|
||||
if (becameFavorite) {
|
||||
document.querySelectorAll(`.favorite-btn[data-fav-key="${(window.CSS && CSS.escape) ? CSS.escape(key) : key}"]`).forEach((btn) => {
|
||||
btn.classList.remove('just-favorited');
|
||||
void btn.offsetWidth; // restart the animation
|
||||
btn.classList.add('just-favorited');
|
||||
btn.addEventListener('animationend', () => btn.classList.remove('just-favorited'), { once: true });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The bar is a horizontal strip, and a long favorites list is hundreds of
|
||||
// cards. Only a screenful or so is built up front; the rest arrives as the
|
||||
// strip is scrolled, which keeps opening the app cheap no matter how many
|
||||
// favorites are saved (an import can add hundreds at once).
|
||||
const BAR_PAGE_SIZE = 24;
|
||||
// How close to the right end the strip has to get before the next page is
|
||||
// appended -- roughly a screen's worth of cards ahead of the reader.
|
||||
const BAR_PAGE_AHEAD_PX = 800;
|
||||
const barPage = { items: [], rendered: 0 };
|
||||
|
||||
// Bar titles are one line that scrolls when it doesn't fit, the same as the
|
||||
// grid card's. Which one scrolls follows the grid too: with a real pointer
|
||||
// it's the card under it, on touch the card nearest the middle of the strip.
|
||||
// Animating every overflowing title at once turns the bar into a wall of
|
||||
// moving text.
|
||||
const barTitleEnv = {
|
||||
useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||
};
|
||||
|
||||
const setBarTitleActive = function(card, active) {
|
||||
const title = card && card.querySelector('.favorite-title');
|
||||
if (!title) return;
|
||||
card.classList.toggle('is-title-active', !!active && title.classList.contains('has-marquee'));
|
||||
};
|
||||
|
||||
// Touch: the card nearest the centre of the visible strip is the one being
|
||||
// read, so it is the one whose title scrolls.
|
||||
const syncBarTitleActive = function(list) {
|
||||
if (!list) return;
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const centre = listRect.left + listRect.width / 2;
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
const cards = list.querySelectorAll('.favorite-card');
|
||||
cards.forEach((card) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
if (rect.right <= listRect.left || rect.left >= listRect.right) return;
|
||||
const distance = Math.abs((rect.left + rect.width / 2) - centre);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = card;
|
||||
}
|
||||
});
|
||||
cards.forEach((card) => setBarTitleActive(card, card === best));
|
||||
};
|
||||
|
||||
// Measures every card in the strip. Cheap enough to redo wholesale: a card's
|
||||
// width is fixed, so this only really runs when cards are added.
|
||||
const measureBarTitles = function(list) {
|
||||
const target = list || document.getElementById('favorites-list');
|
||||
if (!target) return;
|
||||
target.querySelectorAll('.favorite-card').forEach((card) => {
|
||||
App.marquee.measure(card.querySelector('.favorite-title'),
|
||||
card.querySelector('.favorite-title-text'));
|
||||
});
|
||||
if (!barTitleEnv.useHoverFocus) syncBarTitleActive(target);
|
||||
};
|
||||
|
||||
// The display font arrives after the first render, and it changes how wide
|
||||
// every title is -- so whatever was measured against the fallback font has
|
||||
// to be measured again once the real one is in.
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(() => measureBarTitles()).catch(() => {});
|
||||
}
|
||||
|
||||
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.sorted();
|
||||
// While the favorites grid is open the bar is kept mounted even if it's
|
||||
// switched off in settings: its header is what leads back out.
|
||||
const browsing = !!(App.favoritesView && App.favoritesView.isActive());
|
||||
bar.style.display = (App.favorites.isVisible() || browsing) ? 'block' : 'none';
|
||||
|
||||
list.innerHTML = "";
|
||||
barPage.items = favorites;
|
||||
barPage.rendered = 0;
|
||||
// While the favorites grid is open the strip is hidden -- the grid is
|
||||
// the same list, larger -- so don't build cards nobody can see. The
|
||||
// header stays, because it carries the way back out.
|
||||
if (!browsing) appendBarPage(list);
|
||||
|
||||
// Assignment rather than addEventListener: renderBar runs on every
|
||||
// favorite change, and this must not stack up handlers.
|
||||
let scrollRaf = null;
|
||||
list.onscroll = () => {
|
||||
// Which card is centred changes as the strip moves, but only once
|
||||
// per frame is worth measuring.
|
||||
if (!barTitleEnv.useHoverFocus && !scrollRaf) {
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
scrollRaf = null;
|
||||
syncBarTitleActive(list);
|
||||
});
|
||||
}
|
||||
if (barPage.rendered >= barPage.items.length) return;
|
||||
const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth);
|
||||
if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list);
|
||||
};
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
|
||||
function appendBarPage(list) {
|
||||
const slice = barPage.items.slice(barPage.rendered, barPage.rendered + BAR_PAGE_SIZE);
|
||||
barPage.rendered += slice.length;
|
||||
slice.forEach((item) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'favorite-card';
|
||||
card.dataset.favKey = item.key;
|
||||
const uploaderText = item.uploader || '';
|
||||
const durationText = (!item.isLive && App.videos && typeof App.videos.formatDuration === 'function')
|
||||
? App.videos.formatDuration(item.duration)
|
||||
: '';
|
||||
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
||||
card.innerHTML = `
|
||||
${liveBadge}
|
||||
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}" data-fav-url="${item.url || ''}">♥</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>
|
||||
<div class="video-thumb">
|
||||
<img alt="${item.title}" loading="lazy" decoding="async">
|
||||
<div class="video-loading" aria-hidden="true">
|
||||
<div class="video-loading-spinner"></div>
|
||||
</div>
|
||||
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
|
||||
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
||||
</div>
|
||||
<div class="favorite-info">
|
||||
<h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
|
||||
</div>
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
||||
App.videos.attachThumbnail(thumb, item.thumb);
|
||||
}
|
||||
card.onclick = () => {
|
||||
if (card.classList.contains('is-loading')) return;
|
||||
card.classList.add('is-loading');
|
||||
// Ignore any stale `meta` a favorite saved before this fix may
|
||||
// still carry in localStorage -- always re-resolve from `item`
|
||||
// (id/url) so playback never reuses an expired stream URL.
|
||||
App.player.open(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.videos.closeAllMenus();
|
||||
// Favorites deliberately store no resolved metadata; the
|
||||
// panel opens on what the entry holds and resolves the rest
|
||||
// itself.
|
||||
App.ui.openInfo(item);
|
||||
};
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.closeAllMenus();
|
||||
// Same as playback: resolve a real media URL first rather
|
||||
// than pointing the download at the page URL.
|
||||
App.videos.ensureFormats(item).then(() => App.videos.downloadVideo(item));
|
||||
};
|
||||
}
|
||||
const uploaderBtn = card.querySelector('.uploader-link');
|
||||
if (uploaderBtn) {
|
||||
uploaderBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
|
||||
App.videos.handleSearch(uploader);
|
||||
};
|
||||
}
|
||||
if (barTitleEnv.useHoverFocus) {
|
||||
card.addEventListener('pointerenter', () => setBarTitleActive(card, true));
|
||||
card.addEventListener('pointerleave', () => setBarTitleActive(card, false));
|
||||
}
|
||||
// Keyboard: tabbing to a card's heart should reveal its whole title
|
||||
// too, on touch devices as much as on desktop.
|
||||
card.addEventListener('focusin', () => setBarTitleActive(card, true));
|
||||
card.addEventListener('focusout', () => setBarTitleActive(card, false));
|
||||
list.appendChild(card);
|
||||
});
|
||||
// Widths only exist once the cards are laid out.
|
||||
requestAnimationFrame(() => measureBarTitles(list));
|
||||
}
|
||||
})();
|
||||
110
frontend/js/favoritesView.js
Normal file
110
frontend/js/favoritesView.js
Normal file
@@ -0,0 +1,110 @@
|
||||
window.App = window.App || {};
|
||||
App.favoritesView = App.favoritesView || {};
|
||||
|
||||
// Browsing favorites as a full grid, the same way the channel listing is
|
||||
// browsed: same cards, same virtualized masonry, same infinite scroll, same
|
||||
// reels mode. The only difference is where the pages come from -- localStorage
|
||||
// instead of the server -- so App.videos.loadVideos routes here while this view
|
||||
// is active and every page hands its slice to App.videos.renderVideos.
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
// A page of a local list can be bigger than a page from the server: there's
|
||||
// no request behind it, only the cost of building cards, which the
|
||||
// virtualizer already keeps to what's on screen.
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
const view = {
|
||||
active: false,
|
||||
queue: [], // the sorted favorites still to be handed to the grid
|
||||
offset: 0
|
||||
};
|
||||
|
||||
// A favorite as the grid expects a video: `id` has to be unique per card
|
||||
// (the virtualizer and renderedVideoIds key on it), and an imported
|
||||
// favorite has no server id -- its key, which is the URL, stands in.
|
||||
const toVideo = function(entry) {
|
||||
return Object.assign({}, entry, { id: entry.key, tags: [] });
|
||||
};
|
||||
|
||||
App.favoritesView.isActive = function() {
|
||||
return view.active;
|
||||
};
|
||||
|
||||
App.favoritesView.loadNext = function() {
|
||||
if (!view.active) return false;
|
||||
const slice = view.queue.slice(view.offset, view.offset + PAGE_SIZE);
|
||||
view.offset += slice.length;
|
||||
state.hasNextPage = view.offset < view.queue.length;
|
||||
if (!slice.length) {
|
||||
App.videos.updateLoadMoreState();
|
||||
return false;
|
||||
}
|
||||
App.videos.renderVideos({ items: slice.map(toVideo) });
|
||||
App.videos.updateLoadMoreState();
|
||||
return true;
|
||||
};
|
||||
|
||||
// Starts (or restarts, after a sort change) the favorites grid.
|
||||
App.favoritesView.open = function(options) {
|
||||
const sort = (options && options.sort) || App.favorites.getSort();
|
||||
const favorites = App.favorites.sorted(sort);
|
||||
if (!favorites.length) {
|
||||
App.ui.showError('No favorites yet. Tap the heart on a video to save one.');
|
||||
return false;
|
||||
}
|
||||
App.videos.resetGrid();
|
||||
view.active = true;
|
||||
view.queue = favorites;
|
||||
view.offset = 0;
|
||||
state.hasNextPage = true;
|
||||
document.body.classList.add('favorites-view-open');
|
||||
// Re-render the bar so it re-decides whether to be mounted: hidden in
|
||||
// settings or not, its header has to be on screen now, since that's
|
||||
// where the way back out lives (the palette can open this view too).
|
||||
App.favorites.renderBar();
|
||||
App.favoritesView.syncControls();
|
||||
App.favoritesView.loadNext();
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
return true;
|
||||
};
|
||||
|
||||
App.favoritesView.close = function(options) {
|
||||
if (!view.active) return;
|
||||
view.active = false;
|
||||
view.queue = [];
|
||||
view.offset = 0;
|
||||
document.body.classList.remove('favorites-view-open');
|
||||
// Back to whatever the settings say, and with its cards built again.
|
||||
App.favorites.renderBar();
|
||||
App.favoritesView.syncControls();
|
||||
// Back to the channel listing, unless the caller is about to load
|
||||
// something itself (a search, a channel switch).
|
||||
if (!(options && options.silent)) App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
App.favoritesView.toggle = function() {
|
||||
if (view.active) App.favoritesView.close();
|
||||
else App.favoritesView.open();
|
||||
};
|
||||
|
||||
// Re-pages the grid under a new order, and re-renders the bar so both show
|
||||
// favorites the same way round.
|
||||
App.favoritesView.applySort = function(sort) {
|
||||
App.favorites.setSort(sort);
|
||||
App.favorites.renderBar();
|
||||
if (view.active) App.favoritesView.open({ sort });
|
||||
};
|
||||
|
||||
App.favoritesView.syncControls = function() {
|
||||
const button = document.getElementById('favorites-browse-btn');
|
||||
if (button) {
|
||||
button.textContent = view.active ? 'Back to videos' : 'Browse all';
|
||||
button.setAttribute('aria-pressed', view.active ? 'true' : 'false');
|
||||
}
|
||||
const select = document.getElementById('favorites-sort');
|
||||
if (select && select.value !== App.favorites.getSort()) {
|
||||
select.value = App.favorites.getSort();
|
||||
}
|
||||
};
|
||||
})();
|
||||
879
frontend/js/feed.js
Normal file
879
frontend/js/feed.js
Normal file
@@ -0,0 +1,879 @@
|
||||
window.App = window.App || {};
|
||||
App.feed = App.feed || {};
|
||||
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
// Tuning knobs for the virtualized feed.
|
||||
//
|
||||
// The feed is a y-scroll-snap list where every slide is exactly one
|
||||
// viewport tall. We never keep the whole list in the DOM. Instead we keep
|
||||
// a sliding window of slides around the active one:
|
||||
//
|
||||
// [active - HISTORY_COUNT .. active + RENDER_AHEAD]
|
||||
//
|
||||
// Everything outside that range is removed from the document; its video
|
||||
// JSON stays in state.loadedVideos so the slide is rebuilt instantly when
|
||||
// the user scrolls back to (or forward into) it. A top spacer absorbs the
|
||||
// height of the not-yet-rendered slides above the window, so adding or
|
||||
// removing slides never shifts the scroll position: any slide at index i
|
||||
// always sits at scrollTop === i * slideHeight regardless of the window.
|
||||
//
|
||||
// Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the
|
||||
// active slide so the data buffer is always full before we need to render
|
||||
// a slide from it.
|
||||
const PRELOAD_COUNT = 2; // slides ahead kept with a live <video> playing/preloaded
|
||||
const RENDER_AHEAD = 5; // slides ahead kept materialized in the DOM
|
||||
const HISTORY_COUNT = 5; // slides behind kept materialized in the DOM
|
||||
const PREFETCH_PAGES = 2; // pages of JSON to keep buffered ahead of the active slide
|
||||
|
||||
// Map of loadedVideos index -> rendered .feed-slide element.
|
||||
const slidesByIndex = new Map();
|
||||
let scrollBound = false;
|
||||
let scrollRaf = null;
|
||||
|
||||
// While true, scroll events are ignored. A viewport change (e.g. an
|
||||
// orientation switch) makes the scroll-snap container re-snap and fire
|
||||
// scroll events with positions that no longer map to the active slide;
|
||||
// onResize sets this for the brief realign window so those events don't
|
||||
// flip the active video -- rotating the device must never change which
|
||||
// slide is playing. Normal swipes (no resize in flight) are unaffected.
|
||||
let suppressScroll = false;
|
||||
let resizeSettleRaf = null;
|
||||
|
||||
// HUD auto-hide: the reels HUD fades out after this much inactivity and
|
||||
// reappears on any pointer movement / tap / scroll. Buttons keep their
|
||||
// pointer-events while hidden, so they stay clickable even when invisible.
|
||||
const HUD_IDLE_MS = 1000;
|
||||
let hudIdleTimer = null;
|
||||
let hudActivityBound = false;
|
||||
|
||||
const scheduleHudHide = function() {
|
||||
if (hudIdleTimer) clearTimeout(hudIdleTimer);
|
||||
hudIdleTimer = setTimeout(() => {
|
||||
hudIdleTimer = null;
|
||||
if (!state.feedOpen) return;
|
||||
document.body.classList.add('feed-hud-idle');
|
||||
// The quality menu only fades with the rest of the HUD if we close
|
||||
// it: it's an opened popover, not a permanently mounted control.
|
||||
document.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
|
||||
}, HUD_IDLE_MS);
|
||||
};
|
||||
|
||||
const wakeHud = function() {
|
||||
document.body.classList.remove('feed-hud-idle');
|
||||
if (state.feedOpen) scheduleHudHide();
|
||||
};
|
||||
|
||||
const getScroller = () => document.getElementById('feed-scroll');
|
||||
const getSentinel = () => document.getElementById('feed-sentinel');
|
||||
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
||||
|
||||
const slideHeight = function() {
|
||||
const scroller = getScroller();
|
||||
return (scroller && scroller.clientHeight) || window.innerHeight || 1;
|
||||
};
|
||||
|
||||
const clampIndex = function(index) {
|
||||
const total = (state.loadedVideos || []).length;
|
||||
if (total === 0) return -1;
|
||||
return Math.min(total - 1, Math.max(0, index));
|
||||
};
|
||||
|
||||
// True when the user wants a finished clip to replay; false when it should
|
||||
// auto-advance to the next video. Defaults to looping (see storage).
|
||||
const shouldLoop = function() {
|
||||
return App.storage.getFeedEndBehavior() !== 'scroll';
|
||||
};
|
||||
|
||||
// Smoothly scrolls to the slide after `fromIndex`; the scroll-snap container
|
||||
// fires onScroll, which promotes the new slide to active. No-ops at the end
|
||||
// of the list so the final clip simply stops on its last frame.
|
||||
const advanceToNext = function(fromIndex) {
|
||||
const next = clampIndex(fromIndex + 1);
|
||||
if (next < 0 || next === fromIndex) return;
|
||||
const scroller = getScroller();
|
||||
if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Remembers playback position per video id so scrolling away and back
|
||||
// resumes where the user left off. Slides kept in the window are merely
|
||||
// paused (instant resume); slides whose <video> is torn down to free
|
||||
// resources still have their position restored on reload via applyResume.
|
||||
const KEEP_BEHIND = 2; // slides behind the active one kept loaded & paused
|
||||
const resumeTimes = new Map();
|
||||
|
||||
const slideVideoId = (slide) => (slide && slide._videoData ? slide._videoData.id : null);
|
||||
|
||||
const rememberTime = function(slide, video) {
|
||||
if (!slide || !video || slide.classList.contains('is-live')) return;
|
||||
const id = slideVideoId(slide);
|
||||
if (id == null) return;
|
||||
const t = video.currentTime;
|
||||
if (isFinite(t) && t > 0.5) resumeTimes.set(id, t);
|
||||
};
|
||||
|
||||
const applyResume = function(video, videoId, isLive) {
|
||||
if (!video || isLive || videoId == null) return;
|
||||
const t = resumeTimes.get(videoId);
|
||||
if (t == null || t <= 0) return;
|
||||
const seek = () => {
|
||||
let target = t;
|
||||
if (isFinite(video.duration) && video.duration > 0) {
|
||||
target = Math.min(t, video.duration - 0.25);
|
||||
}
|
||||
try { video.currentTime = Math.max(0, target); } catch (err) { /* ignore */ }
|
||||
};
|
||||
if (video.readyState >= 1) seek();
|
||||
else video.addEventListener('loadedmetadata', seek, { once: true });
|
||||
};
|
||||
|
||||
// Pauses a slide but keeps its <video> loaded so returning to it resumes
|
||||
// instantly from the exact frame it was paused on.
|
||||
const pauseSlide = function(slide) {
|
||||
const video = slide.querySelector('.feed-video');
|
||||
slide.classList.remove('is-active');
|
||||
if (video && !video.paused) video.pause();
|
||||
rememberTime(slide, video);
|
||||
};
|
||||
|
||||
const destroySlidePlayback = function(slide) {
|
||||
const video = slide.querySelector('.feed-video');
|
||||
slide.classList.remove('is-active');
|
||||
rememberTime(slide, video);
|
||||
const fill = slide.querySelector('.feed-timeline-fill');
|
||||
if (fill) fill.style.width = '0%';
|
||||
const handle = slide.querySelector('.feed-timeline-handle');
|
||||
if (handle) handle.style.left = '0%';
|
||||
if (!video) return;
|
||||
if (video._hlsPlayer) {
|
||||
video._hlsPlayer.destroy();
|
||||
video._hlsPlayer = null;
|
||||
}
|
||||
// Clearing the src below makes the element fire a spurious `error` event;
|
||||
// flag the teardown so the failure handler ignores it (see markSlideFailed).
|
||||
video._tearingDown = true;
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
slide.classList.remove('is-loaded');
|
||||
};
|
||||
|
||||
// Single-line slide title that scrolls when it overflows. Only the active
|
||||
// slide's title is measured, so like the player it always scrolls.
|
||||
const measureFeedTitle = function(slide) {
|
||||
if (!slide) return;
|
||||
App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
|
||||
};
|
||||
|
||||
const setTimelinePosition = function(slide, ratio) {
|
||||
const fill = slide.querySelector('.feed-timeline-fill');
|
||||
const handle = slide.querySelector('.feed-timeline-handle');
|
||||
const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`;
|
||||
if (fill) fill.style.width = pct;
|
||||
if (handle) handle.style.left = pct;
|
||||
};
|
||||
|
||||
const seekFromPointer = function(slide, video, timeline, clientX) {
|
||||
if (!isFinite(video.duration) || video.duration <= 0) return;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
||||
const clamped = Math.min(1, Math.max(0, ratio));
|
||||
video.currentTime = clamped * video.duration;
|
||||
setTimelinePosition(slide, clamped);
|
||||
};
|
||||
|
||||
const bindTimeline = function(slide, video) {
|
||||
const timeline = slide.querySelector('.feed-timeline');
|
||||
if (!timeline) return;
|
||||
let scrubbing = false;
|
||||
|
||||
video.addEventListener('timeupdate', () => {
|
||||
if (scrubbing || !isFinite(video.duration) || video.duration <= 0) return;
|
||||
setTimelinePosition(slide, video.currentTime / video.duration);
|
||||
});
|
||||
|
||||
timeline.addEventListener('pointerdown', (event) => {
|
||||
scrubbing = true;
|
||||
timeline.classList.add('is-scrubbing');
|
||||
timeline.setPointerCapture(event.pointerId);
|
||||
seekFromPointer(slide, video, timeline, event.clientX);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
timeline.addEventListener('pointermove', (event) => {
|
||||
if (!scrubbing) return;
|
||||
seekFromPointer(slide, video, timeline, event.clientX);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
const stopScrubbing = (event) => {
|
||||
if (!scrubbing) return;
|
||||
scrubbing = false;
|
||||
timeline.classList.remove('is-scrubbing');
|
||||
if (timeline.hasPointerCapture(event.pointerId)) {
|
||||
timeline.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
event.stopPropagation();
|
||||
};
|
||||
timeline.addEventListener('pointerup', stopScrubbing);
|
||||
timeline.addEventListener('pointercancel', stopScrubbing);
|
||||
};
|
||||
|
||||
const flashFeed = function(slide, text) {
|
||||
const flashEl = slide.querySelector('.feed-flash');
|
||||
if (!flashEl) return;
|
||||
flashEl.textContent = text;
|
||||
flashEl.classList.remove('is-visible');
|
||||
void flashEl.offsetWidth;
|
||||
flashEl.classList.add('is-visible');
|
||||
};
|
||||
|
||||
// Wires the controls shared with the standalone fullscreen player (skip
|
||||
// escalation + double-tap zones, format switching, PiP) onto a reels
|
||||
// slide, reusing the same App.customPlayer logic so both surfaces behave
|
||||
// identically. Feed's own timeline/favorite/title and scroll-snap
|
||||
// slide-to-slide navigation are untouched (see bindTimeline above and
|
||||
// setActive/onScroll below).
|
||||
const bindSharedControls = function(slide, video, videoData) {
|
||||
const cleanups = [];
|
||||
const escalator = App.customPlayer.createSkipEscalator();
|
||||
cleanups.push(() => escalator.destroy());
|
||||
|
||||
const doSkip = (direction) => {
|
||||
const amount = App.customPlayer.skip(video, direction, escalator);
|
||||
flashFeed(slide, `${direction === 'forward' ? '+' : '-'}${amount}s`);
|
||||
wakeHud();
|
||||
};
|
||||
|
||||
const pipBtn = slide.querySelector('.feed-pip-btn');
|
||||
if (pipBtn) {
|
||||
pipBtn.hidden = !App.customPlayer.supportsPiP();
|
||||
const onClick = async (event) => {
|
||||
event.stopPropagation();
|
||||
await App.customPlayer.togglePiP(video);
|
||||
};
|
||||
pipBtn.addEventListener('click', onClick);
|
||||
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
|
||||
}
|
||||
cleanups.push(App.customPlayer.bindAutoPiP(video));
|
||||
|
||||
const formatBtn = slide.querySelector('.feed-format-btn');
|
||||
const formatMenu = slide.querySelector('.feed-format-menu');
|
||||
const onFormatPick = (fmt) => {
|
||||
slide._formatOverride = fmt;
|
||||
const t = video.currentTime;
|
||||
if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t);
|
||||
// Tear down the current source (mirrors destroySlidePlayback's
|
||||
// hls/video reset) before reloading with the new format -- this
|
||||
// is a live in-place reload, not a fresh never-loaded slide, so
|
||||
// the old Hls.js instance must be destroyed or it keeps running
|
||||
// (fetching segments, attached to the same <video>) forever.
|
||||
if (video._hlsPlayer) {
|
||||
video._hlsPlayer.destroy();
|
||||
video._hlsPlayer = null;
|
||||
}
|
||||
video._tearingDown = true;
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
slide.classList.remove('is-loaded');
|
||||
loadSlideSource(slide, videoData, true);
|
||||
};
|
||||
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
|
||||
{ getCurrentUrl: () => slide._activeUrl || '', onOpen: wakeHud });
|
||||
let destroyFormatMenu = bindFormats();
|
||||
cleanups.push(() => destroyFormatMenu());
|
||||
// A slide can go active before its formats have been resolved (feed items
|
||||
// carry only a page URL until then), which would leave the quality menu
|
||||
// empty. Playback already runs from that page URL through the proxy, so
|
||||
// resolve in the background and rebuild the menu once the real qualities
|
||||
// land -- same as the standalone player does.
|
||||
if (App.videos && typeof App.videos.ensureFormats === 'function') {
|
||||
App.videos.ensureFormats(videoData).then((meta) => {
|
||||
// Bail if the slide was torn down (or rebound) in the meantime.
|
||||
if (!meta || slide._sharedControlCleanups !== cleanups) return;
|
||||
destroyFormatMenu();
|
||||
destroyFormatMenu = bindFormats();
|
||||
});
|
||||
}
|
||||
|
||||
cleanups.push(App.customPlayer.attachGestures(slide, {
|
||||
onSingleTap: wakeHud,
|
||||
onDoubleTapLeft: () => doSkip('back'),
|
||||
onDoubleTapRight: () => doSkip('forward'),
|
||||
ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline'
|
||||
}));
|
||||
|
||||
slide._sharedControlCleanups = cleanups;
|
||||
};
|
||||
|
||||
const loadSlideSource = function(slide, videoData, autoplay) {
|
||||
const video = slide.querySelector('.feed-video');
|
||||
if (!video) return;
|
||||
if (slide.classList.contains('is-loaded')) {
|
||||
if (autoplay) {
|
||||
video.muted = state.feedMuted;
|
||||
const playPromise = video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A slide whose formats haven't been resolved yet carries only a page
|
||||
// URL, and handing that to /api/stream makes the backend re-run yt-dlp
|
||||
// per request (slow, and a hard failure on some sites). Resolve once,
|
||||
// then load for real -- `_awaitingFormats` keeps a failed resolve from
|
||||
// looping, so we still fall back to the page URL as a last resort.
|
||||
const meta = videoData && (videoData.meta || videoData);
|
||||
const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length);
|
||||
if (!hasFormats && !slide._awaitingFormats && App.videos && typeof App.videos.ensureFormats === 'function') {
|
||||
slide._awaitingFormats = true;
|
||||
App.videos.ensureFormats(videoData).then(() => {
|
||||
if (slide._videoData === videoData) loadSlideSource(slide, videoData, autoplay);
|
||||
});
|
||||
return;
|
||||
}
|
||||
slide.classList.add('is-loaded');
|
||||
|
||||
const resolved = slide._formatOverride
|
||||
? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
|
||||
: App.videos.resolveStreamSource(videoData);
|
||||
if (!resolved || !resolved.url) {
|
||||
// No playable source -- treat exactly like a load failure so the
|
||||
// clip is dropped from the queue and the next one takes its place.
|
||||
markSlideFailed(slide);
|
||||
return;
|
||||
}
|
||||
// What's actually on screen, so the quality menu can tick it.
|
||||
slide._activeUrl = resolved.url;
|
||||
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||
const isHls = App.videos.classifySource(resolved).isHls;
|
||||
|
||||
video.muted = state.feedMuted;
|
||||
video.preload = 'auto';
|
||||
video._tearingDown = false;
|
||||
applyResume(video, videoData && videoData.id, resolved.isLive);
|
||||
|
||||
const startPlay = () => {
|
||||
if (!autoplay) return;
|
||||
const playPromise = video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||
};
|
||||
|
||||
const attachHls = (HlsLib) => {
|
||||
const hls = new HlsLib();
|
||||
video._hlsPlayer = hls;
|
||||
hls.loadSource(streamUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(HlsLib.Events.ERROR, (event, data) => {
|
||||
if (data && data.fatal && video._hlsPlayer === hls) {
|
||||
hls.destroy();
|
||||
video._hlsPlayer = null;
|
||||
// A fatal HLS error means the stream won't play: drop it.
|
||||
markSlideFailed(slide);
|
||||
}
|
||||
});
|
||||
startPlay();
|
||||
};
|
||||
|
||||
const startNative = () => {
|
||||
video.src = streamUrl;
|
||||
startPlay();
|
||||
};
|
||||
|
||||
if (!isHls) {
|
||||
startNative();
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
attachHls(window.Hls);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
startNative();
|
||||
} else {
|
||||
// Lazy-load hls.js on demand for the first HLS slide.
|
||||
App.ensureHls()
|
||||
.then((HlsLib) => {
|
||||
if (HlsLib && HlsLib.isSupported()) attachHls(HlsLib);
|
||||
else startNative();
|
||||
})
|
||||
.catch(startNative);
|
||||
}
|
||||
};
|
||||
|
||||
// Builds (or returns) the .feed-slide element for loadedVideos[index] and
|
||||
// inserts it into the DOM in index order, between the top spacer and the
|
||||
// sentinel.
|
||||
const createSlide = function(index) {
|
||||
if (slidesByIndex.has(index)) return slidesByIndex.get(index);
|
||||
const v = (state.loadedVideos || [])[index];
|
||||
if (!v) return null;
|
||||
const scroller = getScroller();
|
||||
if (!scroller) return null;
|
||||
|
||||
const slide = document.createElement('div');
|
||||
slide.className = v.isLive ? 'feed-slide is-live' : 'feed-slide';
|
||||
slide.dataset.videoId = v.id;
|
||||
slide.dataset.index = String(index);
|
||||
slide._videoData = v;
|
||||
slide._index = index;
|
||||
const uploaderText = v.uploader || '';
|
||||
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
||||
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
||||
slide.innerHTML = `
|
||||
<img class="feed-poster" alt="" loading="lazy" decoding="async">
|
||||
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
|
||||
${liveBadge}
|
||||
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
|
||||
<button class="cp-pip-btn feed-pip-btn" type="button" aria-label="Picture in picture" hidden>
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
|
||||
</button>
|
||||
<button class="cp-format-btn feed-format-btn" type="button" aria-label="Quality" hidden>HD</button>
|
||||
<div class="cp-format-menu feed-format-menu" role="menu" hidden></div>
|
||||
<div class="cp-flash feed-flash" aria-hidden="true"></div>
|
||||
<div class="feed-info">
|
||||
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>
|
||||
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
|
||||
</div>
|
||||
<div class="feed-timeline" role="slider" aria-label="Seek">
|
||||
<div class="feed-timeline-track">
|
||||
<div class="feed-timeline-fill"></div>
|
||||
<div class="feed-timeline-handle"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const poster = slide.querySelector('.feed-poster');
|
||||
App.videos.attachThumbnail(poster, v.thumb);
|
||||
const slideVideo = slide.querySelector('.feed-video');
|
||||
bindTimeline(slide, slideVideo);
|
||||
bindSharedControls(slide, slideVideo, v);
|
||||
|
||||
// A media error (bad/expired source, network failure, unsupported codec)
|
||||
// means this clip can't play -- drop it from the queue. Errors fired by
|
||||
// our own teardown (src cleared) carry the _tearingDown flag and are
|
||||
// ignored inside markSlideFailed.
|
||||
slideVideo.addEventListener('error', () => markSlideFailed(slide));
|
||||
|
||||
// On video end, either loop (handled by the `loop` flag, so `ended`
|
||||
// never fires) or auto-scroll to the next clip. We only advance for the
|
||||
// active slide so a preloaded neighbour ending early can't hijack focus.
|
||||
slideVideo.loop = shouldLoop();
|
||||
slideVideo.addEventListener('ended', () => {
|
||||
if (shouldLoop()) return;
|
||||
if (!slide.classList.contains('is-active')) return;
|
||||
advanceToNext(slide._index);
|
||||
});
|
||||
|
||||
const favBtn = slide.querySelector('.feed-fav-btn');
|
||||
if (favBtn && App.favorites) {
|
||||
App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0);
|
||||
favBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(v);
|
||||
});
|
||||
}
|
||||
|
||||
// Insert before the rendered slide with the next-highest index so DOM
|
||||
// order always matches index order; fall back to the sentinel.
|
||||
let ref = getSentinel();
|
||||
let refIndex = Infinity;
|
||||
slidesByIndex.forEach((el, i) => {
|
||||
if (i > index && i < refIndex) {
|
||||
refIndex = i;
|
||||
ref = el;
|
||||
}
|
||||
});
|
||||
scroller.insertBefore(slide, ref);
|
||||
slidesByIndex.set(index, slide);
|
||||
return slide;
|
||||
};
|
||||
|
||||
// Tears down everything a slide holds -- playback (video/hls) plus the
|
||||
// shared skip/format/PiP/gesture bindings from bindSharedControls -- but
|
||||
// does not remove it from the DOM or from slidesByIndex (callers differ
|
||||
// on that: removeSlide always does, reset() removes the whole tree at
|
||||
// once).
|
||||
const teardownSlide = function(slide) {
|
||||
destroySlidePlayback(slide);
|
||||
if (Array.isArray(slide._sharedControlCleanups)) {
|
||||
slide._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||
slide._sharedControlCleanups = null;
|
||||
}
|
||||
};
|
||||
|
||||
const removeSlide = function(index) {
|
||||
const slide = slidesByIndex.get(index);
|
||||
if (!slide) return;
|
||||
teardownSlide(slide);
|
||||
slide.remove();
|
||||
slidesByIndex.delete(index);
|
||||
};
|
||||
|
||||
// Re-keys every rendered slide after `removedIndex` was spliced out of
|
||||
// state.loadedVideos: indices past the hole shift down by one so
|
||||
// slidesByIndex (and each slide's _index) stays aligned with the queue.
|
||||
const reindexAfterRemoval = function(removedIndex) {
|
||||
const entries = [];
|
||||
slidesByIndex.forEach((slide, i) => entries.push([i, slide]));
|
||||
slidesByIndex.clear();
|
||||
entries.forEach(([i, slide]) => {
|
||||
const ni = i > removedIndex ? i - 1 : i;
|
||||
slide._index = ni;
|
||||
slide.dataset.index = String(ni);
|
||||
slidesByIndex.set(ni, slide);
|
||||
});
|
||||
};
|
||||
|
||||
// Drops a video that failed to load/resolve from the queue and pulls the
|
||||
// next clip into its place. A failed *preload* neighbour leaves the active
|
||||
// video playing untouched; a failed *active* clip is replaced in-place by
|
||||
// the next one (the broken frame is removed and the next clip slides into
|
||||
// the same scroll position, so playback advances without a visible jump).
|
||||
const removeVideoFromQueue = function(videoId) {
|
||||
const videos = state.loadedVideos || [];
|
||||
const r = videos.findIndex((v) => String(v.id) === String(videoId));
|
||||
if (r < 0) return;
|
||||
const prevActive = state.feedActiveIndex;
|
||||
|
||||
// Drop the failed clip's feed slide element from the DOM, then its JSON
|
||||
// from the queue, then re-key the remaining rendered slides.
|
||||
removeSlide(r);
|
||||
videos.splice(r, 1);
|
||||
reindexAfterRemoval(r);
|
||||
|
||||
// Remove the failed clip's grid card element from the DOM too (the grid
|
||||
// shares the queue) and re-pack the remaining cards.
|
||||
if (App.virtualGrid && typeof App.virtualGrid.removeVideo === 'function') {
|
||||
App.virtualGrid.removeVideo(videoId);
|
||||
}
|
||||
|
||||
if (videos.length === 0) {
|
||||
App.feed.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// The active slot only moves when the removed clip was the active one
|
||||
// (r === prevActive) or, defensively, sat before it.
|
||||
let newActive = prevActive;
|
||||
if (r < prevActive) newActive -= 1;
|
||||
newActive = clampIndex(newActive);
|
||||
|
||||
state.feedActiveIndex = -1; // force setActive to re-promote the slot
|
||||
setActive(newActive);
|
||||
|
||||
if (r <= prevActive) {
|
||||
// Active clip failed: re-anchor scroll onto the clip that slid into
|
||||
// its slot so the snap container stays pinned to the new active.
|
||||
const scroller = getScroller();
|
||||
if (scroller) scroller.scrollTop = newActive * slideHeight();
|
||||
}
|
||||
};
|
||||
|
||||
// Flags a slide whose video failed and schedules its removal from the queue.
|
||||
// Deferred to a macrotask so we never mutate slidesByIndex while setActive /
|
||||
// syncWindow is mid-iteration over it. Teardown-induced errors (src cleared)
|
||||
// are ignored via the video's _tearingDown flag, and we only act while the
|
||||
// feed is open so late errors after close are harmless.
|
||||
const markSlideFailed = function(slide) {
|
||||
if (!slide || slide._failed || !state.feedOpen) return;
|
||||
const video = slide.querySelector('.feed-video');
|
||||
if (video && video._tearingDown) return;
|
||||
const id = slideVideoId(slide);
|
||||
if (id == null) return;
|
||||
slide._failed = true;
|
||||
setTimeout(() => removeVideoFromQueue(id), 0);
|
||||
};
|
||||
|
||||
// Brings the rendered window in line with the active index: drops slides
|
||||
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
||||
// any missing ones inside it, and sizes the top spacer to stand in for the
|
||||
// slides above the window.
|
||||
const syncWindow = function(activeIndex) {
|
||||
const total = (state.loadedVideos || []).length;
|
||||
if (total === 0) return;
|
||||
const start = Math.max(0, activeIndex - HISTORY_COUNT);
|
||||
const end = Math.min(total - 1, activeIndex + RENDER_AHEAD);
|
||||
|
||||
slidesByIndex.forEach((slide, i) => {
|
||||
if (i < start || i > end) removeSlide(i);
|
||||
});
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (!slidesByIndex.has(i)) createSlide(i);
|
||||
}
|
||||
|
||||
const spacer = getTopSpacer();
|
||||
if (spacer) spacer.style.height = `${start * slideHeight()}px`;
|
||||
};
|
||||
|
||||
// Once the active slide gets within PREFETCH_PAGES of the end of the loaded
|
||||
// JSON, pull the next page so the buffer stays ahead of the rendered window.
|
||||
const prefetchIfNeeded = function(activeIndex) {
|
||||
const total = (state.loadedVideos || []).length;
|
||||
const bufferAhead = total - 1 - activeIndex;
|
||||
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
|
||||
&& state.hasNextPage && !state.isLoading) {
|
||||
// The feed is its own reader: the grid's scroll position says
|
||||
// nothing about whether it needs the next page.
|
||||
App.videos.loadVideos({ force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Promotes the slide at `index` to active: syncs the window, updates the
|
||||
// active styling, plays it, preloads the next PRELOAD_COUNT, and tears down
|
||||
// playback for everything else in the window.
|
||||
const setActive = function(index) {
|
||||
const clamped = clampIndex(index);
|
||||
if (clamped < 0) return;
|
||||
state.feedActiveIndex = clamped;
|
||||
const activeVideo = (state.loadedVideos || [])[clamped];
|
||||
state.feedActiveVideoId = activeVideo ? activeVideo.id : null;
|
||||
|
||||
syncWindow(clamped);
|
||||
|
||||
slidesByIndex.forEach((slide, i) => {
|
||||
slide.classList.toggle('is-active', i === clamped);
|
||||
});
|
||||
|
||||
const activeSlide = slidesByIndex.get(clamped);
|
||||
if (activeSlide) {
|
||||
loadSlideSource(activeSlide, activeSlide._videoData, true);
|
||||
requestAnimationFrame(() => measureFeedTitle(activeSlide));
|
||||
}
|
||||
|
||||
slidesByIndex.forEach((slide, i) => {
|
||||
if (i === clamped) return;
|
||||
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
||||
loadSlideSource(slide, slide._videoData, false);
|
||||
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
||||
// Recently-watched slides stay loaded but paused so scrolling
|
||||
// back resumes seamlessly from where it was paused.
|
||||
if (slide.classList.contains('is-loaded')) pauseSlide(slide);
|
||||
} else if (slide.classList.contains('is-loaded')) {
|
||||
destroySlidePlayback(slide);
|
||||
}
|
||||
});
|
||||
|
||||
prefetchIfNeeded(clamped);
|
||||
};
|
||||
|
||||
const onScroll = function() {
|
||||
wakeHud();
|
||||
if (scrollRaf) return;
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
scrollRaf = null;
|
||||
const scroller = getScroller();
|
||||
if (!scroller) return;
|
||||
// Ignore scroll events fired by a resize/orientation re-snap; the
|
||||
// active video is realigned by onResize instead (see suppressScroll).
|
||||
if (suppressScroll) return;
|
||||
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
||||
if (index < 0) return;
|
||||
if (index !== state.feedActiveIndex) {
|
||||
setActive(index);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Re-anchors the scroll position on the currently active video after the
|
||||
// viewport changes. The active slide is resolved by id (not by a possibly
|
||||
// stale scroll position) so an orientation change always keeps the same
|
||||
// video playing/focused rather than snapping to a neighbour.
|
||||
const realignToActive = function() {
|
||||
const total = (state.loadedVideos || []).length;
|
||||
if (total === 0) return;
|
||||
let index = state.feedActiveIndex;
|
||||
if (state.feedActiveVideoId != null) {
|
||||
const found = (state.loadedVideos || [])
|
||||
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
|
||||
if (found >= 0) index = found;
|
||||
}
|
||||
index = clampIndex(index);
|
||||
if (index < 0) return;
|
||||
state.feedActiveIndex = index;
|
||||
const h = slideHeight();
|
||||
const start = Math.max(0, index - HISTORY_COUNT);
|
||||
const spacer = getTopSpacer();
|
||||
if (spacer) spacer.style.height = `${start * h}px`;
|
||||
const scroller = getScroller();
|
||||
if (scroller) scroller.scrollTop = index * h;
|
||||
const activeSlide = slidesByIndex.get(index);
|
||||
if (activeSlide) measureFeedTitle(activeSlide);
|
||||
};
|
||||
|
||||
const onResize = function() {
|
||||
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
||||
// Suppress scroll handling while we realign so the container's re-snap
|
||||
// doesn't flip the active video, then re-enable it once layout settles.
|
||||
suppressScroll = true;
|
||||
realignToActive();
|
||||
// Orientation changes can settle over more than one frame (the visual
|
||||
// viewport and the scroll-snap re-anchor in stages); realign again once
|
||||
// layout has settled, then stop suppressing real swipes.
|
||||
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
|
||||
resizeSettleRaf = requestAnimationFrame(() => {
|
||||
realignToActive();
|
||||
resizeSettleRaf = requestAnimationFrame(() => {
|
||||
resizeSettleRaf = null;
|
||||
suppressScroll = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
App.feed.isOpen = function() {
|
||||
return !!state.feedOpen;
|
||||
};
|
||||
|
||||
// Re-applies the on-video-end preference to every rendered slide so toggling
|
||||
// the setting takes effect immediately, without needing to reopen the feed.
|
||||
App.feed.applyEndBehavior = function() {
|
||||
const loop = shouldLoop();
|
||||
slidesByIndex.forEach((slide) => {
|
||||
const video = slide.querySelector('.feed-video');
|
||||
if (video) video.loop = loop;
|
||||
});
|
||||
};
|
||||
|
||||
// Called whenever new video JSON is appended (e.g. after a prefetch). Lets
|
||||
// the open feed pick up newly buffered slides and extend its window if the
|
||||
// active slide is near the end.
|
||||
App.feed.renderSlides = function() {
|
||||
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
||||
setActive(state.feedActiveIndex);
|
||||
};
|
||||
|
||||
App.feed.reset = function() {
|
||||
slidesByIndex.forEach((slide) => {
|
||||
teardownSlide(slide);
|
||||
slide.remove();
|
||||
});
|
||||
slidesByIndex.clear();
|
||||
resumeTimes.clear();
|
||||
state.feedActiveIndex = -1;
|
||||
state.feedActiveVideoId = null;
|
||||
suppressScroll = false;
|
||||
if (resizeSettleRaf) {
|
||||
cancelAnimationFrame(resizeSettleRaf);
|
||||
resizeSettleRaf = null;
|
||||
}
|
||||
const spacer = getTopSpacer();
|
||||
if (spacer) spacer.style.height = '0px';
|
||||
const scroller = getScroller();
|
||||
if (scroller) scroller.scrollTop = 0;
|
||||
};
|
||||
|
||||
App.feed.open = function(startVideoId) {
|
||||
const container = document.getElementById('feed-view');
|
||||
const scroller = getScroller();
|
||||
if (!container || !scroller) return;
|
||||
state.feedOpen = true;
|
||||
|
||||
if (App.player && typeof App.player.close === 'function') {
|
||||
// fromPopState: true suppresses the player's own history.back()
|
||||
// -- this is an incidental "make sure it's closed" call when
|
||||
// switching to Reels view, not the user pressing the player's
|
||||
// close button, so it must not silently consume a back-button
|
||||
// entry out from under real browser navigation.
|
||||
App.player.close({ fromPopState: true });
|
||||
}
|
||||
|
||||
container.classList.add('open');
|
||||
container.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('feed-mode-open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
if (!scrollBound) {
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onResize);
|
||||
scrollBound = true;
|
||||
}
|
||||
|
||||
if (!hudActivityBound) {
|
||||
container.addEventListener('mousemove', wakeHud, { passive: true });
|
||||
container.addEventListener('pointerdown', wakeHud, { passive: true });
|
||||
container.addEventListener('touchstart', wakeHud, { passive: true });
|
||||
hudActivityBound = true;
|
||||
}
|
||||
|
||||
// Start from whichever grid video the user was looking at.
|
||||
let startIndex = 0;
|
||||
if (startVideoId != null) {
|
||||
const found = (state.loadedVideos || [])
|
||||
.findIndex((v) => String(v.id) === String(startVideoId));
|
||||
if (found >= 0) startIndex = found;
|
||||
}
|
||||
|
||||
// Force a fresh activation even if the index happens to match.
|
||||
state.feedActiveIndex = -1;
|
||||
setActive(startIndex);
|
||||
scroller.scrollTop = startIndex * slideHeight();
|
||||
|
||||
App.feed.updateToggleButton();
|
||||
App.feed.updateMuteButton();
|
||||
wakeHud();
|
||||
};
|
||||
|
||||
App.feed.close = function() {
|
||||
const container = document.getElementById('feed-view');
|
||||
if (!container) return;
|
||||
state.feedOpen = false;
|
||||
if (hudIdleTimer) {
|
||||
clearTimeout(hudIdleTimer);
|
||||
hudIdleTimer = null;
|
||||
}
|
||||
document.body.classList.remove('feed-hud-idle');
|
||||
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
||||
container.classList.remove('open');
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('feed-mode-open');
|
||||
document.body.style.overflow = 'auto';
|
||||
App.feed.updateToggleButton();
|
||||
};
|
||||
|
||||
App.feed.toggle = function() {
|
||||
if (state.feedOpen) {
|
||||
App.feed.close();
|
||||
} else {
|
||||
const focusedId = App.videos && typeof App.videos.getFocusedVideoId === 'function'
|
||||
? App.videos.getFocusedVideoId()
|
||||
: null;
|
||||
App.feed.open(focusedId);
|
||||
}
|
||||
};
|
||||
|
||||
App.feed.toggleMute = function() {
|
||||
state.feedMuted = !state.feedMuted;
|
||||
document.querySelectorAll('.feed-video').forEach((video) => {
|
||||
video.muted = state.feedMuted;
|
||||
});
|
||||
App.feed.updateMuteButton();
|
||||
};
|
||||
|
||||
App.feed.updateToggleButton = function() {
|
||||
const btn = document.getElementById('mode-toggle-btn');
|
||||
const icon = document.getElementById('mode-toggle-icon');
|
||||
if (!btn || !icon) return;
|
||||
const open = !!state.feedOpen;
|
||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||
const label = open ? 'Back to grid' : 'Switch to Reels view';
|
||||
btn.title = label;
|
||||
icon.alt = label;
|
||||
icon.src = open
|
||||
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/squares-2x2.svg'
|
||||
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg';
|
||||
};
|
||||
|
||||
App.feed.updateMuteButton = function() {
|
||||
const icon = document.getElementById('feed-mute-icon');
|
||||
if (!icon) return;
|
||||
icon.src = state.feedMuted
|
||||
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
|
||||
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
|
||||
icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
|
||||
// Pulse a brass ring while muted to hint "tap to hear sound".
|
||||
const btn = document.getElementById('feed-mute-btn');
|
||||
if (btn) btn.classList.toggle('is-muted', !!state.feedMuted);
|
||||
};
|
||||
})();
|
||||
79
frontend/js/hottubBackup.js
Normal file
79
frontend/js/hottubBackup.js
Normal file
@@ -0,0 +1,79 @@
|
||||
window.App = window.App || {};
|
||||
App.hottubBackup = App.hottubBackup || {};
|
||||
|
||||
// Reads a Hot Tub app backup (an exported SQLite database) and turns the videos
|
||||
// it has flagged as favorites into this client's favorites.
|
||||
//
|
||||
// The app and this client don't agree on identity: the app keys a video by a
|
||||
// hash it computes locally, while the server -- and so this client -- keys it by
|
||||
// something like "reddit-1rdudss". So an imported favorite is matched to an
|
||||
// existing one by URL, and carries no id of its own; see App.favorites.mergeImported.
|
||||
(function() {
|
||||
// The app stores a comma-separated set here ("favorite", "recent", ...).
|
||||
// It also keeps a `favoriteDate` on rows it no longer flags -- a leftover
|
||||
// from unfavoriting -- so the flag, not the date, is what counts.
|
||||
const FAVORITE_FLAG = 'favorite';
|
||||
|
||||
// Only what a favorite needs. Skipping the rest matters: `allFormats` alone
|
||||
// is kilobytes of resolved-format JSON per row, and it is exactly the kind
|
||||
// of thing this client must not store -- those URLs are signed and expire
|
||||
// (see App.favorites.normalize).
|
||||
const COLUMNS = ['url', 'title', 'thumb', 'duration', 'uploader', 'flags', 'favoriteDate'];
|
||||
|
||||
// The app writes a local-time stamp with no zone ("2025-04-25T20:26:02.144").
|
||||
// Read it as local time (which is what it was) and keep it as an instant, so
|
||||
// imported favorites sort against ones saved here. Unparseable or missing
|
||||
// dates fall back to now rather than to 1970, which would bury them.
|
||||
const toIsoDate = function(value) {
|
||||
const parsed = Date.parse(value || '');
|
||||
return isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString();
|
||||
};
|
||||
|
||||
const hasFavoriteFlag = function(flags) {
|
||||
if (!flags) return false;
|
||||
return String(flags).split(',').some((flag) => flag.trim().toLowerCase() === FAVORITE_FLAG);
|
||||
};
|
||||
|
||||
// Newest first, matching how favorites are ordered when added by hand.
|
||||
const byNewest = function(a, b) {
|
||||
return String(b.favoriteDate || '').localeCompare(String(a.favoriteDate || ''));
|
||||
};
|
||||
|
||||
App.hottubBackup.readFavorites = function(buffer) {
|
||||
const db = App.sqlite.open(buffer);
|
||||
if (db.tableNames().indexOf('video_details') < 0) {
|
||||
throw new Error('This database has no video_details table -- is it a Hot Tub backup?');
|
||||
}
|
||||
const rows = db.readTable('video_details', { columns: COLUMNS });
|
||||
return rows
|
||||
.filter((row) => row.url && hasFavoriteFlag(row.flags))
|
||||
.sort(byNewest)
|
||||
.map((row) => ({
|
||||
// No id: the app's own is meaningless to this client, and the
|
||||
// URL is what both sides agree on.
|
||||
key: row.url,
|
||||
id: null,
|
||||
url: row.url,
|
||||
title: row.title || '',
|
||||
thumb: row.thumb || '',
|
||||
channel: '',
|
||||
uploader: row.uploader || '',
|
||||
duration: Number(row.duration) || 0,
|
||||
isLive: false,
|
||||
favoriteDate: toIsoDate(row.favoriteDate)
|
||||
}));
|
||||
};
|
||||
|
||||
App.hottubBackup.readFile = function(file) {
|
||||
return file.arrayBuffer().then((buffer) => App.hottubBackup.readFavorites(buffer));
|
||||
};
|
||||
|
||||
// Reads the file and merges what it finds. Resolves to the merge summary
|
||||
// ({found, added, skipped, total}) so the caller can report it.
|
||||
App.hottubBackup.importFile = function(file) {
|
||||
return App.hottubBackup.readFile(file).then((entries) => {
|
||||
const result = App.favorites.mergeImported(entries);
|
||||
return Object.assign({ found: entries.length }, result);
|
||||
});
|
||||
};
|
||||
})();
|
||||
55
frontend/js/main.js
Normal file
55
frontend/js/main.js
Normal file
@@ -0,0 +1,55 @@
|
||||
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.applyPreferredQuality();
|
||||
App.ui.applyFeedEndBehavior();
|
||||
App.ui.applyDensity();
|
||||
// Set the text-size CSS variable before the first pack so initial card
|
||||
// heights are measured at the user's chosen size.
|
||||
document.documentElement.style.setProperty('--card-font-scale', App.storage.getFontScale());
|
||||
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({ force: true });
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// The UI above is rendered entirely from the last known status cached in
|
||||
// localStorage, so startup never blocks on (or breaks because of) a slow
|
||||
// or failing status endpoint. Now fetch fresh status in the background and
|
||||
// reconcile the UI with whatever comes back.
|
||||
App.storage.refreshServerStatusInBackground();
|
||||
|
||||
// Watch for frontend deploys and seamlessly reload/hot-swap changed assets.
|
||||
if (App.version && App.version.start) {
|
||||
App.version.start();
|
||||
}
|
||||
}
|
||||
|
||||
initApp();
|
||||
})();
|
||||
51
frontend/js/marquee.js
Normal file
51
frontend/js/marquee.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.App = window.App || {};
|
||||
App.marquee = App.marquee || {};
|
||||
|
||||
// A title is always one line. When it doesn't fit its box it scrolls sideways
|
||||
// instead of wrapping or being silently cut off.
|
||||
//
|
||||
// Four surfaces show a title that way -- the grid card, the favorites bar card,
|
||||
// the fullscreen player, the reels slide -- and they must scroll at the same
|
||||
// speed to look like one app, so the measurement lives here rather than being
|
||||
// written out again next to each of them. Whether a given title is *currently*
|
||||
// scrolling is the caller's business (see the `is-title-active` handling in
|
||||
// videos.js and favorites.js): a whole grid of marching text is unreadable, so
|
||||
// most callers animate only the title the reader is actually looking at.
|
||||
(function() {
|
||||
// Drive the duration off the distance so every title scrolls at the same
|
||||
// gentle rate rather than a fixed duration, which made longer titles whip
|
||||
// past. The floor keeps short ones from snapping.
|
||||
const SPEED_PX_PER_SEC = 28;
|
||||
const MIN_DURATION_S = 6;
|
||||
// Sub-pixel rounding isn't overflow worth animating.
|
||||
const OVERFLOW_SLACK_PX = 4;
|
||||
// Trailing space so the last word clears the edge before it wraps around.
|
||||
const TAIL_GAP_PX = 12;
|
||||
|
||||
// Measures `text` inside `wrap` and prepares the animation: sets
|
||||
// --marquee-distance / --marquee-duration on `text`, and marks `wrap` with
|
||||
// `has-marquee` so CSS can decide what to do about it. Returns whether the
|
||||
// title overflows -- callers use that to skip the bookkeeping (scroll
|
||||
// observers, hover handlers) that only scrolling titles need.
|
||||
//
|
||||
// Reads layout, so call it when the element is in the document and visible;
|
||||
// a hidden element measures as zero-width and reports no overflow.
|
||||
App.marquee.measure = function(wrap, text) {
|
||||
if (!wrap || !text) return false;
|
||||
|
||||
const overflow = text.scrollWidth - wrap.clientWidth;
|
||||
if (overflow <= OVERFLOW_SLACK_PX) {
|
||||
wrap.classList.remove('has-marquee');
|
||||
text.style.removeProperty('--marquee-distance');
|
||||
text.style.removeProperty('--marquee-duration');
|
||||
return false;
|
||||
}
|
||||
|
||||
const distance = overflow + TAIL_GAP_PX;
|
||||
const duration = Math.max(MIN_DURATION_S, distance / SPEED_PX_PER_SEC);
|
||||
text.style.setProperty('--marquee-distance', `${distance}px`);
|
||||
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
||||
wrap.classList.add('has-marquee');
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
1043
frontend/js/player.js
Normal file
1043
frontend/js/player.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/js/sqlite.js
Normal file
BIN
frontend/js/sqlite.js
Normal file
Binary file not shown.
48
frontend/js/state.js
Normal file
48
frontend/js/state.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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,
|
||||
loadedVideos: [],
|
||||
feedOpen: false,
|
||||
feedMuted: true,
|
||||
feedActiveIndex: -1,
|
||||
feedActiveVideoId: null,
|
||||
groupCursors: null
|
||||
};
|
||||
|
||||
// Local storage keys used across modules.
|
||||
App.constants = {
|
||||
FAVORITES_KEY: 'favorites',
|
||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
||||
};
|
||||
|
||||
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||
// that only browse thumbnails, or that play native/MP4, never download it.
|
||||
// Resolves with window.Hls (or null if loading failed).
|
||||
App.ensureHls = function() {
|
||||
if (window.Hls) return Promise.resolve(window.Hls);
|
||||
if (App._hlsPromise) return App._hlsPromise;
|
||||
App._hlsPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5';
|
||||
script.async = true;
|
||||
script.onload = () => resolve(window.Hls || null);
|
||||
script.onerror = () => {
|
||||
App._hlsPromise = null;
|
||||
reject(new Error('Failed to load hls.js'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return App._hlsPromise;
|
||||
};
|
||||
387
frontend/js/storage.js
Normal file
387
frontend/js/storage.js
Normal file
@@ -0,0 +1,387 @@
|
||||
window.App = window.App || {};
|
||||
App.storage = App.storage || {};
|
||||
App.session = App.session || {};
|
||||
|
||||
(function() {
|
||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY, FEED_END_BEHAVIOR_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.getPreferredQuality = function() {
|
||||
return localStorage.getItem(PREFERRED_QUALITY_KEY) || '1080';
|
||||
};
|
||||
|
||||
App.storage.setPreferredQuality = function(nextQuality) {
|
||||
localStorage.setItem(PREFERRED_QUALITY_KEY, nextQuality);
|
||||
};
|
||||
|
||||
// Reels/TikTok mode behavior when a video reaches its end: 'loop' replays
|
||||
// the same clip; 'scroll' advances to the next video. Defaults to 'loop'.
|
||||
App.storage.getFeedEndBehavior = function() {
|
||||
return localStorage.getItem(FEED_END_BEHAVIOR_KEY) === 'scroll' ? 'scroll' : 'loop';
|
||||
};
|
||||
|
||||
App.storage.setFeedEndBehavior = function(nextBehavior) {
|
||||
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
|
||||
};
|
||||
|
||||
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
|
||||
App.storage.getDensity = function() {
|
||||
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
|
||||
};
|
||||
|
||||
App.storage.setDensity = function(nextDensity) {
|
||||
localStorage.setItem('density', nextDensity === 'compact' ? 'compact' : 'comfortable');
|
||||
};
|
||||
|
||||
// User-tunable card width / text size multipliers (default 1.0). Clamped so a
|
||||
// stale or hand-edited value can never break the layout.
|
||||
const clampScale = function(value, min, max, fallback) {
|
||||
const n = parseFloat(value);
|
||||
if (!isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
};
|
||||
|
||||
App.storage.getCardScale = function() {
|
||||
return clampScale(localStorage.getItem('cardScale'), 0.7, 1.5, 1);
|
||||
};
|
||||
|
||||
App.storage.setCardScale = function(next) {
|
||||
localStorage.setItem('cardScale', clampScale(next, 0.7, 1.5, 1));
|
||||
};
|
||||
|
||||
App.storage.getFontScale = function() {
|
||||
return clampScale(localStorage.getItem('fontScale'), 0.8, 1.4, 1);
|
||||
};
|
||||
|
||||
App.storage.setFontScale = function(next) {
|
||||
localStorage.setItem('fontScale', clampScale(next, 0.8, 1.4, 1));
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Synthetic filter id used to let a channel group expose its member
|
||||
// channels as a toggleable multi-select, so the user can browse "All <group>"
|
||||
// while disabling individual channels.
|
||||
App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels';
|
||||
|
||||
// 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 fallback = optionGroup.selectAllDefault ? allOptions.slice() : allOptions.slice(0, 1);
|
||||
if (Array.isArray(savedValue)) {
|
||||
const selected = allOptions.filter((opt) => savedValue.includes(opt.id));
|
||||
hydrated[optionGroup.id] = selected.length > 0 ? selected : fallback;
|
||||
} else {
|
||||
hydrated[optionGroup.id] = fallback;
|
||||
}
|
||||
} else {
|
||||
const selected = allOptions.find((opt) => opt.id === savedValue) || allOptions[0];
|
||||
if (selected) hydrated[optionGroup.id] = selected;
|
||||
}
|
||||
});
|
||||
return hydrated;
|
||||
};
|
||||
|
||||
// Builds a pseudo-channel representing a whole channel group, used so the
|
||||
// rest of the app (session, filters, video loading) can treat a selected
|
||||
// group the same way it treats a single channel.
|
||||
App.session.buildGroupChannel = function(group, channels) {
|
||||
if (!group) return null;
|
||||
const knownIds = new Set((channels || []).map((channel) => channel.id));
|
||||
const channelIds = Array.isArray(group.channelIds) ?
|
||||
group.channelIds.filter((id) => knownIds.has(id)) :
|
||||
[];
|
||||
const channelOptions = channelIds.map((id) => {
|
||||
const channel = (channels || []).find((ch) => ch.id === id);
|
||||
return { id: id, title: (channel && (channel.name || channel.id)) || id };
|
||||
});
|
||||
return {
|
||||
id: `group:${group.id}`,
|
||||
name: group.title || group.id,
|
||||
isGroup: true,
|
||||
groupId: group.id,
|
||||
channelIds: channelIds,
|
||||
// Expose member channels as a multi-select filter (all on by
|
||||
// default) so the user can disable individual channels while
|
||||
// browsing the whole group.
|
||||
options: channelOptions.length > 0 ? [{
|
||||
id: App.session.GROUP_CHANNELS_OPTION_ID,
|
||||
title: 'Channels',
|
||||
multiSelect: true,
|
||||
selectAllDefault: true,
|
||||
options: channelOptions
|
||||
}] : []
|
||||
};
|
||||
};
|
||||
|
||||
// Resolves a stored channel id (which may reference a single channel or a
|
||||
// "group:<id>" pseudo-channel) against a server's status payload.
|
||||
App.session.resolveChannelById = function(serverData, channelId) {
|
||||
if (!serverData || !channelId) return null;
|
||||
const channels = Array.isArray(serverData.channels) ? serverData.channels : [];
|
||||
if (channelId.startsWith('group:')) {
|
||||
const groupId = channelId.slice('group:'.length);
|
||||
const groups = Array.isArray(serverData.channelGroups) ? serverData.channelGroups : [];
|
||||
const group = groups.find((g) => g.id === groupId);
|
||||
return App.session.buildGroupChannel(group, channels);
|
||||
}
|
||||
return channels.find((channel) => channel.id === channelId) || null;
|
||||
};
|
||||
|
||||
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.selectAllDefault ?
|
||||
optionGroup.options.slice() :
|
||||
[optionGroup.options[0]];
|
||||
} else {
|
||||
selected[optionGroup.id] = optionGroup.options[0];
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
};
|
||||
|
||||
// Ensures defaults exist and establishes a session from cached status.
|
||||
// Intentionally does NOT touch the network: the last known status of every
|
||||
// server is persisted in localStorage, so the UI can render instantly from
|
||||
// it. Fresh status is fetched separately (and non-blockingly) via
|
||||
// refreshServerStatusInBackground().
|
||||
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(PREFERRED_QUALITY_KEY)) {
|
||||
localStorage.setItem(PREFERRED_QUALITY_KEY, '1080');
|
||||
}
|
||||
if (!localStorage.getItem(FAVORITES_KEY)) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify([]));
|
||||
}
|
||||
if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) {
|
||||
localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true');
|
||||
}
|
||||
if (!localStorage.getItem(FEED_END_BEHAVIOR_KEY)) {
|
||||
localStorage.setItem(FEED_END_BEHAVIOR_KEY, 'loop');
|
||||
}
|
||||
App.storage.ensureSessionFromCache();
|
||||
};
|
||||
|
||||
// A stable fingerprint of which server/channel a session targets, used to
|
||||
// decide whether a status refresh actually changed what's being shown (and
|
||||
// thus whether videos need reloading).
|
||||
function sessionSignature(session) {
|
||||
if (!session) return '';
|
||||
return `${session.server}::${session.channel ? session.channel.id : ''}`;
|
||||
}
|
||||
|
||||
// Builds a session pointing at a valid channel/options using ONLY the status
|
||||
// data already cached in `config` (no network). Returns the session object,
|
||||
// or null if no server in the config currently exposes any channels.
|
||||
App.session.buildSessionFromCache = function(config) {
|
||||
if (!config || !Array.isArray(config.servers) || config.servers.length === 0) return null;
|
||||
const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]);
|
||||
const existingSession = App.storage.getSession();
|
||||
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 || !Array.isArray(serverData.channels) || serverData.channels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[selectedServerKey] || {};
|
||||
const channel = App.session.resolveChannelById(serverData, serverPrefs.channelId) || serverData.channels[0];
|
||||
const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null;
|
||||
const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel);
|
||||
return {
|
||||
server: selectedServerKey,
|
||||
channel: channel,
|
||||
options: options,
|
||||
};
|
||||
};
|
||||
|
||||
// Ensures the stored session points at a channel that still exists in the
|
||||
// cached status, rebuilding it from cache if necessary. Never clears a valid
|
||||
// selection. Returns true if a usable session exists afterwards.
|
||||
App.storage.ensureSessionFromCache = function() {
|
||||
const config = App.storage.getConfig();
|
||||
const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]);
|
||||
const existingSession = App.storage.getSession();
|
||||
|
||||
// Leave a still-valid session untouched so we don't disturb the user's
|
||||
// current server/channel selection on refresh.
|
||||
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {
|
||||
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === existingSession.server);
|
||||
const serverData = serverEntry ? serverEntry[existingSession.server] : null;
|
||||
if (serverData && App.session.resolveChannelById(serverData, existingSession.channel.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionData = App.session.buildSessionFromCache(config);
|
||||
if (sessionData) {
|
||||
App.storage.setSession(sessionData);
|
||||
App.session.savePreference(sessionData);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Fetches fresh server status and merges it into the cached config. Crucially,
|
||||
// a failed status request preserves the server's LAST KNOWN status (channels,
|
||||
// groups, etc.) instead of wiping it -- so a flaky/down status endpoint can no
|
||||
// longer brick the app. Returns true if the active session's target changed
|
||||
// (e.g. channels appeared for the first time), signalling a video reload.
|
||||
App.storage.initializeServerStatus = async function() {
|
||||
const config = JSON.parse(localStorage.getItem('config'));
|
||||
if (!config || !config.servers) return false;
|
||||
|
||||
const fetchDirectStatus = async (server) => {
|
||||
const directUrl = `${server.replace(/\/$/, '')}/api/status`;
|
||||
const response = await fetch(directUrl);
|
||||
if (!response.ok) throw new Error(`Direct status request failed: ${response.status}`);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const fetchProxiedStatus = async (server) => {
|
||||
const response = await fetch(`/api/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
server: server
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`Proxied status request failed: ${response.status}`);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const statusPromises = config.servers.map(async (serverObj) => {
|
||||
const server = Object.keys(serverObj)[0];
|
||||
const prior = serverObj[server];
|
||||
try {
|
||||
// Try a direct request first, then fall back to the server-side proxy.
|
||||
try {
|
||||
serverObj[server] = await fetchDirectStatus(server);
|
||||
} catch (directErr) {
|
||||
serverObj[server] = await fetchProxiedStatus(server);
|
||||
}
|
||||
} catch (err) {
|
||||
// The request failed. Keep the last known good status so the user
|
||||
// doesn't lose their channels when the status endpoint is down;
|
||||
// just flag it offline. Only fall back to an empty stub when we've
|
||||
// never successfully fetched this server.
|
||||
if (prior && Array.isArray(prior.channels) && prior.channels.length > 0) {
|
||||
serverObj[server] = Object.assign({}, prior, { online: false });
|
||||
} else {
|
||||
serverObj[server] = {
|
||||
online: false,
|
||||
channels: []
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statusPromises);
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
|
||||
const before = sessionSignature(App.storage.getSession());
|
||||
App.storage.ensureSessionFromCache();
|
||||
const after = sessionSignature(App.storage.getSession());
|
||||
return before !== after;
|
||||
};
|
||||
|
||||
// Refreshes server status without blocking; updates the menu and reloads
|
||||
// videos only if the refresh actually changed the active selection. Safe to
|
||||
// fire-and-forget during startup so the UI renders from cache immediately.
|
||||
App.storage.refreshServerStatusInBackground = function() {
|
||||
return App.storage.initializeServerStatus()
|
||||
.then((changed) => {
|
||||
if (App.ui && typeof App.ui.renderMenu === 'function') {
|
||||
App.ui.renderMenu();
|
||||
}
|
||||
if (changed && App.videos && typeof App.videos.resetAndReload === 'function') {
|
||||
App.videos.resetAndReload();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Background status refresh failed:', err);
|
||||
});
|
||||
};
|
||||
})();
|
||||
831
frontend/js/ui.js
Normal file
831
frontend/js/ui.js
Normal file
@@ -0,0 +1,831 @@
|
||||
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;
|
||||
};
|
||||
|
||||
App.ui.applyPreferredQuality = function() {
|
||||
const select = document.getElementById('quality-select');
|
||||
if (select) select.value = App.storage.getPreferredQuality();
|
||||
};
|
||||
|
||||
App.ui.applyFeedEndBehavior = function() {
|
||||
const select = document.getElementById('feed-end-select');
|
||||
if (select) select.value = App.storage.getFeedEndBehavior();
|
||||
};
|
||||
|
||||
App.ui.applyDensity = function() {
|
||||
const density = App.storage.getDensity();
|
||||
document.body.dataset.density = density;
|
||||
const select = document.getElementById('density-select');
|
||||
if (select) select.value = density;
|
||||
};
|
||||
|
||||
// Card Size: re-packs the virtual grid (column count derives from the scaled
|
||||
// minimum card width in videos.js).
|
||||
App.ui.applyCardScale = function() {
|
||||
const scale = App.storage.getCardScale();
|
||||
const range = document.getElementById('card-size-range');
|
||||
if (range) range.value = scale;
|
||||
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
||||
App.virtualGrid.relayout();
|
||||
}
|
||||
};
|
||||
|
||||
// Text Size: drives the --card-font-scale CSS variable; a re-pack follows so
|
||||
// card heights account for the new text size.
|
||||
App.ui.applyFontScale = function() {
|
||||
const scale = App.storage.getFontScale();
|
||||
document.documentElement.style.setProperty('--card-font-scale', scale);
|
||||
const range = document.getElementById('text-size-range');
|
||||
if (range) range.value = scale;
|
||||
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
||||
App.virtualGrid.relayout();
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Which video the panel is currently showing, so a slow resolve that lands
|
||||
// after the user moved on doesn't redraw someone else's panel.
|
||||
let infoVideo = null;
|
||||
|
||||
const appendInfoHeading = function(list, label) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'info-section';
|
||||
heading.textContent = label;
|
||||
list.appendChild(heading);
|
||||
};
|
||||
|
||||
// One row per field, whatever the field is. Objects and arrays are printed
|
||||
// as JSON rather than summarised: the panel is the place to see exactly what
|
||||
// the server said, so nothing is dropped or abbreviated here.
|
||||
const appendInfoRows = function(list, data) {
|
||||
let count = 0;
|
||||
Object.entries(data || {}).forEach(([key, value]) => {
|
||||
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);
|
||||
count++;
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
// Shows every field the client holds for a video: the listing item's own
|
||||
// (id, title, uploader, duration, tags, ...) and then the extractor's, which
|
||||
// arrive separately. It used to show `video.meta` *instead of* the item once
|
||||
// one had been resolved, which silently hid everything the listing knew the
|
||||
// moment a card had been hovered.
|
||||
// `options.info` is the full extractor payload (App.videos.fetchFullInfo);
|
||||
// `options.pending` notes that it's still on its way.
|
||||
App.ui.showInfo = function(video, options) {
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
const opts = options || {};
|
||||
const title = document.getElementById('info-title');
|
||||
const list = document.getElementById('info-list');
|
||||
const empty = document.getElementById('info-empty');
|
||||
|
||||
const item = (video && typeof video === 'object') ? video : {};
|
||||
// `meta` is the trimmed playback payload; the full extractor info is a
|
||||
// superset of it, so only one of the two is ever shown.
|
||||
const resolved = opts.info || item.meta || null;
|
||||
|
||||
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
|
||||
|
||||
let rows = 0;
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
// `meta` gets its own section below rather than a row of JSON.
|
||||
const own = Object.assign({}, item);
|
||||
delete own.meta;
|
||||
rows += appendInfoRows(list, own);
|
||||
|
||||
if (resolved && typeof resolved === 'object') {
|
||||
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
|
||||
rows += appendInfoRows(list, resolved);
|
||||
}
|
||||
|
||||
if (opts.pending) {
|
||||
const pending = document.createElement('div');
|
||||
pending.className = 'info-pending';
|
||||
pending.textContent = 'Resolving full metadata…';
|
||||
list.appendChild(pending);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = rows ? 'none' : 'block';
|
||||
}
|
||||
|
||||
modal.classList.add('open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
};
|
||||
|
||||
// Opens the panel on what the client already has, then redraws it with the
|
||||
// extractor's full payload once that lands. Resolution runs yt-dlp against
|
||||
// the source site and can take seconds; there's no reason to stare at
|
||||
// nothing (or at a spinner) while it does.
|
||||
App.ui.openInfo = function(video) {
|
||||
infoVideo = video;
|
||||
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
|
||||
App.ui.showInfo(video, { pending: canResolve });
|
||||
if (!canResolve) return;
|
||||
App.videos.fetchFullInfo(video).then((info) => {
|
||||
if (infoVideo !== video) return; // the panel moved on, or closed
|
||||
App.ui.showInfo(video, { info: info });
|
||||
});
|
||||
};
|
||||
|
||||
App.ui.closeInfo = function() {
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
infoVideo = null;
|
||||
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 selectedServerData = selectedServer && selectedServer.data ? selectedServer.data : null;
|
||||
const channels = selectedServerData && selectedServerData.channels ? selectedServerData.channels : [];
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[selectedServerUrl] || {};
|
||||
const preferredChannel = selectedServerData ?
|
||||
App.session.resolveChannelById(selectedServerData, 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 activeServerData = activeServer && activeServer.data ? activeServer.data : null;
|
||||
const availableChannels = activeServerData && activeServerData.channels ?
|
||||
[...activeServerData.channels] :
|
||||
[];
|
||||
availableChannels.sort((a, b) => {
|
||||
const nameA = (a.name || a.id || '').toLowerCase();
|
||||
const nameB = (b.name || b.id || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
const channelGroups = activeServerData && Array.isArray(activeServerData.channelGroups) ?
|
||||
activeServerData.channelGroups :
|
||||
[];
|
||||
|
||||
channelSelect.innerHTML = "";
|
||||
const groupedChannelIds = new Set();
|
||||
channelGroups.forEach((group) => {
|
||||
const channelIds = Array.isArray(group.channelIds) ?
|
||||
group.channelIds.filter((id) => availableChannels.some((channel) => channel.id === id)) :
|
||||
[];
|
||||
if (channelIds.length === 0) return;
|
||||
channelIds.forEach((id) => groupedChannelIds.add(id));
|
||||
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = group.title || group.id;
|
||||
|
||||
const groupOption = document.createElement('option');
|
||||
groupOption.value = `group:${group.id}`;
|
||||
groupOption.textContent = `All ${group.title || group.id}`;
|
||||
optgroup.appendChild(groupOption);
|
||||
|
||||
channelIds.forEach((id) => {
|
||||
const channel = availableChannels.find((ch) => ch.id === id);
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.id;
|
||||
option.textContent = channel.name || channel.id;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
|
||||
channelSelect.appendChild(optgroup);
|
||||
});
|
||||
|
||||
availableChannels
|
||||
.filter((channel) => !groupedChannelIds.has(channel.id))
|
||||
.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 = activeServerData ? App.session.resolveChannelById(activeServerData, 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();
|
||||
};
|
||||
}
|
||||
|
||||
const qualitySelect = document.getElementById('quality-select');
|
||||
if (qualitySelect) {
|
||||
qualitySelect.onchange = () => {
|
||||
App.storage.setPreferredQuality(qualitySelect.value);
|
||||
};
|
||||
}
|
||||
|
||||
const densitySelect = document.getElementById('density-select');
|
||||
if (densitySelect) {
|
||||
densitySelect.value = App.storage.getDensity();
|
||||
densitySelect.onchange = () => {
|
||||
App.storage.setDensity(densitySelect.value);
|
||||
App.ui.applyDensity();
|
||||
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
||||
App.virtualGrid.relayout();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const cardSizeRange = document.getElementById('card-size-range');
|
||||
if (cardSizeRange) {
|
||||
cardSizeRange.value = App.storage.getCardScale();
|
||||
cardSizeRange.oninput = () => {
|
||||
App.storage.setCardScale(cardSizeRange.value);
|
||||
App.ui.applyCardScale();
|
||||
};
|
||||
}
|
||||
|
||||
const textSizeRange = document.getElementById('text-size-range');
|
||||
if (textSizeRange) {
|
||||
textSizeRange.value = App.storage.getFontScale();
|
||||
textSizeRange.oninput = () => {
|
||||
App.storage.setFontScale(textSizeRange.value);
|
||||
App.ui.applyFontScale();
|
||||
};
|
||||
}
|
||||
|
||||
const feedEndSelect = document.getElementById('feed-end-select');
|
||||
if (feedEndSelect) {
|
||||
feedEndSelect.value = App.storage.getFeedEndBehavior();
|
||||
feedEndSelect.onchange = () => {
|
||||
App.storage.setFeedEndBehavior(feedEndSelect.value);
|
||||
if (App.feed && typeof App.feed.applyEndBehavior === 'function') {
|
||||
App.feed.applyEndBehavior();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 nextServerData = nextServer.data || null;
|
||||
const channels = nextServerData && nextServerData.channels ? nextServerData.channels : [];
|
||||
const nextChannel = (nextServerData && App.session.resolveChannelById(nextServerData, 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 = () => {
|
||||
// Refresh means "give me the current everything": the videos
|
||||
// below, and the app itself. The version check runs in the
|
||||
// background and only acts if the deployed assets actually
|
||||
// differ from what this tab is running.
|
||||
if (App.version && typeof App.version.checkNow === 'function') {
|
||||
App.version.checkNow();
|
||||
}
|
||||
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 = session && session.channel && session.channel.isGroup ?
|
||||
'No filters available when browsing a whole channel group.' :
|
||||
'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.
|
||||
// Settings -> Hot Tub Backup: pick an exported database and merge the
|
||||
// favorites out of it. Bound once (unlike the controls in renderMenu, which
|
||||
// are re-assigned on every render) because a file input mid-read must not
|
||||
// have its handler swapped underneath it.
|
||||
App.ui.bindBackupImport = function() {
|
||||
const button = document.getElementById('import-favorites-btn');
|
||||
const input = document.getElementById('import-favorites-file');
|
||||
const status = document.getElementById('import-favorites-status');
|
||||
if (!button || !input) return;
|
||||
|
||||
const say = (message) => { if (status) status.textContent = message; };
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
// Cleared first so picking the same file twice still fires change.
|
||||
input.value = '';
|
||||
input.click();
|
||||
});
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
button.disabled = true;
|
||||
say('Reading backup…');
|
||||
App.hottubBackup.importFile(file).then((result) => {
|
||||
if (!result.found) {
|
||||
say('No favorites found in that backup.');
|
||||
} else if (!result.added) {
|
||||
say(`Nothing new: all ${result.found} favorites in that backup are already saved.`);
|
||||
} else {
|
||||
const plural = result.added === 1 ? 'favorite' : 'favorites';
|
||||
const already = result.skipped ? ` ${result.skipped} were already saved.` : '';
|
||||
say(`Imported ${result.added} ${plural}.${already}`);
|
||||
}
|
||||
}).catch((err) => {
|
||||
say('Could not read that file.');
|
||||
App.ui.showError((err && err.message) || 'Could not read that backup.');
|
||||
}).then(() => {
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Favorites bar header: the sort order (which applies to the bar and the
|
||||
// favorites grid alike) and the toggle into that grid.
|
||||
App.ui.bindFavoritesControls = function() {
|
||||
const select = document.getElementById('favorites-sort');
|
||||
const button = document.getElementById('favorites-browse-btn');
|
||||
|
||||
if (select && !select.options.length) {
|
||||
App.favorites.SORTS.forEach((sort) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = sort.id;
|
||||
option.textContent = sort.label;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = App.favorites.getSort();
|
||||
select.addEventListener('change', () => App.favoritesView.applySort(select.value));
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', () => App.favoritesView.toggle());
|
||||
}
|
||||
App.favoritesView.syncControls();
|
||||
};
|
||||
|
||||
App.ui.bindGlobalHandlers = function() {
|
||||
App.ui.bindBackupImport();
|
||||
App.ui.bindFavoritesControls();
|
||||
|
||||
window.toggleDrawer = App.ui.toggleDrawer;
|
||||
window.closeDrawers = App.ui.closeDrawers;
|
||||
window.handleSearch = App.videos.handleSearch;
|
||||
|
||||
const modeToggleBtn = document.getElementById('mode-toggle-btn');
|
||||
if (modeToggleBtn) {
|
||||
modeToggleBtn.onclick = () => {
|
||||
App.feed.toggle();
|
||||
};
|
||||
}
|
||||
|
||||
const feedMuteBtn = document.getElementById('feed-mute-btn');
|
||||
if (feedMuteBtn) {
|
||||
feedMuteBtn.onclick = () => {
|
||||
App.feed.toggleMute();
|
||||
};
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const clearSearchBtn = document.getElementById('search-clear-btn');
|
||||
if (searchInput && clearSearchBtn) {
|
||||
let searchDebounce = null;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const updateClearVisibility = () => {
|
||||
const hasValue = searchInput.value.trim().length > 0;
|
||||
clearSearchBtn.classList.toggle('is-visible', hasValue);
|
||||
clearSearchBtn.disabled = !hasValue;
|
||||
};
|
||||
|
||||
clearSearchBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
if (!searchInput.value) return;
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
searchInput.value = '';
|
||||
updateClearVisibility();
|
||||
App.videos.handleSearch('');
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
// Update the clear button immediately for snappy feedback, but
|
||||
// debounce the actual reload so typing doesn't wipe the grid and
|
||||
// fire a backend request on every keystroke.
|
||||
searchInput.addEventListener('input', updateClearVisibility);
|
||||
searchInput.addEventListener('input', () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => {
|
||||
searchDebounce = null;
|
||||
App.videos.handleSearch(searchInput.value);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
updateClearVisibility();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
App.ui.closeDrawers();
|
||||
App.ui.closeInfo();
|
||||
App.videos.closeAllMenus();
|
||||
if (App.feed.isOpen()) {
|
||||
App.feed.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
155
frontend/js/version.js
Normal file
155
frontend/js/version.js
Normal file
@@ -0,0 +1,155 @@
|
||||
window.App = window.App || {};
|
||||
App.version = App.version || {};
|
||||
|
||||
(function() {
|
||||
const VERSION_URL = '/api/version';
|
||||
const POLL_INTERVAL_MS = 60000;
|
||||
|
||||
// Baseline manifest captured on startup: { version, files: { rel: hash } }.
|
||||
let baseline = null;
|
||||
let timer = null;
|
||||
// Set once a JS/HTML change is detected; the page reloads at a safe moment.
|
||||
let reloadPending = false;
|
||||
let checking = false;
|
||||
|
||||
async function fetchVersion() {
|
||||
const resp = await fetch(VERSION_URL, { cache: 'no-store' });
|
||||
if (!resp.ok) throw new Error('version fetch failed: ' + resp.status);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// Swap a stylesheet <link> in place using a cache-busted href so updated CSS
|
||||
// applies instantly. The old link is removed only after the new one loads to
|
||||
// avoid a flash of unstyled content.
|
||||
function hotReloadCss(relPath, hash) {
|
||||
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
||||
const match = links.find((l) => {
|
||||
const href = (l.getAttribute('href') || '').split('?')[0];
|
||||
return href.endsWith(relPath) || href.endsWith('/' + relPath);
|
||||
});
|
||||
if (!match) return false;
|
||||
const base = (match.getAttribute('href') || '').split('?')[0];
|
||||
const fresh = match.cloneNode(false);
|
||||
fresh.setAttribute('href', base + '?v=' + hash);
|
||||
fresh.addEventListener('load', () => { if (match.parentNode) match.remove(); });
|
||||
fresh.addEventListener('error', () => { if (fresh.parentNode) fresh.remove(); });
|
||||
match.parentNode.insertBefore(fresh, match.nextSibling);
|
||||
return true;
|
||||
}
|
||||
|
||||
function diffFiles(oldFiles, newFiles) {
|
||||
const changed = [];
|
||||
const keys = new Set([
|
||||
...Object.keys(oldFiles || {}),
|
||||
...Object.keys(newFiles || {})
|
||||
]);
|
||||
keys.forEach((k) => {
|
||||
if ((oldFiles || {})[k] !== (newFiles || {})[k]) changed.push(k);
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
// A reload is "safe" when the user isn't mid-playback: no open custom
|
||||
// player, no active reels feed, and no playing <video>. App state survives
|
||||
// a reload because it is restored from localStorage on boot.
|
||||
function isSafeToReload() {
|
||||
if (App.state && App.state.feedOpen) return false;
|
||||
const player = document.getElementById('custom-player');
|
||||
if (player && player.classList.contains('open')) {
|
||||
const video = player.querySelector('.cp-video');
|
||||
if (video && !video.paused && !video.ended) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showUpdateBanner() {
|
||||
const banner = document.getElementById('update-banner');
|
||||
if (!banner) return;
|
||||
banner.classList.add('show');
|
||||
const btn = document.getElementById('update-banner-btn');
|
||||
if (btn) btn.onclick = () => window.location.reload();
|
||||
}
|
||||
|
||||
function tryReloadWhenSafe() {
|
||||
if (!reloadPending) return;
|
||||
if (isSafeToReload()) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
showUpdateBanner();
|
||||
}
|
||||
}
|
||||
|
||||
function apply(latest) {
|
||||
const changed = diffFiles(baseline.files, latest.files);
|
||||
if (!changed.length) return;
|
||||
|
||||
let needsReload = false;
|
||||
changed.forEach((file) => {
|
||||
if (file.endsWith('.css') && hotReloadCss(file, latest.files[file])) {
|
||||
return; // hot-swapped without reload
|
||||
}
|
||||
// JS and HTML can't be safely live-patched; they require a reload.
|
||||
needsReload = true;
|
||||
});
|
||||
|
||||
// Adopt the new manifest so we don't re-trigger on the same change.
|
||||
baseline = latest;
|
||||
|
||||
if (needsReload) {
|
||||
reloadPending = true;
|
||||
tryReloadWhenSafe();
|
||||
}
|
||||
}
|
||||
|
||||
async function check() {
|
||||
if (checking || !baseline) return;
|
||||
checking = true;
|
||||
try {
|
||||
const latest = await fetchVersion();
|
||||
apply(latest);
|
||||
} catch (e) {
|
||||
// Network blips are non-fatal; we retry on the next tick.
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same check the poller runs, on demand: the top-bar refresh button asks for
|
||||
// it so a tab left open across a deploy picks the new build up right then,
|
||||
// rather than up to POLL_INTERVAL_MS later. Changed CSS hot-swaps; changed
|
||||
// JS/HTML reloads as soon as that won't interrupt playback.
|
||||
App.version.checkNow = function() {
|
||||
if (!baseline) {
|
||||
// start() never got a manifest (endpoint down, or it hasn't run
|
||||
// yet). Adopt whatever the server reports now so there's something
|
||||
// to diff against next time -- there's no baseline to compare this
|
||||
// one against, so nothing can be concluded from it today.
|
||||
return fetchVersion().then((latest) => { baseline = latest; }).catch(() => {});
|
||||
}
|
||||
return check();
|
||||
};
|
||||
|
||||
App.version.start = async function() {
|
||||
try {
|
||||
baseline = await fetchVersion();
|
||||
} catch (e) {
|
||||
return; // endpoint unavailable; skip version checking entirely
|
||||
}
|
||||
timer = setInterval(check, POLL_INTERVAL_MS);
|
||||
// Check promptly when the user returns to the tab so updates land while
|
||||
// they were away, and retry a pending reload once playback stops.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tryReloadWhenSafe();
|
||||
check();
|
||||
}
|
||||
});
|
||||
// Re-attempt a deferred reload whenever a video finishes/pauses. The
|
||||
// custom player's <video> is torn down and rebuilt on every open(), so
|
||||
// bind on the capture phase at the document level instead of to a
|
||||
// specific element (media events don't bubble, but capture still sees
|
||||
// them on ancestors).
|
||||
document.addEventListener('pause', tryReloadWhenSafe, true);
|
||||
document.addEventListener('ended', tryReloadWhenSafe, true);
|
||||
};
|
||||
})();
|
||||
1904
frontend/js/videos.js
Normal file
1904
frontend/js/videos.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
:root { --bg: #0f0f0f; --text: #fff; --accent: #3d3d3d; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: sans-serif; overflow-x: hidden; }
|
||||
|
||||
.top-bar {
|
||||
height: 60px; display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0 20px; background: #202020; position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px; padding: 20px;
|
||||
}
|
||||
|
||||
.video-card { cursor: pointer; transition: transform 0.2s; }
|
||||
.video-card img { width: 100%; border-radius: 12px; }
|
||||
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: -300px; width: 300px; height: 100%;
|
||||
background: #1e1e1e; z-index: 1000; transition: 0.3s; padding: 20px;
|
||||
}
|
||||
.drawer.open { right: 0; }
|
||||
|
||||
#overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
||||
display: none; z-index: 999;
|
||||
}
|
||||
|
||||
.modal { display: none; position: fixed; inset: 0; background: #000; z-index: 2000; }
|
||||
.modal-content { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; }
|
||||
.close { position: absolute; top: 20px; right: 20px; color: #fff; font-size: 28px; cursor: pointer; background: rgba(0,0,0,0.5); border-radius: 50%; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; z-index: 2001; }
|
||||
.close:hover { background: rgba(255,255,255,0.2); }
|
||||
video { width: 100%; height: 100%; max-width: 100%; max-height: 100vh; object-fit: contain; }
|
||||
1
media_srv2.log
Normal file
1
media_srv2.log
Normal file
@@ -0,0 +1 @@
|
||||
/bin/bash: line 1: cd: too many arguments
|
||||
Reference in New Issue
Block a user