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 @@

- Version + Version GHCR 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: "", + channels: [ + { + tvg_id, name, logo, group, number, + program: { title, desc, start_iso, stop_iso, progress_pct } + }, + ... + ] + } + """ + try: + now = datetime.now(timezone.utc) + virtual_ch = get_virtual_channels() + vc_settings = get_virtual_channel_settings() + virtual_ch = [ch for ch in virtual_ch if vc_settings.get(ch.get('tvg_id', ''), True)] + grid_start = now.replace(minute=(0 if now.minute < 30 else 30), second=0, microsecond=0) + virtual_epg = get_virtual_epg(grid_start, HOURS_SPAN) + all_channels = virtual_ch + cached_channels + all_epg = {**virtual_epg, **cached_epg} + + out = [] + for ch in all_channels: + tvg_id = ch.get('tvg_id') or '' + programs = all_epg.get(tvg_id) or [] + current_prog = None + for prog in programs: + start = prog.get('start') + stop = prog.get('stop') + if start and stop and start <= now <= stop: + current_prog = prog + break + + if current_prog is None and programs: + # Fall back to first non-placeholder entry + _no_data = 'No Guide Data Available' + for prog in programs: + if prog.get('title') and prog.get('title') != _no_data: + current_prog = prog + break + if current_prog is None: + current_prog = programs[0] + + prog_title = '' + prog_desc = '' + start_iso = None + stop_iso = None + progress_pct = 0 + if current_prog: + raw_title = current_prog.get('title') or '' + # Omit the internal fallback placeholder so the UI can show a "No Data" state + prog_title = '' if raw_title == 'No Guide Data Available' else raw_title + prog_desc = current_prog.get('desc') or '' + p_start = current_prog.get('start') + p_stop = current_prog.get('stop') + if p_start: + start_iso = p_start.isoformat() + if p_stop: + stop_iso = p_stop.isoformat() + if p_start and p_stop and p_start <= now <= p_stop: + duration = (p_stop - p_start).total_seconds() + elapsed = (now - p_start).total_seconds() + progress_pct = int(min(100, max(0, (elapsed / duration) * 100))) if duration > 0 else 0 + + out.append({ + 'tvg_id': tvg_id, + 'name': ch.get('name') or '', + 'logo': ch.get('logo') or '', + 'group': ch.get('group') or '', + 'number': ch.get('tvg_chno') or ch.get('number') or '', + 'program': { + 'title': prog_title, + 'desc': prog_desc, + 'start_iso': start_iso, + 'stop_iso': stop_iso, + 'progress_pct': progress_pct, + } + }) + + return jsonify({'ok': True, 'timestamp': now.isoformat(), 'channels': out}) + except Exception as e: + logging.exception("api_whats_on_now error: %s", e) + return jsonify({'ok': False, 'error': 'Internal server error'}), 500 + + +@app.route('/whats-on-now') +@login_required +def whats_on_now(): + """'What's On Now' page – shows all channels with their currently-airing program.""" + log_event(current_user.username, "Loaded What's On Now page") + user_prefs = get_user_prefs(current_user.username) + user_default_theme = user_prefs.get("default_theme") or None + return render_template( + 'whats_on_now.html', + current_tuner=get_current_tuner(), + user_prefs=user_prefs, + user_default_theme=user_default_theme, + ) + + @app.route('/logs', methods=['GET'], endpoint='view_logs') @login_required def view_logs(): diff --git a/retroiptv_linux.sh b/retroiptv_linux.sh index 20d7af5..1d0519d 100644 --- a/retroiptv_linux.sh +++ b/retroiptv_linux.sh @@ -3,7 +3,7 @@ # License: Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) set -euo pipefail -VERSION="4.9.6" +VERSION="4.9.7" TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S") LOGFILE="retroiptv_${TIMESTAMP}.log" exec > >(tee -a "$LOGFILE") 2>&1 diff --git a/retroiptv_rpi.sh b/retroiptv_rpi.sh index 236c67a..10baace 100644 --- a/retroiptv_rpi.sh +++ b/retroiptv_rpi.sh @@ -1,5 +1,5 @@ #!/bin/bash -VERSION="4.9.6" +VERSION="4.9.7" # RetroIPTVGuide Raspberry Pi Installer (Headless, Pi3/4/5) # Installs to /home/iptv/iptv-server for consistency with Debian/Windows # Logs to /var/log/retroiptvguide/install-YYYYMMDD-HHMMSS.log diff --git a/retroiptv_windows.bat b/retroiptv_windows.bat index f74433a..21b02cc 100644 --- a/retroiptv_windows.bat +++ b/retroiptv_windows.bat @@ -3,7 +3,7 @@ mode con: cols=160 lines=50 REM ============================================================ REM RetroIPTVGuide Windows Unified Installer / Uninstaller -REM Version: v4.9.6 +REM Version: v4.9.7 REM License: Creative Commons BY-NC-SA 4.0 REM ============================================================ @@ -31,7 +31,7 @@ if /i "%choice%"=="Y" ( :continue setlocal -set "VERSION=4.9.6" +set "VERSION=4.9.7" set "REPO_URL=https://github.com/thehack904/RetroIPTVGuide.git" set "ZIP_URL=https://github.com/thehack904/RetroIPTVGuide/archive/refs/heads/main.zip" set "INSTALL_DIR=%~dp0RetroIPTVGuide" diff --git a/retroiptv_windows.ps1 b/retroiptv_windows.ps1 index 69e78d1..6070f97 100755 --- a/retroiptv_windows.ps1 +++ b/retroiptv_windows.ps1 @@ -1,7 +1,7 @@ <# RetroIPTVGuide Windows Installer/Uninstaller Filename: retroiptv_windows.ps1 -Version: 4.9.6 +Version: 4.9.7 License: Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International https://creativecommons.org/licenses/by-nc-sa/4.0/ @@ -55,7 +55,7 @@ if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdent $ErrorActionPreference = 'Stop' $PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' -$VERSION = "4.9.6" +$VERSION = "4.9.7" $ScriptDir = Split-Path -Parent -Path $MyInvocation.MyCommand.Path Set-Location $ScriptDir diff --git a/static/css/whats_on_now.css b/static/css/whats_on_now.css new file mode 100644 index 0000000..6703867 --- /dev/null +++ b/static/css/whats_on_now.css @@ -0,0 +1,427 @@ +/* Per-page styles for What's On Now page */ + +.won-container { + max-width: 1100px; + margin: 24px auto 60px; + padding: 0 16px; + box-sizing: border-box; +} + +/* ── Page heading ─────────────────────────────────────────────── */ +.won-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; +} + +.won-heading h1 { + font-size: 1.3rem; + font-weight: 700; + margin: 0; + color: inherit; +} + +.won-meta { + font-size: 0.82rem; + opacity: 0.6; +} + +/* ── Search / filter bar ───────────────────────────────────────── */ +.won-filter-bar { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.won-search { + flex: 1 1 200px; + padding: 6px 10px; + font-size: 0.9rem; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 4px; + color: inherit; + outline: none; + box-sizing: border-box; +} + +.won-search:focus { + border-color: rgba(255,255,255,0.3); +} + +.won-group-filter { + padding: 6px 10px; + font-size: 0.9rem; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 4px; + color: inherit; + min-width: 130px; + box-sizing: border-box; +} + +/* ── Channel card grid ─────────────────────────────────────────── */ +.won-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; +} + +/* ── Single channel card ───────────────────────────────────────── */ +.won-card { + display: flex; + flex-direction: column; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 6px; + overflow: hidden; + transition: border-color 0.15s ease, background 0.15s ease; + cursor: pointer; + text-decoration: none; + color: inherit; +} + +.won-card:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.18); +} + +/* Card top: logo + channel name */ +.won-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 6px; + border-bottom: 1px solid rgba(255,255,255,0.05); + min-height: 42px; +} + +.won-logo { + width: 32px; + height: 32px; + object-fit: contain; + border-radius: 3px; + flex-shrink: 0; + background: transparent; +} + +.won-logo-placeholder { + width: 32px; + height: 32px; + border-radius: 3px; + background: rgba(255,255,255,0.08); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + line-height: 1; +} + +.won-chan-name { + font-size: 0.82rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.won-chan-number { + font-size: 0.72rem; + opacity: 0.5; + flex-shrink: 0; +} + +/* Card body: program info */ +.won-card-body { + padding: 7px 10px 10px; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.won-prog-title { + font-size: 0.88rem; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.35; +} + +.won-prog-title.no-data { + opacity: 0.4; + font-style: italic; + font-weight: 400; +} + +.won-prog-time { + font-size: 0.75rem; + opacity: 0.55; +} + +.won-prog-desc { + font-size: 0.78rem; + opacity: 0.65; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.4; + margin-top: 2px; +} + +/* Progress bar */ +.won-progress-wrap { + margin-top: 6px; + height: 4px; + background: rgba(255,255,255,0.08); + border-radius: 2px; + overflow: hidden; +} + +.won-progress-bar { + height: 100%; + background: #4a90d9; + border-radius: 2px; + transition: width 1s linear; +} + +/* ── Loading / error state ────────────────────────────────────── */ +.won-status { + text-align: center; + padding: 40px 16px; + opacity: 0.55; + font-size: 1rem; +} + +/* ── Group separator ──────────────────────────────────────────── */ +.won-group-label { + grid-column: 1 / -1; + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + opacity: 0.45; + padding: 6px 2px 2px; + border-bottom: 1px solid rgba(255,255,255,0.07); + margin-top: 4px; +} + +/* ── Empty state when no channels pass filter ─────────────────── */ +.won-empty { + grid-column: 1 / -1; + text-align: center; + padding: 32px 16px; + opacity: 0.45; + font-size: 0.95rem; +} + +/* ── Auto-refresh indicator ───────────────────────────────────── */ +.won-refresh-btn { + background: none; + border: 1px solid rgba(255,255,255,0.15); + border-radius: 4px; + color: inherit; + padding: 5px 10px; + font-size: 0.82rem; + cursor: pointer; + opacity: 0.75; +} + +.won-refresh-btn:hover { opacity: 1; } + +/* ── Light theme overrides ────────────────────────────────────── */ +[data-theme="light"] .won-search, +[data-theme="light"] .won-group-filter { + background: #fff; + border-color: rgba(0,0,0,0.22); + color: #111; +} + +[data-theme="light"] .won-card { + background: #fff; + border: 1px solid rgba(0,0,0,0.16); + color: #111; + box-shadow: 0 2px 8px rgba(0,0,0,0.10); +} + +[data-theme="light"] .won-card:hover { + background: #f0f0f0; + border-color: rgba(0,0,0,0.30); +} + +[data-theme="light"] .won-card-header { + border-bottom-color: rgba(0,0,0,0.10); +} + +[data-theme="light"] .won-logo-placeholder { + background: rgba(0,0,0,0.07); +} + +[data-theme="light"] .won-progress-wrap { + background: rgba(0,0,0,0.10); +} + +[data-theme="light"] .won-group-label { + border-bottom-color: rgba(0,0,0,0.14); +} + +/* ── RetroIPTV theme overrides ───────────────────────────────── */ +body.retroiptv .won-search, +body.retroiptv .won-group-filter { + background: #fffaf5; + border-color: rgba(59,15,26,0.28); + color: #111; +} + +body.retroiptv .won-card { + background: #fffaf5; + border: 1px solid rgba(59,15,26,0.28); + color: #111; + box-shadow: 0 2px 8px rgba(59,15,26,0.10); +} + +body.retroiptv .won-card:hover { + background: #fff4e6; + border-color: rgba(59,15,26,0.50); +} + +body.retroiptv .won-card-header { + border-bottom-color: rgba(59,15,26,0.12); +} + +body.retroiptv .won-logo-placeholder { + background: rgba(59,15,26,0.07); +} + +body.retroiptv .won-progress-wrap { + background: rgba(59,15,26,0.10); +} + +body.retroiptv .won-group-label { + border-bottom-color: rgba(59,15,26,0.18); +} + +/* ── TV Guide (Refresh) theme overrides ─────────────────────── */ +body.retro-magazine .won-search, +body.retro-magazine .won-group-filter { + background: #fff; + border-color: rgba(0,0,0,0.40); + color: #000; +} + +body.retro-magazine .won-card { + background: #fff; + border: 1px solid #000; + color: #000; + box-shadow: 3px 3px 0 rgba(0,0,0,0.14); +} + +body.retro-magazine .won-card:hover { + background: #f2f2f2; + box-shadow: 4px 4px 0 rgba(0,0,0,0.20); +} + +body.retro-magazine .won-card-header { + border-bottom-color: rgba(0,0,0,0.18); +} + +body.retro-magazine .won-logo-placeholder { + background: rgba(0,0,0,0.07); +} + +body.retro-magazine .won-progress-wrap { + background: rgba(0,0,0,0.12); +} + +body.retro-magazine .won-group-label { + border-bottom-color: rgba(0,0,0,0.30); +} + +/* ── TV Guide (Classic) theme overrides ─────────────────────── */ +body.tvguide1990 .won-search, +body.tvguide1990 .won-group-filter { + background: #fff; + border-color: rgba(0,0,0,0.30); + color: #000; +} + +body.tvguide1990 .won-card { + background: #fff; + border: 1px solid #999; + color: #000; + box-shadow: 1px 1px 4px rgba(0,0,0,0.18); +} + +body.tvguide1990 .won-card:hover { + background: #f5f5f5; + border-color: #555; +} + +body.tvguide1990 .won-card-header { + border-bottom-color: rgba(0,0,0,0.12); +} + +body.tvguide1990 .won-logo-placeholder { + background: rgba(0,0,0,0.07); +} + +body.tvguide1990 .won-progress-wrap { + background: rgba(0,0,0,0.12); +} + +body.tvguide1990 .won-group-label { + border-bottom-color: rgba(0,0,0,0.18); +} + +/* ── DirecTV theme overrides ─────────────────────────────────── */ +body.directv .won-search, +body.directv .won-group-filter { + background: rgba(0,0,0,0.25); + border-color: rgba(255,255,255,0.30); + color: #fff; +} + +body.directv .won-card { + background: #001f66; + border: 1px solid #4a7fff; + color: #fff; + box-shadow: 0 2px 10px rgba(0,0,0,0.35); +} + +body.directv .won-card:hover { + background: #1a3a80; + border-color: #7fbfff; +} + +body.directv .won-card-header { + border-bottom-color: rgba(255,255,255,0.12); +} + +body.directv .won-logo-placeholder { + background: rgba(255,255,255,0.12); +} + +body.directv .won-progress-wrap { + background: rgba(255,255,255,0.14); +} + +body.directv .won-group-label { + border-bottom-color: rgba(255,255,255,0.18); +} + +/* ── Responsive ───────────────────────────────────────────────── */ +@media (max-width: 640px) { + .won-container { margin: 16px auto 40px; padding: 0 10px; } + .won-grid { grid-template-columns: 1fr; } + .won-heading h1 { font-size: 1.1rem; } +} diff --git a/static/img/remote_icon.png b/static/img/remote_icon.png new file mode 100644 index 0000000..053f81b Binary files /dev/null and b/static/img/remote_icon.png differ diff --git a/static/js/mini-guide.js b/static/js/mini-guide.js new file mode 100644 index 0000000..8e18d39 --- /dev/null +++ b/static/js/mini-guide.js @@ -0,0 +1,458 @@ +(function () { + 'use strict'; + + var MINI_GUIDE_ROW_COUNT = 7; + var MINI_GUIDE_AUTODISMISS_MS = 8000; + var MINI_GUIDE_TOGGLE_KEY = 'g'; + + var isOpen = false; + var selectedIndex = 0; + var channels = []; + var dismissTimer = null; + var progressTimer = null; + var isHovering = false; + var guideLayout = ((window.__initialUserPrefs || {}).guide_layout === 'mini') ? 'mini' : 'full'; + var pageRenderQueued = false; + + function escapeCid(cid) { + if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(cid); + return String(cid || '').replace(/[^a-zA-Z0-9._-]/g, '\\$&'); + } + + function fmtTime(date) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + function fmtRemaining(ms) { + var minutes = Math.max(0, Math.round(ms / 60000)); + if (minutes < 1) return '< 1 min'; + if (minutes === 1) return '1 min'; + return minutes + ' min'; + } + + function currentChannelId() { + if (window.currentChannelMeta && window.currentChannelMeta.id) { + return window.currentChannelMeta.id; + } + return window._cibLastCid || null; + } + + function currentProgramFor(row) { + var now = new Date(); + var current = null; + row.querySelectorAll('.program').forEach(function (program) { + if (!program.dataset.start || !program.dataset.stop) return; + var start = new Date(program.dataset.start); + var stop = new Date(program.dataset.stop); + if (start <= now && stop >= now) { + current = { + title: program.dataset.title || program.textContent.trim(), + start: start, + stop: stop, + progressPct: Math.min(100, Math.max(0, ((now - start) / (stop - start)) * 100)), + remaining: fmtRemaining(stop - now) + }; + } + }); + return current; + } + + function isRowVisibleInGuide(row) { + if (row.classList.contains('chan-search-hidden')) return false; + if (row.classList.contains('chan-hidden') && !row.classList.contains('chan-hidden-visible')) return false; + if (document.body.classList.contains('favorites-only') && !row.classList.contains('chan-favorite')) return false; + return true; + } + + function collectChannels(options) { + options = options || {}; + channels = Array.prototype.slice.call(document.querySelectorAll('.guide-row[data-cid]')).filter(function (row) { + return !options.visibleOnly || isRowVisibleInGuide(row); + }).map(function (row, index) { + var chan = row.querySelector('.chan-name'); + var program = currentProgramFor(row); + var name = chan ? (chan.dataset.name || chan.textContent.trim()) : ''; + return { + index: index, + cid: row.dataset.cid || (chan ? chan.dataset.cid : ''), + url: chan ? (chan.dataset.url || '') : '', + name: name, + logo: chan ? (chan.dataset.logo || '') : '', + chanNum: chan ? (chan.dataset.chanNum || String(index + 1)) : String(index + 1), + programTitle: program ? program.title : 'No Guide Data Available', + timeRange: program ? (fmtTime(program.start) + ' - ' + fmtTime(program.stop)) : '', + remaining: program ? (program.remaining + ' left') : '', + progressPct: program ? program.progressPct : 0 + }; + }); + } + + function selectedWindow() { + var count = Math.min(MINI_GUIDE_ROW_COUNT, channels.length); + var start = Math.max(0, selectedIndex - Math.floor(count / 2)); + start = Math.min(start, Math.max(0, channels.length - count)); + return channels.slice(start, start + count); + } + + function setSelectedByCurrent() { + var cid = currentChannelId(); + var found = channels.findIndex(function (channel) { return channel.cid === cid; }); + selectedIndex = found >= 0 ? found : 0; + } + + function clearChildren(el) { + while (el.firstChild) el.removeChild(el.firstChild); + } + + function buildRow(channel, options) { + options = options || {}; + var row = document.createElement('button'); + row.type = 'button'; + row.className = 'mini-guide-row'; + row.id = (options.idPrefix || 'mg-row-') + channel.index; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', (!options.disableSelection && channel.index === selectedIndex) ? 'true' : 'false'); + row.dataset.index = String(channel.index); + row.dataset.url = channel.url; + row.dataset.cid = channel.cid; + row.dataset.name = channel.name; + + if (!options.disableSelection && channel.index === selectedIndex) row.classList.add('is-selected'); + if (channel.cid === currentChannelId()) row.classList.add('is-current'); + + var num = document.createElement('div'); + num.className = 'mg-channel-num'; + num.textContent = channel.chanNum ? 'CH ' + channel.chanNum : ''; + + var logoWrap; + if (channel.logo) { + logoWrap = document.createElement('img'); + logoWrap.className = 'mg-logo'; + logoWrap.src = channel.logo; + logoWrap.alt = ''; + logoWrap.onerror = function () { + var fallback = document.createElement('div'); + fallback.className = 'mg-logo-placeholder'; + this.replaceWith(fallback); + }; + } else { + logoWrap = document.createElement('div'); + logoWrap.className = 'mg-logo-placeholder'; + } + + var main = document.createElement('div'); + main.className = 'mg-main'; + + var top = document.createElement('div'); + top.className = 'mg-topline'; + + var name = document.createElement('div'); + name.className = 'mg-channel-name'; + name.textContent = channel.name; + + var title = document.createElement('div'); + title.className = 'mg-program-title'; + title.textContent = channel.programTitle; + + top.appendChild(name); + top.appendChild(title); + + var meta = document.createElement('div'); + meta.className = 'mg-meta'; + + var time = document.createElement('span'); + time.className = 'mg-time'; + time.textContent = channel.timeRange; + + var progress = document.createElement('div'); + progress.className = 'mg-progress-wrap'; + var bar = document.createElement('div'); + bar.className = 'mg-progress-bar'; + bar.style.width = channel.progressPct + '%'; + progress.appendChild(bar); + + var remaining = document.createElement('span'); + remaining.className = 'mg-remaining'; + remaining.textContent = channel.remaining; + + meta.appendChild(time); + meta.appendChild(progress); + meta.appendChild(remaining); + main.appendChild(top); + main.appendChild(meta); + + row.appendChild(num); + row.appendChild(logoWrap); + row.appendChild(main); + row.addEventListener('click', function () { + selectedIndex = channel.index; + tuneSelected(); + }); + row.addEventListener('mouseenter', function () { + if (options.disableSelection) return; + selectedIndex = channel.index; + render(); + }); + + return row; + } + + function render() { + var list = document.getElementById('mgChannelList'); + if (!list) return; + collectChannels(); + if (!channels.length) { + clearChildren(list); + return; + } + if (selectedIndex < 0 || selectedIndex >= channels.length) setSelectedByCurrent(); + + clearChildren(list); + selectedWindow().forEach(function (channel) { + list.appendChild(buildRow(channel)); + }); + list.setAttribute('aria-activedescendant', 'mg-row-' + selectedIndex); + } + + function renderPageMiniGuide() { + var list = document.getElementById('miniGuidePageList'); + var count = document.getElementById('miniGuidePageCount'); + if (!list) return; + + collectChannels({ visibleOnly: true }); + clearChildren(list); + if (count) count.textContent = channels.length + (channels.length === 1 ? ' channel' : ' channels'); + + if (!channels.length) { + var empty = document.createElement('div'); + empty.className = 'mini-guide-empty'; + empty.textContent = 'No channels match the current filters.'; + list.appendChild(empty); + return; + } + + channels.forEach(function (channel) { + list.appendChild(buildRow(channel, { idPrefix: 'mg-page-row-', disableSelection: true })); + }); + } + + function schedulePageRender() { + if (guideLayout !== 'mini' || pageRenderQueued) return; + pageRenderQueued = true; + requestAnimationFrame(function () { + pageRenderQueued = false; + renderPageMiniGuide(); + }); + } + + function syncLayoutButtons() { + var mini = guideLayout === 'mini'; + var label = mini ? '▦ Regular Guide Layout' : '▤ Mini Guide Layout'; + ['toggleGuideLayout', 'mobileToggleGuideLayout'].forEach(function (id) { + var el = document.getElementById(id); + if (!el) return; + el.textContent = label; + el.setAttribute('aria-pressed', mini ? 'true' : 'false'); + }); + } + + function persistGuideLayout() { + var patch = { guide_layout: guideLayout }; + if (window.__userPrefs && typeof window.__userPrefs.save === 'function') { + window.__userPrefs.save(patch); + return; + } + fetch('/api/user_prefs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(patch) + }).catch(function () {}); + } + + function applyGuideLayout(layout, persist) { + guideLayout = layout === 'mini' ? 'mini' : 'full'; + var miniPage = document.getElementById('miniGuidePage'); + document.body.classList.toggle('guide-layout-mini', guideLayout === 'mini'); + if (miniPage) miniPage.hidden = guideLayout !== 'mini'; + syncLayoutButtons(); + + if (guideLayout === 'mini') { + renderPageMiniGuide(); + } else { + requestAnimationFrame(function () { + if (typeof window.createOrUpdateFixedTimeBar === 'function') window.createOrUpdateFixedTimeBar(); + if (typeof window.updateNowLine === 'function') window.updateNowLine(); + window.dispatchEvent(new Event('resize')); + }); + } + + document.dispatchEvent(new CustomEvent('guide-layout:changed', { detail: { layout: guideLayout } })); + + if (persist) persistGuideLayout(); + } + + function toggleGuideLayout() { + applyGuideLayout(guideLayout === 'mini' ? 'full' : 'mini', true); + } + + function scheduleDismiss() { + cancelDismiss(); + if (!isOpen || isHovering) return; + dismissTimer = setTimeout(closeMiniGuide, MINI_GUIDE_AUTODISMISS_MS); + } + + function cancelDismiss() { + if (dismissTimer) { + clearTimeout(dismissTimer); + dismissTimer = null; + } + } + + function resetIdleTimer() { + scheduleDismiss(); + } + + function openMiniGuide() { + var panel = document.getElementById('miniGuide'); + if (!panel) return; + collectChannels(); + setSelectedByCurrent(); + isOpen = true; + panel.classList.add('is-open'); + panel.setAttribute('aria-hidden', 'false'); + render(); + scheduleDismiss(); + if (progressTimer) clearInterval(progressTimer); + progressTimer = setInterval(function () { + if (!isOpen) return; + render(); + }, 15000); + } + + function closeMiniGuide() { + var panel = document.getElementById('miniGuide'); + if (!panel) return; + isOpen = false; + panel.classList.remove('is-open'); + panel.setAttribute('aria-hidden', 'true'); + cancelDismiss(); + if (progressTimer) { + clearInterval(progressTimer); + progressTimer = null; + } + } + + function toggleMiniGuide() { + if (isOpen) closeMiniGuide(); + else openMiniGuide(); + } + + function moveSelection(delta) { + if (!channels.length) collectChannels(); + if (!channels.length) return; + selectedIndex = Math.min(channels.length - 1, Math.max(0, selectedIndex + delta)); + render(); + resetIdleTimer(); + } + + function tuneSelected() { + var channel = channels[selectedIndex]; + if (!channel || !channel.url || typeof window.playChannel !== 'function') return; + window.playChannel(channel.url, channel.cid, channel.name); + closeMiniGuide(); + } + + function shouldIgnoreShortcut(event) { + var tag = (document.activeElement || {}).tagName || ''; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || event.ctrlKey || event.metaKey || event.altKey; + } + + function wire() { + var button = document.getElementById('miniGuideBtn'); + var closeButton = document.getElementById('miniGuideClose'); + var panel = document.getElementById('miniGuide'); + + if (button) { + button.addEventListener('click', function (event) { + event.stopPropagation(); + toggleMiniGuide(); + }); + } + if (closeButton) { + closeButton.addEventListener('click', function () { + closeMiniGuide(); + }); + } + if (panel) { + panel.addEventListener('mouseenter', function () { + isHovering = true; + cancelDismiss(); + }); + panel.addEventListener('mouseleave', function () { + isHovering = false; + scheduleDismiss(); + }); + panel.addEventListener('mousemove', resetIdleTimer); + } + + ['toggleGuideLayout', 'mobileToggleGuideLayout'].forEach(function (id) { + var layoutToggle = document.getElementById(id); + if (!layoutToggle) return; + layoutToggle.addEventListener('click', function (event) { + event.preventDefault(); + toggleGuideLayout(); + }); + }); + + var guideOuter = document.getElementById('guideOuter'); + if (guideOuter && typeof MutationObserver === 'function') { + var observer = new MutationObserver(schedulePageRender); + observer.observe(guideOuter, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'] + }); + } + ['guideSearchInput', 'guideTypeFilter'].forEach(function (id) { + var input = document.getElementById(id); + if (input) input.addEventListener('input', schedulePageRender); + if (input) input.addEventListener('change', schedulePageRender); + }); + + document.addEventListener('keydown', function (event) { + if (shouldIgnoreShortcut(event)) return; + if (event.key && event.key.toLowerCase() === MINI_GUIDE_TOGGLE_KEY) { + event.preventDefault(); + toggleMiniGuide(); + return; + } + if (!isOpen) return; + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveSelection(-1); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + moveSelection(1); + } else if (event.key === 'Enter') { + event.preventDefault(); + tuneSelected(); + } else if (event.key === 'Escape') { + event.preventDefault(); + closeMiniGuide(); + } + }); + + applyGuideLayout(guideLayout, false); + } + + window.openMiniGuide = openMiniGuide; + window.closeMiniGuide = closeMiniGuide; + window.toggleMiniGuide = toggleMiniGuide; + window.setGuideLayout = function (layout, persist) { applyGuideLayout(layout, persist !== false); }; + window.toggleGuideLayout = toggleGuideLayout; + window.renderMiniGuidePage = renderPageMiniGuide; + + document.addEventListener('DOMContentLoaded', wire); +})(); diff --git a/static/js/reminders.js b/static/js/reminders.js new file mode 100644 index 0000000..fc67722 --- /dev/null +++ b/static/js/reminders.js @@ -0,0 +1,503 @@ +// reminders.js — Program reminders / browser notifications for RetroIPTVGuide +// +// Lets users set a reminder on any upcoming program in the guide grid. +// When the program is about to start (configurable minutes in advance), +// the browser fires a native Notification. +// +// Usage: +// • Right-click any future .program block → "🔔 Set Reminder" (or remove) +// • Click the 🔔 Reminders button in the header to view / manage reminders +// +// Reminders are persisted server-side in user prefs (key "reminders") via +// POST /api/user_prefs and are loaded from window.__initialUserPrefs. +// +// Public API exposed as window.__reminders: +// .list() — returns copy of current reminders array +// .open() — opens the reminders panel +// .close() — closes the reminders panel + +(function () { + 'use strict'; + + var POLL_MS = 30 * 1000; // check every 30 s + var DEFAULT_NOTIFY_BEFORE = 5; // minutes before program start + var FIRED_KEY = 'reminders_fired'; // sessionStorage key for fired IDs + var _timer = null; + var _panel = null; + + // ─── State ──────────────────────────────────────────────────────────────── + var _reminders = []; // [{id, channel_id, channel_name, program_title, program_start, notify_before_mins}] + + // Load initial state from server-injected prefs + (function () { + var ip = (typeof window.__initialUserPrefs === 'object' && window.__initialUserPrefs) || {}; + var saved = ip.reminders; + if (Array.isArray(saved)) _reminders = saved.slice(); + }()); + + // ─── Helpers ────────────────────────────────────────────────────────────── + function log() { + if (window.console && console.debug) { + console.debug.apply(console, ['[reminders]'].concat(Array.from(arguments))); + } + } + + function esc(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function uniqueId() { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + } + + /** Return the set of already-fired reminder IDs for this session. */ + function firedSet() { + try { + return new Set(JSON.parse(sessionStorage.getItem(FIRED_KEY) || '[]')); + } catch (e) { + return new Set(); + } + } + + /** Mark a reminder ID as fired in sessionStorage. */ + function markFired(id) { + try { + var s = firedSet(); + s.add(id); + sessionStorage.setItem(FIRED_KEY, JSON.stringify(Array.from(s))); + } catch (e) { /* ignore */ } + } + + // ─── Server persistence ─────────────────────────────────────────────────── + async function persist() { + try { + await fetch('/api/user_prefs', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reminders: _reminders }) + }); + } catch (e) { + log('persist failed', e); + } + } + + // ─── Notification permission ─────────────────────────────────────────────── + /** Request browser notification permission if not already granted. + * Returns a Promise that resolves to true if permission is granted. */ + function requestPermission() { + if (!('Notification' in window)) return Promise.resolve(false); + if (Notification.permission === 'granted') return Promise.resolve(true); + if (Notification.permission === 'denied') return Promise.resolve(false); + return Notification.requestPermission().then(function (p) { + return p === 'granted'; + }); + } + + // ─── Reminder CRUD ──────────────────────────────────────────────────────── + async function addReminder(channelId, channelName, programTitle, programStart, notifyBeforeMins) { + // Remove any existing reminder for the same program/channel + _reminders = _reminders.filter(function (r) { + return !(r.channel_id === channelId && r.program_start === programStart); + }); + _reminders.push({ + id: uniqueId(), + channel_id: channelId, + channel_name: channelName, + program_title: programTitle, + program_start: programStart, + notify_before_mins: notifyBeforeMins + }); + await persist(); + applyBellMarkers(); + renderPanel(); + log('added reminder for', programTitle); + } + + async function removeReminder(id) { + _reminders = _reminders.filter(function (r) { return r.id !== id; }); + await persist(); + applyBellMarkers(); + renderPanel(); + log('removed reminder', id); + } + + /** Check if a reminder is already set for a given channel+programStart. */ + function hasReminder(channelId, programStart) { + return _reminders.some(function (r) { + return r.channel_id === channelId && r.program_start === programStart; + }); + } + + // ─── Bell markers ───────────────────────────────────────────────────────── + /** Annotate .program elements that have an active reminder with a bell icon. */ + function applyBellMarkers() { + // Build a lookup: "channelId|programStart" → true + var lookup = {}; + _reminders.forEach(function (r) { + lookup[r.channel_id + '|' + r.program_start] = true; + }); + + document.querySelectorAll('.guide-row[data-cid] .program').forEach(function (prog) { + var row = prog.closest('.guide-row[data-cid]'); + if (!row) return; + var cid = row.dataset.cid || ''; + var start = prog.dataset.start || ''; + var key = cid + '|' + start; + var bell = prog.querySelector('.reminder-bell'); + + if (lookup[key]) { + if (!bell) { + bell = document.createElement('span'); + bell.className = 'reminder-bell'; + bell.setAttribute('aria-label', 'Reminder set'); + bell.textContent = '🔔'; + prog.appendChild(bell); + } + } else { + if (bell) bell.parentNode.removeChild(bell); + } + }); + } + + // ─── Polling / notification firing ──────────────────────────────────────── + function checkReminders() { + if (!_reminders.length) return; + if (!('Notification' in window) || Notification.permission !== 'granted') return; + + var now = Date.now(); + var fired = firedSet(); + + _reminders.forEach(function (r) { + if (fired.has(r.id)) return; + var start = new Date(r.program_start).getTime(); + var notifyAt = start - (r.notify_before_mins || 0) * 60 * 1000; + if (now >= notifyAt && now < start + 5 * 60 * 1000) { + // Fire the notification + markFired(r.id); + var notifyBefore = r.notify_before_mins || 0; + var body = notifyBefore > 0 + ? 'Starts in ' + notifyBefore + ' min on ' + r.channel_name + : 'Now starting on ' + r.channel_name; + try { + var n = new Notification('📺 ' + r.program_title, { + body: body, + icon: '/static/logo.png', + tag: 'reminder-' + r.id + }); + n.onclick = function () { + window.focus(); + n.close(); + }; + } catch (e) { + log('notification failed', e); + } + log('fired notification for', r.program_title); + } + }); + } + + function startPolling() { + if (_timer !== null) return; + _timer = setInterval(checkReminders, POLL_MS); + checkReminders(); // run immediately on start + } + + // ─── Reminder panel ─────────────────────────────────────────────────────── + function ensurePanel() { + if (_panel) return _panel; + + _panel = document.createElement('div'); + _panel.id = 'reminders-panel'; + _panel.setAttribute('role', 'dialog'); + _panel.setAttribute('aria-modal', 'true'); + _panel.setAttribute('aria-label', 'Reminders'); + _panel.style.cssText = [ + 'position:fixed', 'top:60px', 'right:12px', + 'z-index:9600', + 'background:var(--panel-bg,#1a1a1a)', + 'color:var(--panel-text,#eee)', + 'border:1px solid rgba(255,255,255,0.14)', + 'border-radius:9px', + 'padding:14px 16px', + 'min-width:280px', 'max-width:360px', + 'max-height:70vh', 'overflow-y:auto', + 'box-shadow:0 8px 28px rgba(0,0,0,0.55)', + 'display:none', + 'font-size:0.93rem' + ].join(';'); + + document.body.appendChild(_panel); + return _panel; + } + + function renderPanel() { + var panel = ensurePanel(); + var html = '

' + + '🔔 Reminders' + + '' + + '
'; + + if (_reminders.length === 0) { + html += '

No reminders set.
' + + 'Right-click a future program in the guide to add one.

'; + } else { + 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 %}
--:-- --
@@ -132,6 +160,7 @@ {% if request.endpoint == 'guide' %}
  • {% endif %} +
  • WHAT'S ON NOW
  • @@ -200,7 +229,10 @@
  • 👁 Show Hidden Channels
  • ⭐ Show Favorites Only
  • +
  • 🧭 Enable Browse Mode
  • +
  • ▤ Mini Guide Layout
  • +
  • 🔔 Reminders
  • {% endif %} diff --git a/templates/admin_diagnostics.html b/templates/admin_diagnostics.html index c0992b9..e704d39 100644 --- a/templates/admin_diagnostics.html +++ b/templates/admin_diagnostics.html @@ -169,6 +169,20 @@ .ch-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; } .ch-table th { color: var(--dc-subtle); font-weight: normal; text-transform: uppercase; font-size: 0.68rem; padding: 0.25rem 0.4rem; border-bottom: 1px solid var(--dc-border-faint); } .ch-table td { padding: 0.25rem 0.4rem; border-bottom: 1px solid var(--dc-border-faint); font-family: monospace; word-break: break-all; } +.channel-health-summary { display:flex; gap:0.6rem; flex-wrap:wrap; margin:0.7rem 0; } +.channel-health-stat { border:1px solid var(--dc-border); border-radius:3px; padding:0.35rem 0.65rem; background:var(--dc-bg-deep); font-size:0.82rem; color:var(--dc-muted2); } +.channel-health-stat b { color:var(--dc-text); } +.channel-health-table-wrap { overflow-x:auto; max-height:55vh; border:1px solid var(--dc-border); border-radius:4px; } +.channel-health-table { width:100%; border-collapse:collapse; font-size:0.82rem; } +.channel-health-table th { position:sticky; top:0; background:var(--dc-bg-panel); color:var(--dc-muted); font-weight:normal; text-transform:uppercase; font-size:0.72rem; padding:0.4rem 0.5rem; border-bottom:1px solid var(--dc-border); text-align:left; } +.channel-health-table td { padding:0.35rem 0.5rem; border-bottom:1px solid var(--dc-border-faint); vertical-align:top; } +.channel-health-table td.mono { font-family:monospace; word-break:break-all; } +.diag-health-status { display:inline-flex; align-items:center; gap:0.35rem; font-weight:700; text-transform:uppercase; font-size:0.75rem; } +.diag-health-dot { width:8px; height:8px; border-radius:50%; display:inline-block; background:#888; box-shadow:0 0 0 1px rgba(255,255,255,0.15); } +.diag-health-dot.chi-up { background:#33cc66; } +.diag-health-dot.chi-down { background:#ff4444; } +.diag-health-dot.chi-virtual { background:#66a3ff; } +.diag-health-dot.chi-pending { background:#888; } /* Issue draft tab */ .issue-desc-area { width: 100%; min-height: 80px; background: var(--dc-bg-input); color: var(--dc-input-text); border: 1px solid var(--dc-border); border-radius: 3px; padding: 0.5rem; font-size: 0.88rem; font-family: inherit; resize: vertical; box-sizing: border-box; margin-bottom: 0.6rem; } .issue-title-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.7rem; } @@ -383,6 +397,16 @@

    Health Checks