From eaca4d18e5006784fe79f79fae1aaaf874a38dfc Mon Sep 17 00:00:00 2001
From: thehack904 <35552907+thehack904@users.noreply.github.com>
Date: Sat, 2 May 2026 20:08:00 -0500
Subject: [PATCH 1/3] Initial update for v4.9.5 -beta
---
CHANGELOG.md | 90 ++++---------------
ROADMAP.md | 59 +++----------
app.py | 15 ++--
static/css/base.css | 21 ++++-
static/css/change_tuner.css | 40 +++++++++
static/css/mobile-scroll-fix.css | 56 ++++++------
static/css/mobile.css | 15 ++++
static/js/auto-scroll.js | 28 ++++--
static/js/display-size.js | 56 +++++-------
static/js/grid-adapt.js | 30 ++++---
static/js/guide-refresh.js | 37 +++++++-
static/js/mobile-scroll-fix.js | 39 ++-------
static/js/tv-remote-nav.js | 16 +++-
static/js/video-resize.js | 14 ++-
templates/guide.html | 144 +++++++++++++++++++++++++------
templates/nasa.html | 4 +-
templates/news.html | 4 +-
templates/on_this_day.html | 4 +-
templates/status.html | 4 +-
templates/traffic.html | 4 +-
templates/weather.html | 4 +-
tests/test_audio.py | 4 +
tests/test_logo_upload.py | 3 +-
tests/test_traffic_demo.py | 3 +
tests/test_tuner_validation.py | 14 +--
tests/test_weather.py | 3 +
26 files changed, 420 insertions(+), 291 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3408fb3..87f00c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@ This project follows [Semantic Versioning](https://semver.org/).
---
+## v4.9.5 - 2026-04-29
+
+### Fixed
+- Fixed virtual channel standalone pages not fitting the viewport on mobile devices.
+ - Updated `weather.html`, `news.html`, `status.html`, `traffic.html`, `nasa.html`, and `on_this_day.html` to use `height: 100vh` (instead of `min-height: 100vh`) so the page fills the screen without overflow.
+ - Added `calc(100vh * 16 / 9)` as an additional constraint to each channel frame's `width: min(...)` so the 16:9 frame stays fully visible and correctly proportioned on landscape mobile screens. This brings these pages in line with `sports.html` and `updates.html`.
+- Improved mobile layout of the Virtual Channels admin page (`/virtual_channels`):
+ - Channel list items now wrap gracefully on narrow screens instead of overflowing.
+ - Channel names allow wrapping on very small viewports.
+ - Per-channel preferences fields stack vertically at ≤ 700 px.
+ - Traffic demo city table gains horizontal scrolling so all columns remain accessible on narrow screens.
+ - Audio file list items stack vertically on mobile so filenames and audio controls are readable.
+ - Drag handles in the Channel Order list are larger for easier touch interaction.
+ - Page title font size is reduced slightly on narrow screens to prevent overflow.
+
+---
+
## v4.9.4 - 2026-04-25
### Added
@@ -15,7 +32,6 @@ This project follows [Semantic Versioning](https://semver.org/).
- Deprecation notice added to the Windows Installation section of `INSTALL.md`.
- Deprecation note added to `ROADMAP.md` under Installer Enhancements.
- Deprecation notice shown on the **About** page when RetroIPTVGuide is running on Windows.
- - The About page now shows a Windows deprecation notice when running on Windows.
- Added structured wiki documentation under `docs/wiki/`:
- `Home.md` — wiki landing page with navigation table
- `Installation.md` — full installation guide covering Docker, Linux, Raspberry Pi, and Windows
@@ -24,78 +40,6 @@ This project follows [Semantic Versioning](https://semver.org/).
- `FAQ.md` — frequently asked questions
- `Troubleshooting.md` — common problems and step-by-step fixes
- Updated `README.md` to link to the new wiki pages.
-- Added Space Channel naming to replace NASA-facing labels.
-- Added optional external sports data configuration:
- - External sports data is disabled by default.
- - Users must explicitly enable external sports data.
- - Users must provide their own compatible scores API base URL.
- - Added notice that RetroIPTVGuide does not provide sports data.
-- Added weather channel segment rotation controls.
- - Current Conditions
- - 5-Day Forecast
- - Regional Radar
- - Severe Weather Alerts
-- Added configurable weather segment duration.
-- Added NOAA-style regional radar URL generation based on configured latitude/longitude.
-- Added XMLTV program icon support for guide display.
-- Added M3U `group-title` parsing for channel grouping.
-- Added new guide theme styling:
- - Classic Cable-style theme rules
- - Icon/ErsatzTV-style guide theme rules
-
-### Changed
-- Bumped application version from `v4.9.3` to `v4.9.4`.
-- Updated release date from `2026-04-08` to `2026-04-25`.
-- Renamed the virtual NASA channel display name to **Space Channel**.
-- Updated NASA-facing preview text to Space Channel-facing text.
-- Removed hardcoded ESPN scoreboard API behavior.
-- Replaced ESPN-specific sports score fetching with a user-configured external JSON scores endpoint.
-- Changed sports league defaults so leagues are no longer enabled by default.
-- Changed external sports data behavior so no outside sports requests are made unless explicitly enabled.
-- Changed virtual channel defaults from enabled by default to disabled by default.
-- Changed default overlay refresh from 300 seconds to 30 seconds.
-- Updated Updates / Announcements wording from “Beta” to “Pre-release”.
-- Updated README documentation links to point to the new wiki documentation.
-- Updated install documentation to better position Docker as the recommended long-term deployment method.
-- Updated guide theme handling so server-side/admin-assigned default themes override local browser storage.
-- Updated guide rendering to carry channel group metadata into the DOM.
-- Updated program blocks to support optional XMLTV icon artwork.
-- Updated Channel Mix fullscreen behavior to better sync the active sub-channel iframe while fullscreen is open.
-- Updated Virtual Channels admin UI:
- - Shows disabled virtual channels differently due to default-disabled behavior.
- - Adds weather rotation details.
- - Adds sports external data opt-in controls.
- - Adds sports scores base URL input.
- - Starts icon pack details collapsed by default.
-- Updated diagnostics/config reporting to include newer virtual-channel and sports/weather configuration fields.
-- Updated Linux and Raspberry Pi installer scripts.
-- Updated Windows installer scripts with deprecation notices.
-- Updated CodeQL workflow configuration.
-- Updated Unraid Docker documentation wording.
-
-### Fixed
-- Fixed virtual channel enablement logic so missing settings no longer implicitly enable all virtual channels.
-- Fixed sports channel behavior to avoid relying on a specific third-party API by default.
-- Fixed weather virtual channel layout to support multi-screen rotation without overflowing.
-- Fixed guide theme persistence so admin/user-selected defaults apply reliably.
-- Fixed tuner validation behavior to allow single `.m3u8` stream tuners without requiring an XML URL.
-- Fixed tuner validation behavior around private/local M3U URLs.
-- Fixed drag/reorder handling cleanup in the Virtual Channels admin page.
-- Fixed additional test coverage around:
- - virtual channels
- - tuner validation
- - traffic demo behavior
- - admin diagnostics
- - app configuration diagnostics
-
-### Removed
-- Removed default Plex and Tubi sample tuner entries.
-- Removed ESPN-specific naming and API dependency from sports score handling.
-- Removed public-facing NASA channel naming in favor of Space Channel naming.
-
-### Deprecated
-- Deprecated the Windows installer path for future removal in v5.0.
-- Docker is now the recommended deployment method going forward.
---
diff --git a/ROADMAP.md b/ROADMAP.md
index 6621402..7095218 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,10 +11,10 @@ These are **not yet implemented**, partially implemented, or completed in previo
## 🔮 Feature Upgrades
### 1. Tuner Management
-- [x] Add ability to **add/remove tuners** from the UI *(v2.3.0)*
-- [x] Add ability to rename tuners via the UI *(v2.3.0)*
-- [x] Support for **.m3u8 single-channel playlists** as tuner sources *(v4.6.0)*
-- [x] Validate tuner URLs *(v2.0.0 → improved v4.9.1–v4.9.4)*
+- [x] Add ability to **add/remove tuners** from the UI (v2.3.0).
+- [x] Add ability to rename tuners via the UI (v2.3.0).
+- [x] Support for **.m3u8 single-channel playlists** as tuner sources. *(v4.6.0)*
+- [x] Validate tuner URLs *(v2.0.0 → improved v4.9.1–v4.9.2)*
- [x] Duplicate tuner name prevention *(v4.6.0)*
- [x] Optional auto-refresh of tuner lineup *(v4.3.0)*
- [x] Per-user tuner assignment and default preferences *(v4.7.0)*
@@ -32,7 +32,6 @@ These are **not yet implemented**, partially implemented, or completed in previo
- [x] Post-install verification *(v3.1.0)*
- [x] Unified guide refresh scheduler *(v4.2.0)*
- [x] Sanitized diagnostics and error responses *(v4.9.1–v4.9.2)*
-- [x] Expanded diagnostics/config reporting *(v4.9.4)*
---
@@ -40,10 +39,7 @@ These are **not yet implemented**, partially implemented, or completed in previo
- [x] Auto-Scroll *(v4.1.0 → v4.6.0 enhancements)*
- [x] Responsive layout *(v4.2.0)*
- [x] Fullscreen improvements for virtual channels *(v4.9.3)*
-- [x] Channel Mix dynamic fullscreen switching *(v4.9.3 → improved v4.9.4)*
-- [x] XMLTV program icon support *(v4.9.4)*
-- [x] M3U `group-title` parsing *(v4.9.4)*
-- [x] Theme priority handling (server/admin defaults override stale browser storage) *(v4.9.4)*
+- [x] Channel Mix dynamic fullscreen switching *(v4.9.3)*
- [ ] Search/filter box
- [ ] Favorites (lightweight)
- [ ] Channel Info Banner
@@ -78,10 +74,7 @@ These are **not yet implemented**, partially implemented, or completed in previo
- [x] Mobile responsiveness *(v4.2.0)*
- [x] Theme auto-detect *(v4.5.0)*
- [x] Display size settings *(v4.6.0)*
-- [x] Virtual channel fullscreen enhancements *(v4.8.0–v4.9.4)*
-- [x] New guide themes:
- - Classic Cable Style *(v4.9.4)*
- - Icon / ErsatzTV Style *(v4.9.4)*
+- [x] Virtual channel fullscreen enhancements *(v4.8.0–v4.9.3)*
---
@@ -89,7 +82,6 @@ These are **not yet implemented**, partially implemented, or completed in previo
- [x] Linux / Windows / Raspberry Pi installers *(v4.0.0)*
- [x] Windows parity *(v4.1.0)*
- [ ] TrueNAS SCALE App Catalog certification
-- [ ] Windows installer retirement in v5.0 (Docker-first direction)
---
@@ -101,26 +93,16 @@ These are **not yet implemented**, partially implemented, or completed in previo
### 8. Virtual Channels
- [x] Virtual Channels framework *(v4.8.0)*
-- [x] Weather *(v4.8.0 → expanded v4.9.4)*
+- [x] Weather *(v4.8.0)*
- [x] News *(v4.8.0)*
- [x] Traffic *(v4.8.0)*
- [x] System Status *(v4.8.0)*
- [x] Updates *(v4.9.3)*
-- [x] Sports *(v4.9.3 → reworked v4.9.4)*
-- [x] Space Channel *(renamed from NASA in v4.9.4)*
+- [x] Sports *(v4.9.3)*
+- [x] NASA *(v4.9.3)*
- [x] On This Day *(v4.9.3)*
- [x] Channel Mix *(v4.9.3)*
-- [x] Expanded Virtual Channels admin UI *(v4.9.3 → improved v4.9.4)*
-- [x] Virtual channels default disabled for safer deployments *(v4.9.4)*
-- [x] Weather segment rotation:
- - Current Conditions
- - 5-Day Forecast
- - Regional Radar
- - Severe Weather Alerts *(v4.9.4)*
-- [x] Configurable weather segment duration *(v4.9.4)*
-- [x] Regional radar generation using configured coordinates *(v4.9.4)*
-- [x] External sports data opt-in only *(v4.9.4)*
-- [x] User-configurable sports scores API base URL *(v4.9.4)*
+- [x] Expanded Virtual Channels admin UI *(v4.9.3)*
- [ ] Advanced Virtual Channel Composition Engine
- Scheduled rotation blocks
- Weighted/random playback
@@ -133,34 +115,19 @@ These are **not yet implemented**, partially implemented, or completed in previo
- [x] DB schema migration guards *(v4.7.1)*
- [x] Security hardening *(v4.9.1–v4.9.2)*
- [x] Database-backed activity logging *(v4.9.3)*
-- [x] CodeQL workflow improvements *(v4.9.4)*
---
## 🍓 Installer Enhancements
- [x] Unified installer architecture
- [x] Windows parity
-- [x] Windows deprecation notices added *(v4.9.4)*
-- [ ] **Deprecate Windows installer in v5.0** — Docker is the recommended deployment method going forward
-
----
-
-## 📚 Documentation
-- [x] Added GitHub Wiki-ready documentation structure *(v4.9.4)*
- - Home
- - Installation
- - Configuration
- - Virtual Channels
- - FAQ
- - Troubleshooting
-- [x] Updated README links to point users toward the new wiki documentation *(v4.9.4)*
-- [x] Updated install documentation around Docker-first deployment direction *(v4.9.4)*
+- [ ] **Deprecate Windows installer in v5.0** — Docker is the recommended deployment method going forward
---
## User Submitted Enhancements
- [x] Resize Pop Out Video *(v4.6.0)*
- [x] Resize video *(v4.6.0)*
-- [x] Auto load Channel from Guide
+- [ ] Auto load Channel from Guide
- [x] Adjustable scrolling speed *(v4.6.0)*
-- [x] Unraid Template
\ No newline at end of file
+- [x] Unraid Template
diff --git a/app.py b/app.py
index fc22681..0cce54d 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,6 @@
# app.py — merged version (features from both sources)
-APP_VERSION = "v4.9.4"
-APP_RELEASE_DATE = "2026-04-25"
+APP_VERSION = "v4.9.5-beta"
+APP_RELEASE_DATE = "2026-05-02"
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
@@ -485,10 +485,11 @@ def add_tuner(name, xml_url, m3u_url):
if name in tuners:
raise ValueError(f"Tuner '{name}' already exists")
- # Validate XML URL (optional – single .m3u8 stream tuners may omit it)
- if xml_url and xml_url.strip():
- if not xml_url.startswith(('http://', 'https://')):
- raise ValueError("XML URL must start with http:// or https://")
+ # Validate XML URL
+ if not xml_url or not xml_url.strip():
+ raise ValueError("XML URL cannot be empty")
+ if not xml_url.startswith(('http://', 'https://')):
+ raise ValueError("XML URL must start with http:// or https://")
# Validate M3U URL
if not m3u_url or not m3u_url.strip():
@@ -4084,8 +4085,8 @@ def change_tuner():
log_event(current_user.username, f"Failed to add combined tuner {name}: {str(e)}")
else:
if tuner_mode == "single_stream":
- xml_url = ""
m3u_url = request.form.get("m3u8_stream_url", "").strip()
+ xml_url = m3u_url
else:
xml_url = request.form.get("xml_url", "").strip()
m3u_url = request.form.get("m3u_url", "").strip()
diff --git a/static/css/base.css b/static/css/base.css
index ced8ede..c256726 100644
--- a/static/css/base.css
+++ b/static/css/base.css
@@ -322,8 +322,8 @@ body { font-family: Arial, sans-serif; margin:0; }
display: flex;
align-items: stretch;
overflow: hidden;
- background: transparent;
- box-shadow: 0 2px 6px rgba(0,0,0,0.12);
+ background: var(--timebar-bg, rgba(255,255,255,0.95));
+ box-shadow: 0 2px 8px rgba(0,0,0,0.18);
border-bottom: 1px solid var(--timebar-border, rgba(0,0,0,0.12));
height: 34px;
top: 0;
@@ -339,6 +339,23 @@ body { font-family: Arial, sans-serif; margin:0; }
.time-header-fixed .now-line { position:absolute; top:0; bottom:0; width:2px; z-index:1300; pointer-events:none; }
.time-header-fixed .left-spacer { flex: 0 0 auto; height:100%; background: var(--chan-col-bg, #1a1a1a); border-right: 1px solid var(--timebar-border, rgba(0,0,0,0.12)); pointer-events: none; }
.hide-in-grid .time-header-wrap { visibility: hidden; height: 0; margin:0; padding:0; }
+/* Collapse the entire gridTimeRow so the empty chan-header strip is not visible
+ between the fixed timebar and the first real channel row. Elements stay in the
+ DOM so JS can still clone time cells and compute the spacer width.
+ visibility:hidden is required in addition to height:0/overflow:hidden because
+ .chan-header uses position:sticky and sticky elements anchor to the nearest
+ scroll container (.guide-outer), allowing them to escape an intermediate
+ overflow:hidden parent — visibility:hidden makes all descendants invisible
+ regardless of their positioning model. JS cloneNode() is unaffected since
+ visibility is a CSS cascade property on the ancestor, not carried on clones. */
+.guide-row.hide-in-grid {
+ height: 0 !important;
+ overflow: hidden !important;
+ visibility: hidden !important;
+ border: none !important;
+ padding: 0 !important;
+ margin: 0 !important;
+}
/* Theme variables & theme-specific rules preserved below (copied/preserved from original base.css) */
diff --git a/static/css/change_tuner.css b/static/css/change_tuner.css
index f317bca..117729d 100644
--- a/static/css/change_tuner.css
+++ b/static/css/change_tuner.css
@@ -849,6 +849,46 @@ button:focus, select:focus, input:focus, textarea:focus {
.mode-card { min-width: unset; }
.tuners-table { font-size: 0.8rem; }
.tuners-table th, .tuners-table td { padding: 6px 6px; }
+
+ /* Virtual channel list items: allow wrapping on very narrow screens */
+ .vc-enabled-item,
+ .vc-available-item {
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px 10px;
+ }
+
+ /* Allow channel name to wrap so it doesn't overflow */
+ .vc-item-name {
+ white-space: normal;
+ word-break: break-word;
+ }
+
+ /* Keep sub-text readable */
+ .vc-item-sub { font-size: 0.75rem; }
+
+ /* Prefs panel: stack fields vertically, allow them full width */
+ .vc-prefs-fields { flex-direction: column; gap: 10px; }
+ .vc-prefs-field,
+ .vc-prefs-field-wide { min-width: unset; width: 100%; flex-basis: 100%; }
+
+ /* Traffic city table: allow horizontal scroll on narrow screens */
+ .traffic-demo-city-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
+
+ /* Audio file items: stack on mobile */
+ .audio-file-item { flex-direction: column; align-items: flex-start; }
+ .audio-preview { width: 100%; max-width: 100%; min-width: unset; }
+ .audio-file-name { white-space: normal; word-break: break-all; }
+
+ /* Channel order list: ensure drag handles are touch-friendly */
+ .channel-order-item { padding: 10px 8px; }
+ .drag-handle { font-size: 1.3rem; padding: 4px 6px; }
+
+ /* Channel order save row: stack on small screens */
+ .channel-order-save-row { flex-wrap: wrap; gap: 8px; }
+
+ /* Page header: reduce title size */
+ .page-header h1 { font-size: 1.3rem; }
}
/* Smooth transitions */
diff --git a/static/css/mobile-scroll-fix.css b/static/css/mobile-scroll-fix.css
index 64b1447..3ecf6f8 100644
--- a/static/css/mobile-scroll-fix.css
+++ b/static/css/mobile-scroll-fix.css
@@ -1,34 +1,42 @@
-/* Mobile scroll fixes and timebar recompute helpers
- Load this after mobile.css (or append these rules to mobile.css).
- Re-enables body scrolling on narrow viewports while keeping mobile-nav
- independently scrollable and locking background scroll only when nav open.
+/* Mobile fixed-top layout
+ Load this after mobile.css.
+ On mobile the header, player row, and timebar are all pinned at the top of
+ the viewport. Only the guide channel rows scroll — inside guide-outer which
+ is an internal scroll container (overflow-y: auto), the same model used on
+ desktop. display-size.js uses position:fixed for #appZoomRoot on mobile too.
*/
@media (max-width: 900px) {
- /* Re-enable page scrolling on mobile so users can reach the guide and channels.
- #appZoomRoot is position:fixed + overflow:hidden on desktop so the internal
- .guide-outer can scroll. On mobile we want the *page* to scroll instead, so
- we switch to normal document flow and let html/body scroll.
- display-size.js applies transform:scale() and compensating width/height for
- Display Size — those are left to JS so Medium/Small still work on mobile. */
+ /* Use the same viewport-filling fixed layout as desktop so that the header,
+ player row, and timebar stay pinned at the top and only guide-outer scrolls
+ internally. base.css already sets position:fixed + overflow:hidden on
+ #appZoomRoot; these rules make sure nothing below overrides those properties.
+ Do NOT set width/height here — display-size.js sets them as inline styles
+ (compensated px values) so that transform:scale() fills the full viewport
+ for Medium (0.8×) and Small (0.67×) display sizes. */
#appZoomRoot {
- position: relative !important;
- overflow: visible !important;
+ position: fixed !important;
+ top: 0 !important;
+ left: 0 !important;
+ overflow: hidden !important;
}
html, body {
- height: auto !important;
- min-height: 100% !important;
- overflow: auto !important;
- -webkit-overflow-scrolling: touch !important;
+ height: 100% !important;
+ overflow: hidden !important;
}
- /* guideOuter should grow with content rather than being a fixed viewport-sub layout */
+ /* guide-outer fills all remaining flex space below the pinned items and
+ provides the only scroll surface on mobile. */
.guide-outer {
+ flex: 1 !important;
+ min-height: 0 !important;
height: auto !important;
max-height: none !important;
- overflow: visible !important;
- padding-bottom: 120px !important; /* ensure page can scroll past the fixed timebar */
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+ -webkit-overflow-scrolling: touch !important;
+ padding-bottom: 40px !important;
box-sizing: border-box !important;
}
@@ -38,19 +46,19 @@
max-height: 100vh !important;
}
- /* When mobile nav is open, prevent the background page from scrolling */
+ /* When mobile nav is open, prevent background scroll */
body.mobile-nav-open {
overflow: hidden !important;
touch-action: none !important;
}
- /* Ensure the fixed time header remains accessible and not covered by content.
- This keeps it visually anchored at top on mobile; we also recompute on scroll in JS. */
+ /* Fixed timebar: position is set at runtime by createOrUpdateFixedTimeBar().
+ It is always just below the player row which is itself at the top of the
+ fixed #appZoomRoot flex column, so the top value never changes on scroll. */
.time-header-fixed {
- top: 0 !important;
+ position: fixed !important;
left: 0 !important;
width: 100% !important;
- position: fixed !important;
z-index: 1200 !important;
}
}
diff --git a/static/css/mobile.css b/static/css/mobile.css
index 390314e..122a281 100644
--- a/static/css/mobile.css
+++ b/static/css/mobile.css
@@ -137,6 +137,21 @@ html, body {
padding: 10px !important;
}
+ /* Video player wrap must be block (not inline-block) so it stretches to the
+ full column width; otherwise it shrinks to the natural video size.
+ .video-wrapper is the div dynamically injected by video-resize.js around
+ #video — it also needs block + 100% so the video inside resolves width
+ against the full player column rather than the inline-block natural size. */
+ .video-player-wrap,
+ #videoPlayerWrap,
+ .video-wrapper {
+ display: block !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+ flex-shrink: 0 !important;
+ }
+
/* Video fills the width and is constrained in height so it does not become huge */
#video {
order: 2;
diff --git a/static/js/auto-scroll.js b/static/js/auto-scroll.js
index 1699a14..932780d 100644
--- a/static/js/auto-scroll.js
+++ b/static/js/auto-scroll.js
@@ -1,8 +1,9 @@
-// auto-scroll v36.3 — deterministic wrap + RAF primary with interval fallback watchdog.
+// auto-scroll v36.4 — deterministic wrap + RAF primary with interval fallback watchdog.
// - Clone full row elements so program cells are carried with clones.
// - Deterministic immediate wrap to prep offset to avoid stop/restart races.
// - Primary animation via requestAnimationFrame; fallback watcher uses setInterval to nudge scrollTop
// when RAF hasn't advanced (handles throttling/race across browsers).
+// - guide-outer (overflow:auto, flex:1) is the scroll container on both desktop and mobile.
// - Exposes status and cloneNow APIs.
(function () {
@@ -10,6 +11,12 @@
function prefEnabled() { return localStorage.getItem(PREF_KEY) !== 'false'; }
function setPref(v) { localStorage.setItem(PREF_KEY, v ? 'true' : 'false'); }
+ // Previously returned true on ≤900px viewports when guide-outer had overflow:visible
+ // and the page itself scrolled via window.scrollY. Mobile now uses the same
+ // fixed-viewport model as desktop (guide-outer has overflow:auto and scrolls
+ // internally), so window-scroll handling is no longer needed anywhere.
+ function isMobilePage() { return false; }
+
const SELECTOR_PRIORITY = ['#guideOuter', '.guide-outer', '.grid-col'];
let scrollSpeed = 1.2; // px per frame (visual)
const idleDelay = 15000; // ms initial inactivity/start delay (15s)
@@ -280,7 +287,7 @@
try {
if (!isScrolling || !scroller) return;
const now = performance.now();
- // If RAF hasn't run in last 250ms, nudge scrollTop a tiny amount
+ // If RAF hasn't run in last 250ms, nudge scroll a tiny amount
if (now - lastFrameTime > 250) {
try { scroller.scrollTop = (scroller.scrollTop || 0) + scrollSpeed; } catch (e) {}
// update lastFrameTime so we don't double-nudge
@@ -297,6 +304,7 @@
if (document.hidden) { rafId = requestAnimationFrame(frameLoop); return; }
try {
lastFrameTime = performance.now();
+
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
scroller.scrollTop += scrollSpeed;
const maxScroll = scroller.scrollHeight - scroller.clientHeight;
@@ -361,6 +369,7 @@
ensureStyles(scroller);
}
+ // Clone rows for seamless loop, then begin animation
if (loopMode && scroller && scroller.dataset.__autoScrollCloned !== '1') {
waitForContent(scroller, waitForContentMs, contentSampleCount).then(() => cloneOnce(scroller)).catch(() => cloneOnce(scroller)).then(() => {
// start animation after a tiny tick to let layout settle
@@ -369,7 +378,7 @@
return;
}
- // clones already present or no cloning needed
+ // clones already present, no cloning needed, or mobile mode
startAnimation();
}
@@ -445,13 +454,20 @@
}
};
window.__autoScroll.getSpeed = function(){ return scrollSpeed; };
- window.__autoScroll.status = function(){ return { isScrolling, pref: prefEnabled(), loopMode, scrollerInfo: scroller ? { id: scroller.id, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId: !!rafId, watchdog: !!watchdogInterval }; };
- window.__autoScroll.debug = function(){ return { lastActivity, idleDelay, scrollSpeed, isScrolling, pref: prefEnabled(), loopMode, endReached, endReachedAt, autoRestart, autoRestartDelayMs, scrollerInfo: scroller ? { id: scroller.id, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId, lastFrameTime, watchdogInterval }; };
+ window.__autoScroll.status = function(){
+ var mob = isMobilePage();
+ return { isScrolling, pref: prefEnabled(), loopMode, mobile: mob, scrollerInfo: scroller ? { id: scroller.id, scrollTop: mob ? window.scrollY : scroller.scrollTop, scrollHeight: mob ? document.body.scrollHeight : scroller.scrollHeight, clientHeight: mob ? window.innerHeight : scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId: !!rafId, watchdog: !!watchdogInterval };
+ };
+ window.__autoScroll.debug = function(){
+ var mob = isMobilePage();
+ return { lastActivity, idleDelay, scrollSpeed, isScrolling, pref: prefEnabled(), loopMode, endReached, endReachedAt, autoRestart, autoRestartDelayMs, mobile: mob, scrollerInfo: scroller ? { id: scroller.id, scrollTop: mob ? window.scrollY : scroller.scrollTop, scrollHeight: mob ? document.body.scrollHeight : scroller.scrollHeight, clientHeight: mob ? window.innerHeight : scroller.clientHeight, cloned: !!scroller.dataset.__autoScrollCloned, prependedHeight: scroller.dataset.__autoScrollPrependedHeight } : null, rafId, lastFrameTime, watchdogInterval };
+ };
- log('auto-scroll (conservative v36.3) initialized');
+ log('auto-scroll (conservative v36.4) initialized');
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+
diff --git a/static/js/display-size.js b/static/js/display-size.js
index b17c099..1e29411 100644
--- a/static/js/display-size.js
+++ b/static/js/display-size.js
@@ -41,46 +41,30 @@
function applyUiZoom(scale) {
var root = document.getElementById('appZoomRoot');
if (root) {
- var isMobile = window.innerWidth <= 900;
var vp = getViewportSize();
root.style.transformOrigin = 'top left';
- if (isMobile) {
- // On mobile, normal document flow (position:relative, overflow:visible) is set
- // by mobile-scroll-fix.css so the page can scroll. We only set the transform
- // and compensating width here so Display Size (Medium/Small) still works.
- root.style.position = 'relative';
- root.style.top = '';
- root.style.left = '';
- root.style.overflow = 'visible';
- if (scale < 1) {
- root.style.transform = 'scale(' + scale + ')';
- root.style.width = Math.ceil(vp.w / scale) + 'px';
- root.style.height = 'auto'; // let content height flow naturally for page scroll
- } else {
- root.style.transform = '';
- root.style.width = '100%';
- root.style.height = 'auto';
- }
+ // Use the same fixed-viewport approach on both mobile and desktop.
+ // Mobile now uses the same layout model as desktop: #appZoomRoot is
+ // position:fixed so the header, player row, and timebar are pinned at the
+ // top and guide-outer scrolls internally. mobile-scroll-fix.css enforces
+ // this at the CSS level; here we ensure JS-applied inline styles match.
+ root.style.position = 'fixed';
+ root.style.top = '0';
+ root.style.left = '0';
+ // Guide page uses its own internal scroll container (.guide-outer); all other pages
+ // (Logs, About, Tuner Management, etc.) need the zoom root itself to be scrollable.
+ root.style.overflow = document.body.classList.contains('guide-page') ? 'hidden' : 'auto';
+ if (scale < 1) {
+ // Explicit px avoids 100%/vh resolution quirks when overflow:hidden is involved
+ root.style.transform = 'scale(' + scale + ')';
+ root.style.width = Math.ceil(vp.w / scale) + 'px';
+ root.style.height = Math.ceil(vp.h / scale) + 'px';
} else {
- // Desktop: fixed wrapper so body/percent sizing cannot clip the scaled UI.
- root.style.position = 'fixed';
- root.style.top = '0';
- root.style.left = '0';
- // Guide page uses its own internal scroll container (.guide-outer); all other pages
- // (Logs, About, Tuner Management, etc.) need the zoom root itself to be scrollable.
- root.style.overflow = document.body.classList.contains('guide-page') ? 'hidden' : 'auto';
- if (scale < 1) {
- // Explicit px avoids 100%/vh resolution quirks when overflow:hidden is involved
- root.style.transform = 'scale(' + scale + ')';
- root.style.width = Math.ceil(vp.w / scale) + 'px';
- root.style.height = Math.ceil(vp.h / scale) + 'px';
- } else {
- root.style.transform = '';
- // Fixed + 100% resolves to the viewport
- root.style.width = '100%';
- root.style.height = '100%';
- }
+ root.style.transform = '';
+ // Fixed + 100% resolves to the viewport
+ root.style.width = '100%';
+ root.style.height = '100%';
}
}
// Keep --display-zoom in sync so CSS/JS consumers always read the right value
diff --git a/static/js/grid-adapt.js b/static/js/grid-adapt.js
index e36a369..6fdf3c6 100644
--- a/static/js/grid-adapt.js
+++ b/static/js/grid-adapt.js
@@ -83,18 +83,26 @@
// Adjust guideOuter height to accommodate scaled content so that elements below remain reachable
const guideOuter = document.getElementById('guideOuter');
if (guideOuter) {
- if (scale < 1) {
- // estimate original content height using bounding rect and scale factor
- const rect = gridContent.getBoundingClientRect();
- // boundingRect is already scaled; estimate original by dividing by scale
- const estimatedUnscaledH = rect.height / (scale || 1);
- const scaledHeight = Math.round(estimatedUnscaledH * scale);
- // keep a sensible minimum and add room for the player row above
- const minHeight = 220;
- guideOuter.style.height = Math.max(minHeight, scaledHeight + 180) + 'px';
+ // On mobile guide-outer scrolls internally (overflow:auto, flex:1) inside the
+ // fixed #appZoomRoot — the same model as desktop. An explicit pixel height would
+ // fight the flex layout, so always use resetGuideHeight() on mobile.
+ if (window.innerWidth > MOBILE_MAX) {
+ if (scale < 1) {
+ // estimate original content height using bounding rect and scale factor
+ const rect = gridContent.getBoundingClientRect();
+ // boundingRect is already scaled; estimate original by dividing by scale
+ const estimatedUnscaledH = rect.height / (scale || 1);
+ const scaledHeight = Math.round(estimatedUnscaledH * scale);
+ // keep a sensible minimum and add room for the player row above
+ const minHeight = 220;
+ guideOuter.style.height = Math.max(minHeight, scaledHeight + 180) + 'px';
+ } else {
+ // Use the zoom-aware helper rather than clearing to '' so the correct
+ // height is always set when a display-size zoom is active.
+ resetGuideHeight();
+ }
} else {
- // Use the zoom-aware helper rather than clearing to '' so the correct
- // height is always set when a display-size zoom is active.
+ // Mobile: clear any explicit height so flex:1 drives the layout
resetGuideHeight();
}
}
diff --git a/static/js/guide-refresh.js b/static/js/guide-refresh.js
index afb9d0b..f921a95 100644
--- a/static/js/guide-refresh.js
+++ b/static/js/guide-refresh.js
@@ -3,7 +3,27 @@
const ENDPOINT = '/api/guide_snapshot?hours=6';
const REFRESH_INTERVAL_MIN = 30; // same cadence as Cairo
+ // Track whether a refresh was skipped because video was in fullscreen
+ let _pendingRefresh = false;
+
+ function isFullscreenActive() {
+ return !!(
+ document.fullscreenElement ||
+ document.webkitFullscreenElement ||
+ document.mozFullScreenElement ||
+ document.msFullscreenElement
+ );
+ }
+
async function refreshGuide() {
+ // Do not reload while video is playing in fullscreen — it kills the stream.
+ // Set a flag so we reload as soon as fullscreen is exited.
+ if (isFullscreenActive()) {
+ _pendingRefresh = true;
+ console.log('[guide-refresh] Fullscreen active — deferring guide refresh.');
+ return;
+ }
+
try {
const res = await fetch(ENDPOINT, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -11,14 +31,27 @@
// You can either rebuild just the EPG grid, or simply reload the page
// depending on how your current guide is generated.
// For now, safest approach is a full reload:
+ _pendingRefresh = false;
window.location.reload();
-
- console.log(`[guide-refresh] Guide refreshed at ${new Date().toLocaleTimeString()}`);
} catch (err) {
console.error('[guide-refresh] Failed to refresh guide:', err);
}
}
+ // When the user exits fullscreen, perform the deferred reload if one is waiting.
+ function onFullscreenChange() {
+ if (!isFullscreenActive() && _pendingRefresh) {
+ _pendingRefresh = false;
+ console.log('[guide-refresh] Fullscreen exited — running deferred guide refresh.');
+ window.location.reload();
+ }
+ }
+
+ document.addEventListener('fullscreenchange', onFullscreenChange);
+ document.addEventListener('webkitfullscreenchange', onFullscreenChange);
+ document.addEventListener('mozfullscreenchange', onFullscreenChange);
+ document.addEventListener('MSFullscreenChange', onFullscreenChange);
+
// Run once every X minutes so the grid rolls forward with real time
setInterval(refreshGuide, REFRESH_INTERVAL_MIN * 60 * 1000);
})();
diff --git a/static/js/mobile-scroll-fix.js b/static/js/mobile-scroll-fix.js
index 9a16115..6743a8f 100644
--- a/static/js/mobile-scroll-fix.js
+++ b/static/js/mobile-scroll-fix.js
@@ -1,32 +1,17 @@
-// Keep fixed timebar correctly positioned on mobile when the page scrolls
+// Keep fixed timebar correctly positioned on mobile.
+// With the fixed-viewport layout, the timebar sits permanently just below the
+// player row which is itself at the top of the fixed #appZoomRoot. The bar
+// position never changes on scroll (guide-outer scrolls internally), so we only
+// need to recompute on resize / orientation change.
// Place at: static/js/mobile-scroll-fix.js
-// Include after mobile-nav.js and grid-adapt.js:
(function(){
- // Don't run unnecessarily on desktop
const MOBILE_MAX = 900;
- let last = 0;
- let rafPending = false;
-
- function throttle(fn) {
- return function() {
- const now = Date.now();
- if (rafPending) return;
- if (now - last < 80) { // ~12 FPS throttle
- rafPending = true;
- setTimeout(() => { rafPending = false; last = Date.now(); fn(); }, 80);
- return;
- }
- last = now;
- fn();
- };
- }
function recomputeFixedBar() {
try {
if (window.innerWidth > MOBILE_MAX) return;
if (typeof window.createOrUpdateFixedTimeBar === 'function') {
- // use rAF to ensure layout has settled
requestAnimationFrame(() => {
window.createOrUpdateFixedTimeBar();
if (typeof window.updateNowLine === 'function') window.updateNowLine();
@@ -35,18 +20,10 @@
} catch (e) { console.debug('recomputeFixedBar err', e); }
}
- const recomputeThrottled = throttle(recomputeFixedBar);
-
- // Recompute on scroll & touchmove for mobile
- window.addEventListener('scroll', recomputeThrottled, { passive: true });
- window.addEventListener('touchmove', recomputeThrottled, { passive: true });
-
- // Also recompute when nav opens/closes via class change (mobile-nav-open)
- const bodyObserver = new MutationObserver(() => {
- recomputeThrottled();
- });
+ // Recompute when nav opens/closes (class mutation changes player row height)
+ const bodyObserver = new MutationObserver(() => { recomputeFixedBar(); });
bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
// Recompute on resize / orientation change
- window.addEventListener('resize', recomputeThrottled);
+ window.addEventListener('resize', recomputeFixedBar);
})();
diff --git a/static/js/tv-remote-nav.js b/static/js/tv-remote-nav.js
index afb3e26..4777273 100644
--- a/static/js/tv-remote-nav.js
+++ b/static/js/tv-remote-nav.js
@@ -46,8 +46,20 @@
'body.tv-mode .chan-name img { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }',
'body.tv-mode .chan-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'body.tv-mode .chan-header { height: 17px; }',
- /* Program grid cells */
- 'body.tv-mode .program { height: 24px !important; font-size: 9px; padding: 2px 3px; top: 3px; }',
+ /* Program grid cells – fill the full row height so current and future programs
+ are always the same height as the channel-column cell on every theme.
+ Targeting both .program and .program.now (specificity 0,3,0) ensures the
+ tv-mode rule wins over any theme-specific .program.now override that would
+ otherwise cause the current program to appear taller than future ones. */
+ 'body.tv-mode .program,',
+ 'body.tv-mode .program.now { height: 100% !important; top: 0 !important; font-size: 9px; padding: 2px 3px; }',
+ /* Icon Guide (ersatztv) – icon cards need taller rows; give the channel name
+ enough height to show the channel logo and let programs fill the row with
+ comfortable inset padding to match the desktop design proportions. */
+ 'body.tv-mode.ersatztv .chan-name { height: 64px; }',
+ 'body.tv-mode.ersatztv .program,',
+ 'body.tv-mode.ersatztv .program.now { top: 4px !important; height: calc(100% - 8px) !important; font-size: 10px; padding: 4px 5px; }',
+ 'body.tv-mode.ersatztv .program .prog-icon-img { width: 42px; height: 42px; }',
'body.tv-mode .time-cell { font-size: 9px; }',
/* Fixed time bar */
'body.tv-mode .time-header-fixed { height: 17px; }',
diff --git a/static/js/video-resize.js b/static/js/video-resize.js
index e710829..8dbf5a1 100644
--- a/static/js/video-resize.js
+++ b/static/js/video-resize.js
@@ -195,15 +195,21 @@
/* ── Restore previously saved sizes ───────────────────────────── */
function restoreSizes() {
var video = document.getElementById('video');
+ var isMobile = window.innerWidth <= 900;
try {
var w = localStorage.getItem(LS_VIDEO_W);
var h = localStorage.getItem(LS_VIDEO_H);
var c = localStorage.getItem(LS_CHAN_W);
- if (video) {
- if (w) video.style.width = w;
- if (h) { video.style.height = h; video.style.removeProperty('max-height'); }
+ // On mobile, CSS (mobile.css + guide.html inline style) handles video sizing
+ // and channel column width. Restoring saved desktop pixel values would
+ // override those rules and produce a small/misaligned player on mobile.
+ if (!isMobile) {
+ if (video) {
+ if (w) video.style.width = w;
+ if (h) { video.style.height = h; video.style.removeProperty('max-height'); }
+ }
+ if (c) document.documentElement.style.setProperty('--chan-col-width', c);
}
- if (c) document.documentElement.style.setProperty('--chan-col-width', c);
} catch (e) {}
updateGuideHeight();
reflow();
diff --git a/templates/guide.html b/templates/guide.html
index 31936be..6644953 100644
--- a/templates/guide.html
+++ b/templates/guide.html
@@ -87,6 +87,13 @@
/* optional: expose channel column width if you compute it server-side or want to override */
--chan-col-width: 200px;
}
+ /* On mobile, narrow the channel column so the guide grid gets more room */
+ @media (max-width: 900px) {
+ :root { --chan-col-width: 80px; }
+ .chan-name { padding: 4px 3px !important; gap: 1px !important; }
+ .chan-name img { width: 22px !important; height: 22px !important; }
+ .chan-name span { font-size: 9px !important; }
+ }
.video-player-wrap {
position: relative;
display: inline-block;
@@ -916,36 +923,74 @@
Program Info
fixedBar.style.left = '0px';
fixedBar.style.width = Math.ceil(window.innerWidth / getDisplayZoom()) + 'px';
- // On mobile, #appZoomRoot is position:relative + transform:scale(s), so
- // position:fixed children behave like position:absolute within appZoomRoot.
- // The CSS top must compensate for window.scrollY so the bar stays in view:
- // top_css = (scrollY + desired_visual_y) / scale
- // On desktop, appZoomRoot is position:fixed so position:fixed children are
- // truly viewport-anchored and no scroll compensation is needed.
- const isMobileZoom = window.innerWidth <= 900 && getDisplayZoom() < 1;
- const scrollOff = isMobileZoom ? (window.scrollY || 0) : 0;
+ // #appZoomRoot is position:fixed on both mobile and desktop.
+ // When scale=1 (Large): no transform, position:fixed children are viewport-anchored.
+ // top = playerRow.getBoundingClientRect().bottom (a viewport Y coordinate).
+ // When scale<1 (Medium/Small): transform:scale is applied to appZoomRoot, so
+ // browsers treat position:fixed children as positioned within that element.
+ // top_css = desired_visual_y / scale (scrollY is always 0 since no page scroll).
+ // We clamp visualY to headerH on mobile so the bar can't slide behind the nav header.
+ const isMobile = window.innerWidth <= 900;
+ const headerEl = document.querySelector('.header');
+ const headerH = headerEl ? Math.round(headerEl.getBoundingClientRect().height) : 40;
if (playerRow) {
const rect = playerRow.getBoundingClientRect();
- // Clamp to 0: when player scrolls off the top, anchor timebar to viewport top.
- const visualY = isMobileZoom ? Math.max(0, rect.bottom) : rect.bottom;
- fixedBar.style.top = Math.ceil((scrollOff + visualY) / getDisplayZoom()) + 'px';
+ const visualY = isMobile ? Math.max(headerH, rect.bottom) : rect.bottom;
+ // Math.floor ensures the timebar is placed at-or-above playerRow.bottom.
+ // Math.ceil could position it 1-2px below (when visualY/zoom is non-integer
+ // due to float imprecision at medium/small display sizes), leaving a gap
+ // that shows raw body background above the timebar on dark/light themes.
+ fixedBar.style.top = Math.floor(visualY / getDisplayZoom()) + 'px';
} else {
- const header = document.querySelector('.header');
- const headerRect = header ? header.getBoundingClientRect() : { bottom: 40 };
- const visualY = isMobileZoom ? Math.max(0, headerRect.bottom) : headerRect.bottom;
- fixedBar.style.top = Math.ceil((scrollOff + visualY) / getDisplayZoom()) + 'px';
+ const headerRect = headerEl ? headerEl.getBoundingClientRect() : { bottom: 40 };
+ const visualY = isMobile ? Math.max(headerH, headerRect.bottom) : headerRect.bottom;
+ fixedBar.style.top = Math.floor(visualY / getDisplayZoom()) + 'px';
}
- requestAnimationFrame(() => {
- const zoom = getDisplayZoom();
- // getBCR returns visual/viewport px; convert to CSS px by dividing by zoom
- const fbHeightCss = Math.ceil((fixedBar.getBoundingClientRect().height || 34) / zoom);
- const currentPaddingTop = parseFloat(window.getComputedStyle(guideOuter).paddingTop) || 0;
- if (currentPaddingTop < fbHeightCss) {
- guideOuter.style.paddingTop = fbHeightCss + 'px';
- }
- });
+ // Compute and apply guide-outer padding so the first guide row starts at or
+ // below the timebar's visual bottom edge.
+ //
+ // We intentionally avoid reading fixedBar.getBoundingClientRect() here.
+ // On Amazon Fire TV Silk browser (and some older WebKit builds), BCR on
+ // position:fixed elements inside a CSS-transformed container can return
+ // stale viewport coordinates even after a forced layout flush, causing the
+ // padding to be computed as 0 and leaving guide rows visible above the bar.
+ // (RetroIPTV appeared to work only because its box-shadow: 0 3px 8px creates
+ // a visual buffer that hides the mis-measurement.)
+ //
+ // Analytical approach:
+ // fixedBarTopCss – the CSS-px top value we just wrote to fixedBar.style.top
+ // fixedBar.offsetHeight – timebar height in CSS px (content + border), never
+ // affected by transforms or BCR quirks
+ // guideRect.top / zoom – guide-outer's CSS-px top edge (BCR of a normal
+ // flex element, consistently accurate on all browsers)
+ //
+ // neededPaddingCss = fixedBarTopCss + timebar_height - guide_outer_css_top
+ // ≈ timebar_height (since fixedBarTopCss ≈ guide_outer_css_top)
+ //
+ // guideOuter.getBoundingClientRect() also serves as the layout flush that
+ // ensures fixedBar is correctly positioned in the compositor before paint.
+ {
+ const zoom = getDisplayZoom();
+ const guideRect = guideOuter.getBoundingClientRect();
+ // CSS-px top value we just set; parseFloat never mismatches because we
+ // own every write to fixedBar.style.top in this function.
+ const fixedBarTopCss = parseFloat(fixedBar.style.top) || 0;
+ // offsetHeight: content-box height + border. Reliable on all browsers;
+ // unaffected by CSS transforms or the BCR quirks mentioned above.
+ const fixedBarHCss = fixedBar.offsetHeight;
+ // Distance (CSS px) from guide-outer's outer top to the timebar's bottom.
+ const neededPaddingCss = Math.ceil(
+ Math.max(0, fixedBarTopCss + fixedBarHCss - guideRect.top / zoom)
+ );
+ guideOuter.style.paddingTop = neededPaddingCss + 'px';
+ // Reserve the same space for programmatic scrollIntoView / DPAD navigation.
+ guideOuter.style.scrollPaddingTop = neededPaddingCss + 'px';
+ // Fill the remaining viewport height below guide-outer's top.
+ const availableHeightCss = Math.floor((window.innerHeight - guideRect.top) / zoom);
+ if (availableHeightCss > 0) guideOuter.style.height = availableHeightCss + 'px';
+ }
const origNow = document.getElementById('nowLineOriginal');
if (origNow) origNow.style.display = 'none';
@@ -1016,8 +1061,33 @@
Program Info
setInterval(updateClock, 1000);
setInterval(updateNowLine, 60000);
+
+ // On mobile, abbreviate channel names so they fit the narrower column
+ if (window.innerWidth <= 900) {
+ applyMobileChanAbbreviations();
+ }
});
+/**
+ * Abbreviate channel name spans on mobile so they fit in the 80px column.
+ * Re-uses abbreviateChannelName() which is also used by the classic-cable theme.
+ * Called on DOMContentLoaded and after a theme:applied event restores full names.
+ */
+function applyMobileChanAbbreviations() {
+ if (window.innerWidth > 900) return;
+ document.querySelectorAll('.chan-name').forEach(function(el) {
+ // Skip names inside auto-scroll clones (they'll be updated when clones are rebuilt)
+ if (el.closest('.__auto_scroll_clone')) return;
+ // Skip cols already decorated by a theme (tvguide1990 / classic-cable use different markup)
+ if (el.querySelector('.channel-number')) return;
+ var span = el.querySelector('span');
+ if (!span) return;
+ var fullName = el.dataset.name || span.textContent || '';
+ var abbr = abbreviateChannelName(fullName);
+ if (abbr && abbr !== span.textContent) span.textContent = abbr;
+ });
+}
+
/* Ensure layout recomputes on viewport resize */
window.addEventListener('resize', () => {
// recompute fixed header placement and now-line
@@ -1067,6 +1137,19 @@
Program Info
if (theme === "tvguide1990") requestAnimationFrame(applyTvGuide1990Capsules);
if (theme === "classic-cable") requestAnimationFrame(applyClassicCableCapsules);
if (theme === "ersatztv") requestAnimationFrame(applyIconGuide);
+ // After theme CSS has settled (new body class + any capsule RAF), recompute
+ // the fixed timebar position and guide-outer padding so every theme aligns
+ // correctly on all browsers including TV/Fire TV.
+ // Two nested RAFs are needed: the first lets any capsule-applying RAF (e.g.
+ // applyTvGuide1990Capsules) commit its DOM changes and trigger layout, and
+ // the second ensures the browser has fully resolved the new theme CSS before
+ // we measure timebar and guide-outer positions.
+ requestAnimationFrame(function () {
+ requestAnimationFrame(function () {
+ if (typeof createOrUpdateFixedTimeBar === 'function') createOrUpdateFixedTimeBar();
+ if (typeof updateNowLine === 'function') updateNowLine();
+ });
+ });
setTimeout(()=>b.classList.remove("fade-switch"), 100);
},150);
}
@@ -1231,7 +1314,18 @@
Program Info
c.classList.remove('tvguide1990-applied', 'classic-cable-applied');
});
restoreIconGuidePrograms();
+ // Re-apply mobile abbreviations after full names are restored
+ if (window.innerWidth <= 900) requestAnimationFrame(applyMobileChanAbbreviations);
}
+ // Recompute fixed timebar position and guide-outer padding for every theme change.
+ // Two nested RAFs let any DOM-mutating RAF above (e.g. capsule application) settle
+ // first, and guarantee the browser has resolved the new theme CSS before measuring.
+ requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ if (typeof createOrUpdateFixedTimeBar === 'function') createOrUpdateFixedTimeBar();
+ if (typeof updateNowLine === 'function') updateNowLine();
+ });
+ });
});
// Also apply on page load in case tvguide1990, classic-cable or ersatztv was the saved
@@ -1548,6 +1642,7 @@
+ {% endif %}
+ {% if user.channel_list %}
+ {# Sentinel tells the server to update hidden_channels from the multi-select.
+ Placed inside the channel_list block so it is only submitted when the
+ select is actually rendered — avoids silently clearing hidden channels
+ when no channel list is available for the user's assigned tuner. #}
+
+
+ Hold Ctrl / ⌘ to select multiple. Deselect all to unhide everything.
+ {% else %}
+ No channels loaded for this user's assigned tuner.
+ {% endif %}
+
- Cycles: Current Conditions → 5-Day Forecast → Regional Radar → Severe Weather Alerts, then repeats.
+ Cycles: Current Conditions → 5-Day Forecast → Regional Radar → Severe Weather Alerts (NOAA/NWS), then repeats.
All viewers stay in sync (wall-clock aligned).
+ Force a specific animated background on the weather channel for testing, regardless of actual weather.
+ Click a condition to activate it; click Auto to restore real-weather backgrounds.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% endif %}
{% if ch.tvg_id == 'virtual.traffic' %}
Use Save above to persist the Rotation Mode and Interval settings.
+
+
+
+
+ ZIP / Postal Code
+
+
+
+
+
+
+ City Name
+
+
+
+ State / Region
+
+
+
+ Latitude
+
+
+
+ Longitude
+
+
+
+ Population
+
+
+
+
+
+
+ Enter a ZIP / postal code and click Lookup to auto-fill city details, or fill in manually.
+ Road data and basemap are downloaded automatically in the background after adding.
+