diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f2acf..141d820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,137 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## v4.9.7 - 2026-07-03 + +### Added + +* Added **Mini Guide overlay**. + + * Added a compact, semi-transparent channel list overlay that slides in over the video player. + * Shows nearby channels around the currently playing channel. + * Displays channel number, logo, channel name, current program title, program time range, remaining time, and progress bar. + * Highlights the currently playing channel. + * Added keyboard support: + * `G` toggles the Mini Guide. + * `↑` / `↓` navigates channels. + * `Enter` tunes the selected channel. + * `Escape` closes the Mini Guide. + * Added mouse support for clicking a row to tune directly. + * Auto-dismisses after 8 seconds of inactivity, with pause-on-hover behavior. + * Added the **☰ Mini Guide** player button next to the Channel Info button. + * Added `static/js/mini-guide.js`. + +* Added **Mini Guide Layout** option. + + * Added a persistent per-user guide layout preference: `full` or `mini`. + * Added desktop and mobile header menu controls for switching guide layout. + * Added a compact full-page Mini Guide layout for TV/remote-control style navigation. + * Added admin user preference support for setting a user’s default guide layout. + * Updated TV remote navigation to work with both the regular guide grid and the Mini Guide layout. + +* Added **Channel Health Indicators** to Admin Diagnostics. + + * Added **Admin Diagnostics → Health → Channel Stream Reachability**. + * Added `/api/channel_health` POST endpoint for lightweight stream reachability checks. + * Performs HTTP `HEAD` checks with a short streaming `GET` fallback. + * Uses a 4-second timeout per channel. + * Runs checks in parallel with up to 10 workers. + * Caps each request batch at 50 channels. + * Reports channel status as: + * `up` + * `down` + * `virtual` + * `pending` + * Displays colored health dots in the diagnostics channel table: + * Green = stream up + * Red = stream down + * Blue = virtual/non-HTTP/no stream URL + * Gray = pending + +* Added **What’s On Now** page. + + * Added `/whats-on-now` page. + * Added `/api/whats_on_now` endpoint. + * Shows all channels with their currently airing program. + * Includes channel logos, channel numbers, group names, program titles, descriptions, time ranges, and progress bars. + * Added search/filter support for channels and programs. + * Added group filtering. + * Auto-refreshes the page data every 60 seconds. + * Clicking a channel card stores the selected channel in `sessionStorage` and opens the guide with that channel selected. + * Added `templates/whats_on_now.html`. + * Added `static/css/whats_on_now.css`. + +* Added **Program Reminders**. + + * Added reminder support for future guide programs. + * Users can right-click an upcoming program to set or remove a reminder. + * Reminders are persisted server-side in user preferences. + * Added browser notification support for reminders. + * Added reminders panel from the desktop and mobile header menus. + * Added reminder count display in the Reminders menu label. + * Added bell indicators on programs with active reminders. + * Added reminder sanitization and a maximum of 50 reminders per user. + * Added `static/js/reminders.js`. + +* Added **Retro Remote overlay**. + + * Added a remote-control overlay on the guide page. + * Added desktop and mobile remote buttons using the new remote icon. + * Added D-pad controls, OK/select, channel up/down, last channel, fullscreen, back/clear, and numeric entry buttons. + * Added support for dispatching keyboard-style remote events into the existing guide navigation flow. + * Added TV-mode sizing for the remote overlay. + * Added `static/js/retro-remote.js`. + * Added `static/img/remote_icon.png`. + +* Added **Browse Mode preference plumbing**. + + * Added `browse_mode_enabled` to user preferences. + * Added desktop and mobile header toggles for enabling/disabling Browse Mode. + * Added body class handling for `browse-mode`. + * Exposed browse mode state through `window.__browseModeEnabled`. + +* Added **HLS startup retry handling**. + + * Added startup retry state for HLS playback. + * Retries HLS startup network errors with `hlsInstance.startLoad()`. + * Uses up to 12 retry attempts with a 1-second retry delay. + * Intended to reduce initial player stalls while HLS segments are still becoming available. + +### Changed + +* Updated app version metadata from `v4.9.6` to `v4.9.7`. +* Updated release date metadata from `2026-06-11` to `2026-06-22`. +* Updated `ROADMAP.md` current version to v4.9.7. +* Marked the following roadmap items complete: + * Channel health indicators + * Mini Guide overlay + * Reminders/notifications +* Updated the guide header menus with: + * What’s On Now + * Browse Mode + * Mini Guide Layout + * Reminders + * Remote overlay button +* Updated TV remote navigation to support Mini Guide layout rows. +* Updated user preference validation to sanitize: + * `browse_mode_enabled` + * `guide_layout` + * `reminders` +* Updated Manage Users to allow setting a user’s default guide layout. +* Updated Channel Info Banner tests to include delayed HLS startup behavior. + +### Tests + +* Added tests for Channel Health. +* Added tests for Mini Guide overlay and Mini Guide layout. +* Added tests for Program Reminders. +* Added tests for Retro Remote overlay. +* Added tests for What’s On Now. +* Updated user preference tests for new preference keys and validation behavior. +* Updated Channel Info Banner tests for HLS startup retry handling. + +--- + ## v4.9.6 - 2026-06-11 ### Added diff --git a/README.md b/README.md index d41308c..b0004cd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
- No reminders set. Click a channel on the left to start playback.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index bef6459..46668aa 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,7 +4,7 @@ This document tracks **planned upgrades** and ideas for improving the IPTV Flask
These are **not yet implemented**, partially implemented, or completed in previous releases.
---
-# Current Version: **v4.9.6 (2026-06-11)**
+# Current Version: **v4.9.7 (2026-07-03)**
---
@@ -47,10 +47,10 @@ These are **not yet implemented**, partially implemented, or completed in previo
- [x] Last channel return *(v4.9.6)*
- [ ] Browse mode
- [ ] "What's On Now" view
-- [ ] Channel health indicators (lightweight only)
-- [ ] Mini Guide overlay
+- [x] Channel health indicators (lightweight only) *(v4.9.7)*
+- [x] Mini Guide overlay *(v4.9.7)*
- [x] Missing EPG fallback *(v3.0.1)*
-- [ ] Reminders/notifications
+- [x] Reminders/notifications *(v4.9.7)*
- [ ] EPG caching
---
diff --git a/app.py b/app.py
index 1d96648..3c519d5 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,6 @@
# app.py — merged version (features from both sources)
-APP_VERSION = "v4.9.6"
-APP_RELEASE_DATE = "2026-06-11"
+APP_VERSION = "v4.9.7"
+APP_RELEASE_DATE = "2026-07-03"
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort, make_response
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
@@ -635,9 +635,14 @@ def inject_tuner_context():
"hidden_channels": [],
"favorite_channels": [],
"channel_numbers_enabled": False,
+ "browse_mode_enabled": False,
+ "guide_layout": "full",
"default_theme": None,
+ "reminders": [],
}
+_MAX_REMINDERS = 50
+
def get_user_prefs(username):
"""Return the stored preferences for *username*, merged with defaults."""
@@ -4049,9 +4054,13 @@ def manage_users():
ch_name = request.form.get('auto_load_channel_name', '').strip() or ch_id
auto_load = {"id": ch_id, "name": ch_name} if ch_id else None
raw_theme = request.form.get('default_theme', '').strip() or None
+ guide_layout = request.form.get('guide_layout', '').strip()
+ if guide_layout not in ("full", "mini"):
+ guide_layout = "full"
patch = {
"auto_load_channel": auto_load,
"default_theme": raw_theme,
+ "guide_layout": guide_layout,
}
if request.form.get('update_hidden_channels'):
patch["hidden_channels"] = request.form.getlist('hidden_channel_ids')
@@ -4702,6 +4711,49 @@ def api_user_prefs_post():
else False
)
+ # Sanitise browse_mode_enabled: must be a boolean
+ if "browse_mode_enabled" in data:
+ data["browse_mode_enabled"] = (
+ data["browse_mode_enabled"]
+ if isinstance(data["browse_mode_enabled"], bool)
+ else False
+ )
+
+ # Sanitise guide_layout: must be one of the supported guide views
+ if "guide_layout" in data:
+ data["guide_layout"] = (
+ data["guide_layout"]
+ if data["guide_layout"] in ("full", "mini")
+ else "full"
+ )
+
+ # Sanitise reminders: must be a list of valid reminder objects, capped at _MAX_REMINDERS
+ if "reminders" in data:
+ raw = data["reminders"]
+ if not isinstance(raw, list):
+ data["reminders"] = []
+ else:
+ clean = []
+ for item in raw:
+ if not isinstance(item, dict):
+ continue
+ rid = item.get("id")
+ cid = item.get("channel_id")
+ if not rid or not isinstance(rid, str) or not cid or not isinstance(cid, str):
+ continue
+ notify_before = item.get("notify_before_mins", 5)
+ if not isinstance(notify_before, int) or not (0 <= notify_before <= 60):
+ notify_before = 5
+ clean.append({
+ "id": str(rid)[:64],
+ "channel_id": str(cid)[:256],
+ "channel_name": str(item.get("channel_name") or "")[:256],
+ "program_title": str(item.get("program_title") or "")[:512],
+ "program_start": str(item.get("program_start") or "")[:32],
+ "notify_before_mins": notify_before,
+ })
+ data["reminders"] = clean[:_MAX_REMINDERS]
+
save_user_prefs(current_user.username, data)
return jsonify({"status": "ok", "prefs": get_user_prefs(current_user.username)})
@@ -4725,7 +4777,82 @@ def api_channels():
})
return jsonify({'channels': out, 'timestamp': datetime.now(timezone.utc).isoformat()})
-@app.route('/api/news', methods=['GET'])
+
+_HEALTH_CHECK_TIMEOUT = 4 # seconds per channel
+_HEALTH_CHECK_MAX_BATCH = 50 # max channels per request
+
+
+@app.route('/api/channel_health', methods=['POST'])
+@login_required
+def api_channel_health():
+ """Lightweight liveness check for a batch of channels.
+
+ Accepts JSON ``{"channel_ids": ["id1", "id2", ...]}``.
+ Performs a cheap HEAD (falling back to a short GET) request for each
+ channel URL and returns ``{"results": {"id": "up"|"down"|"virtual"}, ...}``.
+
+ Virtual channels (no real stream URL) are reported as ``"virtual"``.
+ Batch size is capped at :data:`_HEALTH_CHECK_MAX_BATCH` entries.
+ """
+ import concurrent.futures as _cf
+
+ try:
+ body = request.get_json(force=True, silent=False)
+ if body is None:
+ return jsonify({"error": "invalid JSON"}), 400
+ channel_ids = body.get("channel_ids", [])
+ if not isinstance(channel_ids, list):
+ return jsonify({"error": "channel_ids must be a list"}), 400
+ except Exception:
+ return jsonify({"error": "invalid JSON"}), 400
+
+ # Cap batch size
+ channel_ids = channel_ids[:_HEALTH_CHECK_MAX_BATCH]
+
+ # Build a lookup from tvg_id → url using the live channel cache
+ url_map = {ch.get("tvg_id"): ch.get("url", "") for ch in cached_channels}
+
+ def _check_one(cid):
+ url = url_map.get(cid, "")
+ if not url:
+ return cid, "virtual"
+ try:
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ return cid, "virtual"
+ resp = requests.head(url, timeout=_HEALTH_CHECK_TIMEOUT,
+ allow_redirects=True,
+ headers={"User-Agent": "RetroIPTVGuide-HealthCheck/1.0"})
+ if resp.status_code < 400:
+ return cid, "up"
+ # Some IPTV servers reject HEAD; retry with a tiny GET
+ resp2 = requests.get(url, timeout=_HEALTH_CHECK_TIMEOUT,
+ stream=True,
+ headers={"User-Agent": "RetroIPTVGuide-HealthCheck/1.0"})
+ resp2.close()
+ return cid, "up" if resp2.status_code < 400 else "down"
+ except Exception:
+ return cid, "down"
+
+ results = {}
+ if channel_ids:
+ with _cf.ThreadPoolExecutor(max_workers=min(10, len(channel_ids))) as pool:
+ futures = {pool.submit(_check_one, cid): cid for cid in channel_ids}
+ for fut in _cf.as_completed(futures, timeout=_HEALTH_CHECK_TIMEOUT + 2):
+ try:
+ cid, status = fut.result()
+ results[cid] = status
+ except Exception:
+ results[futures[fut]] = "down"
+
+ # Fill in any IDs that timed out
+ for cid in channel_ids:
+ if cid not in results:
+ results[cid] = "down"
+
+ return jsonify({"results": results})
+
+
@login_required
def api_news():
"""Return headlines from the configured RSS/Atom feeds.
@@ -5844,6 +5971,114 @@ def api_current_program():
logging.exception("api_current_program error: %s", e)
return jsonify({"ok": False, "error": "Internal server error"}), 500
+
+@app.route('/api/whats_on_now', methods=['GET'])
+@login_required
+def api_whats_on_now():
+ """Return all channels with their currently-airing program.
+
+ Response:
+ {
+ ok: True,
+ timestamp: "
'
+ + 'Right-click a future program in the guide to add one.';
+ _reminders.slice().sort(function (a, b) {
+ return new Date(a.program_start) - new Date(b.program_start);
+ }).forEach(function (r) {
+ var startDate = new Date(r.program_start);
+ var timeStr = isNaN(startDate) ? r.program_start
+ : startDate.toLocaleDateString([], {month:'short', day:'numeric'})
+ + ' ' + startDate.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
+ var notifyLabel = r.notify_before_mins > 0
+ ? ' (' + r.notify_before_mins + ' min before)'
+ : ' (at start)';
+ html += '
';
+ }
+
+ panel.innerHTML = html;
+
+ var closeBtn = panel.querySelector('#reminders-panel-close');
+ if (closeBtn) closeBtn.addEventListener('click', closePanel);
+
+ panel.querySelectorAll('[data-remove-id]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ removeReminder(btn.dataset.removeId);
+ });
+ });
+ }
+
+ function openPanel() {
+ renderPanel();
+ var panel = ensurePanel();
+ panel.style.display = 'block';
+ syncBellButton();
+ }
+
+ function closePanel() {
+ if (_panel) _panel.style.display = 'none';
+ syncBellButton();
+ }
+
+ function isPanelOpen() {
+ return _panel && _panel.style.display !== 'none';
+ }
+
+ function togglePanel() {
+ if (isPanelOpen()) closePanel(); else openPanel();
+ }
+
+ // ─── Header bell button state ──────────────────────────────────────────────
+ function syncBellButton() {
+ var count = _reminders.length;
+ var label = count > 0 ? '🔔 Reminders (' + count + ')' : '🔔 Reminders';
+ ['remindersBtn', 'mobileRemindersBtn'].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = label;
+ });
+ }
+
+ // ─── Program right-click context menu ────────────────────────────────────
+ var _progCtxMenu = null;
+ var _progCtxTarget = null; // the .program element that was right-clicked
+
+ function createProgCtxMenu() {
+ var menu = document.createElement('div');
+ menu.id = 'prog-ctx-menu';
+ menu.setAttribute('role', 'menu');
+ menu.style.cssText = [
+ 'position:fixed',
+ 'z-index:9500',
+ 'background:var(--dropdown-bg,#1a1a1a)',
+ 'color:var(--panel-text,#eee)',
+ 'border:1px solid rgba(255,255,255,0.12)',
+ 'border-radius:7px',
+ 'padding:5px 0',
+ 'min-width:200px',
+ 'box-shadow:0 6px 20px rgba(0,0,0,0.5)',
+ 'display:none',
+ 'font-size:0.93rem'
+ ].join(';');
+
+ function item(id, text) {
+ var d = document.createElement('div');
+ d.id = id;
+ d.setAttribute('role', 'menuitem');
+ d.setAttribute('tabindex', '0');
+ d.textContent = text;
+ d.style.cssText = 'padding:8px 14px;cursor:pointer;white-space:nowrap';
+ d.addEventListener('mouseover', function () { d.style.background = 'rgba(30,211,206,0.15)'; });
+ d.addEventListener('mouseout', function () { d.style.background = ''; });
+ return d;
+ }
+
+ menu.appendChild(item('progCtxSetReminder', '🔔 Set Reminder (5 min before)'));
+ menu.appendChild(item('progCtxRemoveReminder', '🔕 Remove Reminder'));
+
+ document.body.appendChild(menu);
+
+ menu.querySelector('#progCtxSetReminder').addEventListener('click', async function () {
+ var tgt = _progCtxTarget;
+ closeProgCtxMenu();
+ if (!tgt) return;
+ var row = tgt.closest('.guide-row[data-cid]');
+ if (!row) return;
+ var cid = row.dataset.cid || '';
+ var chanName = (function () {
+ var cn = row.querySelector('.chan-name');
+ return cn ? (cn.dataset.name || cn.textContent.trim()) : cid;
+ }());
+ var title = tgt.dataset.title || '';
+ var start = tgt.dataset.start || '';
+ if (!start) { log('no start time on program'); return; }
+ var ok = await requestPermission();
+ if (!ok && 'Notification' in window && Notification.permission === 'denied') {
+ alert('Browser notifications are blocked for this site. Please allow notifications in your browser settings to receive program reminders.');
+ return;
+ }
+ await addReminder(cid, chanName, title, start, DEFAULT_NOTIFY_BEFORE);
+ startPolling();
+ });
+
+ menu.querySelector('#progCtxRemoveReminder').addEventListener('click', async function () {
+ var tgt = _progCtxTarget;
+ closeProgCtxMenu();
+ if (!tgt) return;
+ var row = tgt.closest('.guide-row[data-cid]');
+ if (!row) return;
+ var cid = row.dataset.cid || '';
+ var start = tgt.dataset.start || '';
+ var r = _reminders.find(function (x) { return x.channel_id === cid && x.program_start === start; });
+ if (r) await removeReminder(r.id);
+ });
+
+ return menu;
+ }
+
+ function openProgCtxMenu(x, y, progEl) {
+ if (!_progCtxMenu) _progCtxMenu = createProgCtxMenu();
+ _progCtxTarget = progEl;
+
+ var row = progEl.closest('.guide-row[data-cid]');
+ var cid = row ? row.dataset.cid : '';
+ var start = progEl.dataset.start || '';
+ var alreadySet = hasReminder(cid, start);
+
+ _progCtxMenu.querySelector('#progCtxSetReminder').style.display = alreadySet ? 'none' : '';
+ _progCtxMenu.querySelector('#progCtxRemoveReminder').style.display = alreadySet ? '' : 'none';
+
+ // Only show menu for programs that haven't ended yet
+ var stop = progEl.dataset.stop ? new Date(progEl.dataset.stop) : null;
+ if (stop && stop < new Date()) {
+ // Past program — only "remove" makes sense (if set)
+ _progCtxMenu.querySelector('#progCtxSetReminder').style.display = 'none';
+ }
+
+ _progCtxMenu.style.display = 'block';
+ var menuW = 220, menuH = 60;
+ var left = (x + menuW > window.innerWidth) ? (x - menuW) : x;
+ var top = (y + menuH > window.innerHeight) ? (y - menuH) : y;
+ _progCtxMenu.style.left = left + 'px';
+ _progCtxMenu.style.top = top + 'px';
+ }
+
+ function closeProgCtxMenu() {
+ if (_progCtxMenu) _progCtxMenu.style.display = 'none';
+ _progCtxTarget = null;
+ }
+
+ // ─── Attach right-click handlers to program elements ─────────────────────
+ function wirePrograms() {
+ document.querySelectorAll('.guide-row[data-cid] .program').forEach(function (prog) {
+ if (prog.dataset.reminderWired) return;
+ prog.dataset.reminderWired = '1';
+ prog.addEventListener('contextmenu', function (e) {
+ // Don't fire on no-guide placeholders
+ if (prog.classList.contains('no-guide')) return;
+ if (!prog.dataset.start) return;
+ e.preventDefault();
+ e.stopPropagation();
+ openProgCtxMenu(e.clientX, e.clientY, prog);
+ });
+ });
+ }
+
+ // ─── Init ─────────────────────────────────────────────────────────────────
+ function init() {
+ syncBellButton();
+ applyBellMarkers();
+
+ // Wire header buttons
+ function wire(id, fn) {
+ var el = document.getElementById(id);
+ if (el) el.addEventListener('click', function (e) { e.preventDefault(); fn(); });
+ }
+ wire('remindersBtn', togglePanel);
+ wire('mobileRemindersBtn', togglePanel);
+
+ // Close panel on outside click
+ document.addEventListener('click', function (e) {
+ if (_panel && _panel.style.display !== 'none' && !_panel.contains(e.target)) {
+ var isBtnClick = (e.target.id === 'remindersBtn' || e.target.id === 'mobileRemindersBtn');
+ if (!isBtnClick) closePanel();
+ }
+ // Close program context menu on outside click
+ if (_progCtxMenu && !_progCtxMenu.contains(e.target)) closeProgCtxMenu();
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ closePanel();
+ closeProgCtxMenu();
+ }
+ });
+ document.addEventListener('scroll', closeProgCtxMenu, true);
+
+ // Wire program elements (initial + MutationObserver for guide refreshes)
+ wirePrograms();
+ var guideOuter = document.getElementById('guideOuter');
+ if (guideOuter && typeof MutationObserver === 'function') {
+ var obs = new MutationObserver(function () {
+ wirePrograms();
+ applyBellMarkers();
+ });
+ obs.observe(guideOuter, { childList: true, subtree: true });
+ }
+
+ // Start polling if there are existing reminders and permission is already granted
+ if (_reminders.length > 0 && 'Notification' in window && Notification.permission === 'granted') {
+ startPolling();
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init, { once: true });
+ } else {
+ init();
+ }
+
+ // ─── Public API ────────────────────────────────────────────────────────────
+ window.__reminders = {
+ list: function () { return _reminders.slice(); },
+ open: openPanel,
+ close: closePanel
+ };
+}());
diff --git a/static/js/retro-remote.js b/static/js/retro-remote.js
new file mode 100644
index 0000000..03aef31
--- /dev/null
+++ b/static/js/retro-remote.js
@@ -0,0 +1,193 @@
+(function () {
+ 'use strict';
+
+ function ready(fn) {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', fn);
+ } else {
+ fn();
+ }
+ }
+
+ function dispatchRemoteKey(key, keyCode) {
+ var opts = {
+ key: key,
+ bubbles: true,
+ cancelable: true
+ };
+
+ if (Number.isFinite(keyCode)) {
+ opts.keyCode = keyCode;
+ opts.which = keyCode;
+ }
+
+ var event;
+ try {
+ event = new KeyboardEvent('keydown', opts);
+ if (Number.isFinite(keyCode) && event.keyCode !== keyCode) {
+ Object.defineProperty(event, 'keyCode', { get: function () { return keyCode; } });
+ Object.defineProperty(event, 'which', { get: function () { return keyCode; } });
+ }
+ } catch (e) {
+ event = document.createEvent('Event');
+ event.initEvent('keydown', true, true);
+ event.key = key;
+ event.keyCode = keyCode;
+ event.which = keyCode;
+ }
+
+ event.__retroRemote = true;
+ document.dispatchEvent(event);
+ }
+
+ ready(function () {
+ var toggles = Array.from(document.querySelectorAll('#retroRemoteToggle, #mobileRetroRemoteToggle'));
+ var activeToggle = toggles[0] || null;
+ var overlay = document.getElementById('retroRemoteOverlay');
+ var closeBtn = document.getElementById('retroRemoteClose');
+ var playerRow = document.getElementById('playerRow');
+ var videoWrap = document.getElementById('videoPlayerWrap');
+
+ if (!toggles.length || !overlay || !playerRow || !videoWrap) return;
+
+ function setOpen(open) {
+ overlay.classList.toggle('is-open', open);
+ toggles.forEach(function (toggle) {
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
+ toggle.setAttribute('aria-label', open ? 'Close remote' : 'Open remote');
+ });
+ if (open) positionOverlay();
+ }
+
+ function isOpen() {
+ return overlay.classList.contains('is-open');
+ }
+
+ function positionOverlay() {
+ var rowRect = playerRow.getBoundingClientRect();
+ var videoRect = videoWrap.getBoundingClientRect();
+ var overlayWidth = overlay.offsetWidth || 260;
+ var gap = 12;
+ var left = Math.round(videoRect.left - rowRect.left - overlayWidth - gap);
+
+ if (window.innerWidth <= 900) {
+ left = Math.round((rowRect.width - overlayWidth) / 2);
+ }
+ if (left < 8) left = 8;
+ overlay.style.left = left + 'px';
+ overlay.style.top = '8px';
+ }
+
+ function getChannels() {
+ return Array.from(document.querySelectorAll('.guide-row[data-cid] .chan-name')).filter(function (el) {
+ var row = el.closest('.guide-row');
+ if (el.closest('.__auto_scroll_clone')) return false;
+ return !row || getComputedStyle(row).display !== 'none';
+ });
+ }
+
+ function playChannelElement(el) {
+ if (!el) return;
+ el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
+ el.focus();
+ if (typeof window.playChannel === 'function') {
+ window.playChannel(el.dataset.url, el.dataset.cid, el.dataset.name);
+ } else {
+ el.click();
+ }
+ }
+
+ function stepChannel(delta) {
+ var channels = getChannels();
+ if (!channels.length) return;
+
+ var currentId = window.currentChannelMeta && window.currentChannelMeta.id;
+ var index = currentId ? channels.findIndex(function (el) {
+ return el.dataset.cid === currentId;
+ }) : -1;
+
+ if (index === -1) {
+ index = channels.indexOf(document.activeElement);
+ }
+ if (index === -1) index = delta > 0 ? -1 : 0;
+
+ var nextIndex = (index + delta + channels.length) % channels.length;
+ playChannelElement(channels[nextIndex]);
+ }
+
+ function toggleFullscreen() {
+ var vcFsBtn = document.getElementById('vcFsBtn');
+ if (vcFsBtn && !vcFsBtn.hidden) {
+ vcFsBtn.click();
+ return;
+ }
+
+ var fullscreenEl = document.fullscreenElement || document.webkitFullscreenElement || null;
+ if (fullscreenEl) {
+ var exit = document.exitFullscreen || document.webkitExitFullscreen;
+ if (exit) exit.call(document);
+ return;
+ }
+
+ var video = document.getElementById('video');
+ var target = video || videoWrap;
+ var request = target.requestFullscreen || target.webkitRequestFullscreen || target.mozRequestFullscreen;
+ if (request) {
+ var result = request.call(target);
+ if (result && result.catch) result.catch(function () {});
+ }
+ }
+
+ toggles.forEach(function (toggle) {
+ toggle.addEventListener('click', function (event) {
+ event.preventDefault();
+ activeToggle = toggle;
+ setOpen(!isOpen());
+ });
+ });
+
+ if (closeBtn) {
+ closeBtn.addEventListener('click', function () {
+ setOpen(false);
+ if (activeToggle) activeToggle.focus();
+ });
+ }
+
+ overlay.addEventListener('click', function (event) {
+ var actionBtn = event.target.closest('[data-remote-action]');
+ if (actionBtn) {
+ if (actionBtn.dataset.remoteAction === 'channel-up') stepChannel(1);
+ if (actionBtn.dataset.remoteAction === 'channel-down') stepChannel(-1);
+ if (actionBtn.dataset.remoteAction === 'fullscreen') toggleFullscreen();
+ return;
+ }
+
+ var keyBtn = event.target.closest('[data-key]');
+ if (!keyBtn) return;
+ var key = keyBtn.dataset.key || '';
+ var code = parseInt(keyBtn.dataset.keyCode, 10);
+ dispatchRemoteKey(key, Number.isFinite(code) ? code : undefined);
+ });
+
+ document.addEventListener('click', function (event) {
+ if (!isOpen()) return;
+ var clickedToggle = toggles.some(function (toggle) {
+ return toggle.contains(event.target);
+ });
+ if (overlay.contains(event.target) || clickedToggle) return;
+ setOpen(false);
+ });
+
+ document.addEventListener('keydown', function (event) {
+ if (event.key === 'Escape' && isOpen() && !event.__retroRemote) setOpen(false);
+ });
+
+ window.addEventListener('resize', function () {
+ if (isOpen()) positionOverlay();
+ });
+
+ window.addEventListener('orientationchange', function () {
+ if (isOpen()) setTimeout(positionOverlay, 80);
+ });
+ });
+})();
diff --git a/static/js/tv-remote-nav.js b/static/js/tv-remote-nav.js
index 4777273..3b47bf7 100644
--- a/static/js/tv-remote-nav.js
+++ b/static/js/tv-remote-nav.js
@@ -24,6 +24,12 @@
' box-shadow: 0 0 0 4px rgba(255,153,0,0.25);',
' position: relative; z-index: 2;',
'}',
+ '.mini-guide-row.tv-focused {',
+ ' outline: 3px solid #f90 !important;',
+ ' outline-offset: -2px;',
+ ' background: rgba(255,153,0,0.24) !important;',
+ ' box-shadow: 0 0 0 4px rgba(255,153,0,0.25);',
+ '}',
/* guide-row highlight is applied via JS to avoid :has() compat issues */
'.guide-row.tv-focused-row { background: rgba(255,153,0,0.07); }',
/* ── TV-mode: reduce entire UI proportionally to match 50% player ────── */
@@ -64,23 +70,37 @@
/* Fixed time bar */
'body.tv-mode .time-header-fixed { height: 17px; }',
/* Guide outer: fill space below shorter header+player+timebar (20+175+17+18px padding ≈ 230px) */
- 'body.tv-mode .guide-outer { height: calc(100vh - 230px); }'
+ 'body.tv-mode .guide-outer { height: calc(100vh - 230px); }',
+ 'body.tv-mode .mini-guide-page { height: calc(100vh - 230px); padding: 6px; }',
+ 'body.tv-mode .mini-guide-page-inner { max-width: none; }',
+ 'body.tv-mode .mini-guide-page-list .mini-guide-row { min-height: 44px; grid-template-columns: 48px 30px minmax(0, 1fr); padding: 4px 6px; }',
+ 'body.tv-mode .mini-guide-page-list .mg-logo,',
+ 'body.tv-mode .mini-guide-page-list .mg-logo-placeholder { width: 26px; height: 26px; }',
+ 'body.tv-mode .mini-guide-page-list .mg-channel-name { max-width: 32%; font-size: 0.72em; }',
+ 'body.tv-mode .mini-guide-page-list .mg-program-title { font-size: 0.82em; }',
+ 'body.tv-mode .mini-guide-page-list .mg-meta { font-size: 0.66em; }'
].join('\n');
document.head.appendChild(style);
/* ── State ────────────────────────────────────────────────────────────────── */
var selectedIndex = -1; // index into chanNames[]
var chanNames = []; // live NodeList snapshot, refreshed on demand
+ var initRetryCount = 0;
+ var initialized = false;
/* ── Helpers ──────────────────────────────────────────────────────────────── */
function getChannels() {
// Re-query each time in case the DOM changes (guide refresh, etc.)
- chanNames = Array.from(document.querySelectorAll('.chan-name'));
+ if (document.body.classList.contains('guide-layout-mini')) {
+ chanNames = Array.from(document.querySelectorAll('#miniGuidePageList .mini-guide-row'));
+ } else {
+ chanNames = Array.from(document.querySelectorAll('.chan-name'));
+ }
return chanNames;
}
function clearFocus() {
- document.querySelectorAll('.chan-name.tv-focused').forEach(function (el) {
+ document.querySelectorAll('.chan-name.tv-focused, .mini-guide-row.tv-focused').forEach(function (el) {
el.classList.remove('tv-focused');
var row = el.closest('.guide-row');
if (row) row.classList.remove('tv-focused-row');
@@ -106,6 +126,7 @@
// Scroll the channel row into view (vertically), without snapping the
// horizontal scroll position — we only want vertical alignment.
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ if (el.focus && document.activeElement !== el) el.focus({ preventScroll: true });
}
function activateSelected() {
@@ -113,14 +134,38 @@
if (selectedIndex < 0 || selectedIndex >= channels.length) return;
var el = channels[selectedIndex];
- // Delegate to the existing click handler wired by guide.html
+ if (typeof window.playChannel === 'function') {
+ window.playChannel(el.dataset.url, el.dataset.cid, el.dataset.name);
+ return;
+ }
+ // Fallback to click handler wired by guide.html
el.click();
}
+ function wireFocusHandlers() {
+ getChannels().forEach(function (el) {
+ if (el.__tvRemoteFocusWired) return;
+ el.__tvRemoteFocusWired = true;
+ el.addEventListener('focus', function () {
+ var idx = getChannels().indexOf(el);
+ if (idx !== -1) {
+ clearFocus();
+ selectedIndex = idx;
+ el.classList.add('tv-focused');
+ var row = el.closest('.guide-row');
+ if (row) row.classList.add('tv-focused-row');
+ }
+ });
+ });
+ }
+
/* ── Keyboard handler ─────────────────────────────────────────────────────── */
function onKeyDown(e) {
var channels = getChannels();
- if (!channels.length) return;
+ if (!channels.length) {
+ setTimeout(init, 50);
+ return;
+ }
switch (e.keyCode) {
case 38: // ArrowUp / DPAD Up
@@ -149,12 +194,24 @@
/* ── Init ─────────────────────────────────────────────────────────────────── */
function init() {
- var channels = getChannels();
- if (!channels.length) return;
-
+ if (initialized) return;
// Mark body so TV-mode CSS rules apply (player sizing, etc.)
document.body.classList.add('tv-mode');
+ if (window.__initialUserPrefs && window.__initialUserPrefs.guide_layout === 'mini' && typeof window.setGuideLayout === 'function') {
+ window.setGuideLayout('mini', false);
+ }
+
+ var channels = getChannels();
+ if (!channels.length) {
+ if (initRetryCount < 20) {
+ initRetryCount++;
+ setTimeout(init, 50);
+ }
+ return;
+ }
+ initialized = true;
+
// Recompute fixed time bar position after TV-mode CSS has resized the player.
// createOrUpdateFixedTimeBar() already ran at DOMContentLoaded (before this
// async script loaded), so it measured the full-size player. Two rAF cycles
@@ -184,24 +241,22 @@
} catch (e) { /* ignore localStorage errors */ }
// Sync selectedIndex when a channel element receives native focus.
- channels.forEach(function (el) {
- el.addEventListener('focus', function () {
- var idx = getChannels().indexOf(el);
- if (idx !== -1) {
- clearFocus();
- selectedIndex = idx;
- el.classList.add('tv-focused');
- var row = el.closest('.guide-row');
- if (row) row.classList.add('tv-focused-row');
- }
- });
- });
+ wireFocusHandlers();
// Start with the first channel highlighted.
setFocus(0);
document.addEventListener('keydown', onKeyDown);
+ document.addEventListener('guide-layout:changed', function () {
+ clearFocus();
+ selectedIndex = -1;
+ setTimeout(function () {
+ wireFocusHandlers();
+ setFocus(0);
+ }, 0);
+ });
+
console.log('RetroIPTVGuide: TV remote nav active (' + channels.length + ' channels)');
}
diff --git a/static/js/user-prefs.js b/static/js/user-prefs.js
index f3e9df6..5f3742d 100644
--- a/static/js/user-prefs.js
+++ b/static/js/user-prefs.js
@@ -24,7 +24,7 @@
// ─── State ────────────────────────────────────────────────────────────────
let prefs = Object.assign(
- { auto_load_channel: null, hidden_channels: [], favorite_channels: [], channel_numbers_enabled: false },
+ { auto_load_channel: null, hidden_channels: [], favorite_channels: [], channel_numbers_enabled: false, browse_mode_enabled: false, guide_layout: 'full' },
(typeof window.__initialUserPrefs === 'object' && window.__initialUserPrefs) || {}
);
let showingHidden = false; // current toggle state for "show hidden channels"
@@ -224,6 +224,29 @@
applyChannelNumbersPreference();
}
+ // ─── Browse mode toggle ─────────────────────────────────────────────────────
+ function applyBrowseModePreference() {
+ const enabled = !!prefs.browse_mode_enabled;
+ document.body.classList.toggle('browse-mode', enabled);
+ window.__browseModeEnabled = enabled;
+ }
+
+ function syncBrowseModeButton() {
+ const enabled = !!prefs.browse_mode_enabled;
+ const label = enabled ? '🧭 Disable Browse Mode' : '🧭 Enable Browse Mode';
+ ['toggleBrowseMode', 'mobileToggleBrowseMode'].forEach(function (id) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = label;
+ });
+ }
+
+ async function toggleBrowseMode() {
+ prefs.browse_mode_enabled = !prefs.browse_mode_enabled;
+ await savePatch({ browse_mode_enabled: !!prefs.browse_mode_enabled });
+ syncBrowseModeButton();
+ applyBrowseModePreference();
+ }
+
// ─── Auto-load channel ────────────────────────────────────────────────────
function syncAutoLoadButton() {
const al = prefs.auto_load_channel;
@@ -426,7 +449,9 @@
syncShowHiddenButton();
syncShowFavoritesButton();
syncChannelNumbersButton();
+ syncBrowseModeButton();
applyChannelNumbersPreference();
+ applyBrowseModePreference();
scheduleAutoLoad();
// Wire Settings-menu buttons (desktop + mobile, present in _header.html)
@@ -444,6 +469,8 @@
wire('mobileToggleShowFavoritesOnly', toggleShowFavoritesOnly);
wire('toggleChannelNumbers', toggleChannelNumbers);
wire('mobileToggleChannelNumbers', toggleChannelNumbers);
+ wire('toggleBrowseMode', toggleBrowseMode);
+ wire('mobileToggleBrowseMode', toggleBrowseMode);
window.addEventListener('theme:applied', function () {
syncChannelNumbersButton();
@@ -488,7 +515,8 @@
addFavorite: addFavorite,
removeFavorite: removeFavorite,
toggleShowFavoritesOnly: toggleShowFavoritesOnly,
- toggleChannelNumbers: toggleChannelNumbers
+ toggleChannelNumbers: toggleChannelNumbers,
+ toggleBrowseMode: toggleBrowseMode
};
// Bootstrap when DOM is ready
diff --git a/templates/_header.html b/templates/_header.html
index cf10fd2..d4c407c 100644
--- a/templates/_header.html
+++ b/templates/_header.html
@@ -3,6 +3,18 @@
+ {% if request.endpoint == 'guide' %}
+
+ {% endif %}
Health Checks ';
+ });
+});
+
/* ── System Info ── */
document.getElementById('btnLoadSystem').addEventListener('click',function(){
spin('sysSpinner',true);
diff --git a/templates/guide.html b/templates/guide.html
index cc913c9..8553273 100644
--- a/templates/guide.html
+++ b/templates/guide.html
@@ -99,6 +99,190 @@
display: inline-block;
flex-shrink: 0;
}
+ #playerRow { position: relative; }
+ .retro-remote-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ margin-left: 2px;
+ padding: 0;
+ border: 1px solid currentColor;
+ border-radius: 4px;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ line-height: 1;
+ cursor: pointer;
+ overflow: hidden;
+ }
+ .retro-remote-toggle img {
+ display: block;
+ width: 22px;
+ height: 22px;
+ object-fit: contain;
+ }
+ .mobile-retro-remote-toggle {
+ display: none;
+ margin-right: auto;
+ }
+ .retro-remote-toggle:hover,
+ .retro-remote-toggle:focus-visible {
+ background: rgba(255,255,255,0.14);
+ outline: none;
+ }
+ body.tv-mode .retro-remote-toggle {
+ width: 20px;
+ min-width: 20px;
+ height: 20px;
+ font-size: 11px;
+ }
+ body.tv-mode .retro-remote-toggle img {
+ width: 16px;
+ height: 16px;
+ }
+ .retro-remote-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 2600;
+ width: min(260px, calc(100vw - 28px));
+ max-height: calc(100vh - 90px);
+ overflow: auto;
+ padding: 12px;
+ border: 1px solid rgba(255,255,255,0.3);
+ border-radius: 8px;
+ background: rgba(16, 17, 19, 0.54);
+ box-shadow: 0 18px 42px rgba(0,0,0,0.38), inset 0 0 22px rgba(255,255,255,0.05);
+ color: #f6f2e7;
+ font-family: Arial, Helvetica, sans-serif;
+ backdrop-filter: blur(3px);
+ transform: translateY(-16px);
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: transform 0.2s ease, opacity 0.2s ease, visibility 0s linear 0.2s;
+ }
+ .retro-remote-overlay.is-open {
+ transform: translateY(0);
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transition-delay: 0s;
+ }
+ .retro-remote-shell {
+ display: grid;
+ gap: 10px;
+ }
+ .retro-remote-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+ .retro-remote-close {
+ width: 28px;
+ height: 28px;
+ border: 1px solid rgba(255,255,255,0.34);
+ border-radius: 50%;
+ background: rgba(0,0,0,0.24);
+ color: inherit;
+ cursor: pointer;
+ line-height: 1;
+ }
+ .retro-remote-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ }
+ .retro-remote-dpad {
+ display: none;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ align-items: center;
+ }
+ body.tv-mode .retro-remote-dpad {
+ display: grid;
+ }
+ .retro-remote-key {
+ min-height: 38px;
+ border: 1px solid rgba(255,255,255,0.34);
+ border-radius: 6px;
+ background: rgba(0,0,0,0.42);
+ color: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.12);
+ }
+ .retro-remote-key:hover,
+ .retro-remote-key:focus-visible,
+ .retro-remote-close:hover,
+ .retro-remote-close:focus-visible {
+ background: rgba(255,153,0,0.28);
+ outline: 2px solid rgba(255,153,0,0.55);
+ outline-offset: 1px;
+ }
+ .retro-remote-key.is-ok {
+ min-height: 48px;
+ border-radius: 50%;
+ color: #ffcc66;
+ }
+ .retro-remote-key.is-wide {
+ grid-column: span 2;
+ }
+ .retro-remote-spacer {
+ min-height: 38px;
+ }
+ body.tv-mode .retro-remote-overlay {
+ width: 180px;
+ max-height: calc(100vh - 42px);
+ padding: 7px;
+ border-radius: 6px;
+ }
+ body.tv-mode .retro-remote-shell {
+ gap: 6px;
+ }
+ body.tv-mode .retro-remote-head {
+ font-size: 9px;
+ gap: 6px;
+ }
+ body.tv-mode .retro-remote-close {
+ width: 20px;
+ height: 20px;
+ font-size: 12px;
+ }
+ body.tv-mode .retro-remote-grid,
+ body.tv-mode .retro-remote-dpad {
+ gap: 5px;
+ }
+ body.tv-mode .retro-remote-key {
+ min-height: 26px;
+ border-radius: 4px;
+ font-size: 10px;
+ padding: 2px 4px;
+ }
+ body.tv-mode .retro-remote-key.is-ok {
+ min-height: 32px;
+ }
+ body.tv-mode .retro-remote-spacer {
+ min-height: 26px;
+ }
+ @media (max-width: 900px) {
+ .mobile-retro-remote-toggle { display: inline-flex; }
+ .retro-remote-toggle { width: 30px; min-width: 30px; height: 30px; }
+ .retro-remote-toggle img { width: 24px; height: 24px; }
+ .retro-remote-overlay {
+ width: min(236px, calc(100vw - 20px));
+ padding: 10px;
+ }
+ .retro-remote-key { min-height: 34px; }
+ }
.unmute-btn {
position: absolute;
top: 8px;
@@ -135,6 +319,8 @@
.vc-fs-btn.vc-fs-btn-fade { opacity: 0; pointer-events: none; }
/* Mute button sits to the left of the FS button */
#vcMuteBtn { right: 48px; }
+ #chanInfoBtn { right: 88px; }
+ #miniGuideBtn { right: 128px; }
/* Fill wrapper + overlay when in fullscreen (fallback for overlay types without a standalone page) */
#videoPlayerWrap:fullscreen,
#videoPlayerWrap:-webkit-full-screen {
@@ -353,13 +539,211 @@
text-overflow: ellipsis;
line-height: 1.3;
}
- /* Info toggle button – bottom-left of video player */
- .cib-toggle-btn {
- bottom: 8px !important;
- top: auto !important;
- right: auto !important;
- left: 8px !important;
- font-size: 14px !important;
+ /* ── Mini Guide ────────────────────────────────────────────────────────── */
+ .mini-guide {
+ position: absolute;
+ top: 50%;
+ left: 12px;
+ z-index: 35;
+ width: min(520px, calc(100% - 24px));
+ max-height: calc(100% - 24px);
+ color: #fff;
+ background: rgba(8, 10, 14, 0.82);
+ border: 1px solid rgba(255,255,255,0.18);
+ border-radius: 6px;
+ box-shadow: 0 14px 36px rgba(0,0,0,0.45);
+ backdrop-filter: blur(6px);
+ transform: translate(-110%, -50%);
+ opacity: 0;
+ pointer-events: none;
+ transition: transform 0.22s ease, opacity 0.22s ease;
+ overflow: hidden;
+ font-family: Arial, Helvetica, sans-serif;
+ }
+ .mini-guide.is-open {
+ transform: translate(0, -50%);
+ opacity: 1;
+ pointer-events: auto;
+ }
+ .mini-guide-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 9px 12px;
+ border-bottom: 1px solid rgba(255,255,255,0.12);
+ background: rgba(0,0,0,0.22);
+ }
+ .mini-guide-title {
+ font-size: 0.86em;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+ .mini-guide-close {
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ background: rgba(255,255,255,0.12);
+ cursor: pointer;
+ line-height: 1;
+ }
+ .mini-guide-close:hover { background: rgba(255,255,255,0.22); }
+ .mini-guide-list {
+ display: flex;
+ flex-direction: column;
+ padding: 6px;
+ gap: 4px;
+ }
+ .mini-guide-row {
+ display: grid;
+ grid-template-columns: 46px 34px minmax(0, 1fr);
+ gap: 9px;
+ align-items: center;
+ min-height: 54px;
+ padding: 6px 8px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background: rgba(255,255,255,0.04);
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ }
+ .mini-guide-row:hover,
+ .mini-guide-row.is-selected {
+ border-color: rgba(80, 170, 255, 0.9);
+ background: rgba(42, 116, 202, 0.42);
+ }
+ .mini-guide-row.is-current .mg-channel-num { color: #ffd700; }
+ .mg-channel-num {
+ font-size: 0.84em;
+ font-weight: 700;
+ color: #d8e7ff;
+ white-space: nowrap;
+ }
+ .mg-logo {
+ width: 32px;
+ height: 32px;
+ object-fit: contain;
+ border-radius: 4px;
+ background: rgba(255,255,255,0.08);
+ }
+ .mg-logo-placeholder {
+ width: 32px;
+ height: 32px;
+ border-radius: 4px;
+ background: rgba(255,255,255,0.12);
+ }
+ .mg-main { min-width: 0; }
+ .mg-topline,
+ .mg-meta {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+ }
+ .mg-channel-name,
+ .mg-program-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .mg-channel-name {
+ flex: 0 1 auto;
+ max-width: 38%;
+ color: #a9c8ff;
+ font-size: 0.8em;
+ font-weight: 700;
+ }
+ .mg-program-title {
+ flex: 1;
+ color: #fff;
+ font-size: 0.92em;
+ font-weight: 700;
+ }
+ .mg-meta {
+ margin-top: 4px;
+ color: #c5ced8;
+ font-size: 0.74em;
+ }
+ .mg-time,
+ .mg-remaining {
+ flex-shrink: 0;
+ white-space: nowrap;
+ }
+ .mg-progress-wrap {
+ flex: 1;
+ height: 4px;
+ min-width: 52px;
+ border-radius: 2px;
+ overflow: hidden;
+ background: rgba(255,255,255,0.18);
+ }
+ .mg-progress-bar {
+ height: 100%;
+ width: 0%;
+ border-radius: 2px;
+ background: #50aaff;
+ }
+ .mini-guide-page {
+ display: none;
+ flex: 1;
+ overflow: auto;
+ padding: 10px;
+ background: var(--guide-bg, #111);
+ }
+ body.guide-layout-mini #guideOuter,
+ body.guide-layout-mini #fixedTimeBar {
+ display: none !important;
+ }
+ body.guide-layout-mini .mini-guide-page {
+ display: block;
+ }
+ .mini-guide-page-inner {
+ max-width: 980px;
+ margin: 0 auto;
+ }
+ .mini-guide-page-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+ color: var(--text-color, #fff);
+ }
+ .mini-guide-page-title {
+ font-size: 1rem;
+ font-weight: 700;
+ }
+ .mini-guide-page-count {
+ font-size: 0.78rem;
+ opacity: 0.72;
+ white-space: nowrap;
+ }
+ .mini-guide-page-list {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ }
+ .mini-guide-page-list .mini-guide-row {
+ grid-template-columns: 64px 42px minmax(0, 1fr);
+ min-height: 62px;
+ background: var(--program-bg, rgba(255,255,255,0.06));
+ color: var(--text-color, #fff);
+ border-color: rgba(255,255,255,0.1);
+ }
+ .mini-guide-page-list .mg-logo,
+ .mini-guide-page-list .mg-logo-placeholder {
+ width: 36px;
+ height: 36px;
+ }
+ .mini-guide-empty {
+ padding: 18px;
+ color: var(--text-color, #fff);
+ opacity: 0.75;
+ text-align: center;
}
.guide-filter-panel {
display: none;
@@ -386,6 +770,14 @@
}
.guide-filter-panel input { flex: 1; min-width: 180px; }
.guide-row.chan-search-hidden { display: none !important; }
+ .chan-name.browse-selected {
+ outline: 2px solid rgba(30, 211, 206, 0.85);
+ outline-offset: -2px;
+ box-shadow: 0 0 0 3px rgba(30, 211, 206, 0.25);
+ }
+ .guide-row.browse-selected-row {
+ background: rgba(30, 211, 206, 0.08);
+ }
#vcFsOverlay .channel-info-banner {
z-index: 9109;
padding: 64px 20px 16px;
@@ -400,7 +792,7 @@
{% endif %}
-
+
Program Info
Program Info
{% endfor %}
+Program Info
// --- Player, grid, and fixed timebar helpers (adapted with layout recompute hooks) ---
let hlsInstance = null;
+let hlsStartupRetryTimer = null;
let currentChannelId = null;
let _vcCurrentOverlayType = '';
let _vcBgMuted = false;
@@ -560,6 +1014,7 @@ Program Info
let _vcFsLastMove = 0;
let _vcFsOverlayCloseTimer = null;
let _vcFsOverlayLastMove = 0;
+let browseSelectedChannelId = null;
// ── Background music (virtual channel looped audio) ─────────────────────────
let _bgAudio = null;
@@ -624,6 +1079,8 @@ Program Info
if (mb && !mb.hidden) mb.classList.add('vc-fs-btn-fade');
const ib = document.getElementById('chanInfoBtn');
if (ib && !ib.hidden) ib.classList.add('vc-fs-btn-fade');
+ const mgb = document.getElementById('miniGuideBtn');
+ if (mgb && !mgb.hidden) mgb.classList.add('vc-fs-btn-fade');
}, delay);
}
@@ -701,10 +1158,29 @@ Program Info
ib.hidden = !currentChannelId;
ib.classList.remove('vc-fs-btn-fade');
}
+ const mgb = document.getElementById('miniGuideBtn');
+ if (mgb) {
+ mgb.hidden = !currentChannelId;
+ mgb.classList.remove('vc-fs-btn-fade');
+ }
_updateVcMuteBtn();
_scheduleVcFsHide(3500);
}
+function showPlayerAuxBtns() {
+ const ib = document.getElementById('chanInfoBtn');
+ if (ib) {
+ ib.hidden = !currentChannelId;
+ ib.classList.remove('vc-fs-btn-fade');
+ }
+ const mgb = document.getElementById('miniGuideBtn');
+ if (mgb) {
+ mgb.hidden = !currentChannelId;
+ mgb.classList.remove('vc-fs-btn-fade');
+ }
+ _scheduleVcFsHide(3500);
+}
+
function hideVcFsBtn() {
if (_vcFsHideTimer) { clearTimeout(_vcFsHideTimer); _vcFsHideTimer = null; }
const btn = document.getElementById('vcFsBtn');
@@ -716,6 +1192,9 @@ Program Info
if (mb) { mb.hidden = true; mb.classList.remove('vc-fs-btn-fade'); }
const ib = document.getElementById('chanInfoBtn');
if (ib) { ib.hidden = true; ib.classList.remove('vc-fs-btn-fade'); }
+ const mgb = document.getElementById('miniGuideBtn');
+ if (mgb) { mgb.hidden = true; mgb.classList.remove('vc-fs-btn-fade'); }
+ if (typeof window.closeMiniGuide === 'function') window.closeMiniGuide();
// Close iframe overlay if active when switching away from a virtual channel.
const overlay = document.getElementById('vcFsOverlay');
const iframe = document.getElementById('vcFsIframe');
@@ -953,12 +1432,15 @@ Program Info
const mb = document.getElementById('vcMuteBtn');
if (mb && !mb.hidden) mb.classList.remove('vc-fs-btn-fade');
if (ib && !ib.hidden) ib.classList.remove('vc-fs-btn-fade');
+ const mgb = document.getElementById('miniGuideBtn');
+ if (mgb && !mgb.hidden) mgb.classList.remove('vc-fs-btn-fade');
_scheduleVcFsHide(1500);
}
});
wrap.addEventListener('mouseleave', function () {
const ib = document.getElementById('chanInfoBtn');
- if (!btn.hidden || (ib && !ib.hidden)) _scheduleVcFsHide(1500);
+ const mgb = document.getElementById('miniGuideBtn');
+ if (!btn.hidden || (ib && !ib.hidden) || (mgb && !mgb.hidden)) _scheduleVcFsHide(1500);
});
}
@@ -993,6 +1475,11 @@ Program Info
function playChannel(url, cid, name) {
const video = document.getElementById('video');
+ if (hlsStartupRetryTimer) {
+ clearTimeout(hlsStartupRetryTimer);
+ hlsStartupRetryTimer = null;
+ }
+ clearBrowseSelection();
// Save the current channel as "last channel" before switching (skip if it's the same channel)
if (currentChannelId && currentChannelId !== cid && window.currentChannelMeta) {
window.lastChannelMeta = Object.assign({}, window.currentChannelMeta);
@@ -1005,7 +1492,6 @@ Program Info
if (typeof window.showChannelInfoBanner === 'function') {
window.showChannelInfoBanner(cid, name);
}
-
fetch("/play_channel", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -1046,8 +1532,9 @@ Program Info
// Ensure any mute applied by a previous virtual channel is cleared.
_vcCurrentOverlayType = '';
// Normal channels use the native video player's built-in fullscreen button;
- // keep virtual-channel-only controls hidden.
+ // keep virtual-channel-only controls hidden while retaining player overlays.
hideVcFsBtn();
+ showPlayerAuxBtns();
video.muted = false;
// Try normal play; if blocked by browser autoplay policy, retry muted
@@ -1062,6 +1549,10 @@ Program Info
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
if (Hls.isSupported()) {
+ let hlsAwaitingStartup = true;
+ let hlsStartupRetryCount = 0;
+ const HLS_STARTUP_RETRY_MAX_ATTEMPTS = 12;
+ const HLS_STARTUP_RETRY_DELAY_MS = 1000;
hlsInstance = new Hls({
liveSyncDurationCount: 6,
liveMaxLatencyDurationCount: 12,
@@ -1070,13 +1561,73 @@ Program Info
});
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
- hlsInstance.on(Hls.Events.MANIFEST_PARSED, tryPlay);
+ hlsInstance.on(Hls.Events.MANIFEST_PARSED, function () {
+ hlsAwaitingStartup = false;
+ if (hlsStartupRetryTimer) {
+ clearTimeout(hlsStartupRetryTimer);
+ hlsStartupRetryTimer = null;
+ }
+ tryPlay();
+ });
+ hlsInstance.on(Hls.Events.ERROR, function (_event, data) {
+ if (!hlsInstance || !data || !data.fatal) return;
+ if (hlsAwaitingStartup && data.type === Hls.ErrorTypes.NETWORK_ERROR && hlsStartupRetryCount < HLS_STARTUP_RETRY_MAX_ATTEMPTS) {
+ hlsStartupRetryCount += 1;
+ if (hlsStartupRetryTimer) clearTimeout(hlsStartupRetryTimer);
+ hlsStartupRetryTimer = setTimeout(function () {
+ hlsStartupRetryTimer = null;
+ if (!hlsInstance || currentChannelId !== cid) return;
+ try {
+ hlsInstance.startLoad();
+ } catch (err) {
+ console.warn('HLS startLoad retry failed', err);
+ }
+ }, HLS_STARTUP_RETRY_DELAY_MS);
+ return;
+ }
+ if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
+ try {
+ hlsInstance.recoverMediaError();
+ return;
+ } catch (err) {
+ console.warn('HLS media recovery failed', err);
+ }
+ }
+ hlsAwaitingStartup = false;
+ hlsInstance.destroy();
+ hlsInstance = null;
+ });
} else {
video.src = url;
tryPlay();
}
}
+function clearBrowseSelection() {
+ browseSelectedChannelId = null;
+ document.querySelectorAll('.chan-name.browse-selected').forEach(function (el) {
+ el.classList.remove('browse-selected');
+ });
+ document.querySelectorAll('.guide-row.browse-selected-row').forEach(function (row) {
+ row.classList.remove('browse-selected-row');
+ });
+}
+
+function previewChannel(cid, name) {
+ if (!cid) return;
+ clearBrowseSelection();
+ browseSelectedChannelId = cid;
+ const escapedCid = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(cid) : cid.replace(/[^a-zA-Z0-9._-]/g, '\\$&');
+ const chanEl = document.querySelector(`.chan-name[data-cid="${escapedCid}"]`);
+ if (chanEl) chanEl.classList.add('browse-selected');
+ const row = document.querySelector(`.guide-row[data-cid="${escapedCid}"]`);
+ if (row) row.classList.add('browse-selected-row');
+ updateSummary(cid, name);
+ if (typeof window.showChannelInfoBanner === 'function') {
+ window.showChannelInfoBanner(cid, name);
+ }
+}
+
/**
* Return to the previously watched channel ("Last Channel" feature).
* Exposed globally so channel-number-entry.js (and any future module) can trigger it.
@@ -1704,7 +2255,16 @@ Program Info
document.querySelectorAll('.chan-name').forEach(el => {
el.addEventListener('click', () => {
- playChannel(el.dataset.url, el.dataset.cid, el.dataset.name);
+ const isBrowseMode = !!window.__browseModeEnabled;
+ if (!isBrowseMode) {
+ playChannel(el.dataset.url, el.dataset.cid, el.dataset.name);
+ return;
+ }
+ if (browseSelectedChannelId === el.dataset.cid) {
+ playChannel(el.dataset.url, el.dataset.cid, el.dataset.name);
+ return;
+ }
+ previewChannel(el.dataset.cid, el.dataset.name);
});
});
@@ -2034,6 +2594,45 @@ Program Info
video.addEventListener('pause', recomputeAll);
video.addEventListener('ended', recomputeAll);
});
+
+// ── What's On Now → auto-play ─────────────────────────────────────────────────
+// When the user arrives from the What's On Now page, the selected channel id is
+// stored in sessionStorage under 'wonPendingPlay'. Pick it up here, clear it,
+// and trigger playChannel() so the stream starts immediately.
+(function () {
+ var raw = null;
+ try { raw = sessionStorage.getItem('wonPendingPlay'); } catch (e) {
+ console.warn('[guide] Could not read wonPendingPlay from sessionStorage:', e);
+ }
+ if (!raw) return;
+ try { sessionStorage.removeItem('wonPendingPlay'); } catch (e) {
+ console.warn('[guide] Could not remove wonPendingPlay from sessionStorage:', e);
+ }
+ var pending = null;
+ try { pending = JSON.parse(raw); } catch (e) {
+ console.warn('[guide] Failed to parse wonPendingPlay data:', e);
+ }
+ if (!pending || !pending.id) return;
+
+ function tryPlayPending() {
+ // 800 ms grace period matches scheduleAutoLoad in user-prefs.js: gives the
+ // page time to fully render deferred scripts before playChannel() is called.
+ setTimeout(function () {
+ var escapedId = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(pending.id) : pending.id.replace(/[^a-zA-Z0-9._-]/g, '\\$&');
+ var chanEl = document.querySelector('.chan-name[data-cid="' + escapedId + '"]');
+ if (!chanEl) return;
+ if (typeof window.playChannel === 'function') {
+ window.playChannel(chanEl.dataset.url, pending.id, chanEl.dataset.name || pending.name || '');
+ }
+ }, 800);
+ }
+
+ if (document.readyState === 'complete') {
+ tryPlayPending();
+ } else {
+ document.addEventListener('DOMContentLoaded', tryPlayPending, { once: true });
+ }
+}());
+
+
+
+
+
+
+
+
diff --git a/templates/manage_users.html b/templates/manage_users.html
index cef42cb..9a22703 100644
--- a/templates/manage_users.html
+++ b/templates/manage_users.html
@@ -126,6 +126,16 @@ Existing Users
+
+ 📺 What's On Now
+