From ef2f5fcfb211c978e9586ff2b5d0702e0aa5aab2 Mon Sep 17 00:00:00 2001 From: thehack904 <35552907+thehack904@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:03:36 -0600 Subject: [PATCH] Update 4.2.0 Refined CSS, enabled mobile version, added RetroIPTV theme, API's added for backend future upgrades --- CHANGELOG.md | 42 +++-- ROADMAP.md | 58 +++--- retroiptv_linux.sh | 2 +- retroiptv_rpi.sh | 2 +- retroiptv_windows.ps1 | 2 +- static/css/base.css | 21 +++ static/css/change_tuner.css | 77 +++++--- static/css/mobile-scroll-fix.css | 46 +++++ static/css/mobile-submenu.css | 127 +++++++++++++ static/css/mobile.css | 164 ++++++++++++++++ static/js/auto-scroll-manager.js | 150 +++++++++++++++ static/js/auto-scroll.js | 2 +- static/js/clock-fix.js | 62 ++++++ static/js/grid-adapt.js | 119 ++++++++++++ static/js/mobile-nav.js | 252 +++++++++++++++++++++++++ static/js/mobile-player-adapt.js | 50 +++++ static/js/mobile-scroll-fix.js | 52 +++++ static/js/theme.js | 110 +++++++++++ templates/_header.html | 145 ++++++++++---- templates/base.html | 293 +++++----------------------- templates/change_tuner.html | 22 +-- templates/guide.html | 314 ++++++++++++++++++++----------- templates/login.html | 28 +-- 23 files changed, 1653 insertions(+), 487 deletions(-) create mode 100644 static/css/mobile-scroll-fix.css create mode 100644 static/css/mobile-submenu.css create mode 100644 static/css/mobile.css create mode 100644 static/js/auto-scroll-manager.js create mode 100644 static/js/clock-fix.js create mode 100644 static/js/grid-adapt.js create mode 100644 static/js/mobile-nav.js create mode 100644 static/js/mobile-player-adapt.js create mode 100644 static/js/mobile-scroll-fix.js create mode 100644 static/js/theme.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b506a2a..9d8bfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,36 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## v4.2.0 - 2025-11-06 +This version introduces mobile responsiveness, a new theme, refinements to auto-scroll, and backend API structures. + +### Added +- Added mobile-friendly CSS and JS for improved viewing on mobile devices +- Added RetroIPTV Theme +- Added backend API structures for future updates / efforts + +### Changed +- Enhanced auto-scroll handling with new modular files: `auto-scroll.js` and `auto-scroll-manager.js`. +- Improved responsive layout for guide and settings pages on small screens. + +### Fixed +- Fixed font scaling and layout issues in mobile and embedded browsers. +- Fixed path references for Flask static files and templates. +- Resolved layout inconsistencies across themes and display sizes. +- General code cleanup and alignment for CI/CD consistency. + +--- + +## [Unreleased] + +- Planned: add `.m3u8` tuner support. +- Planned: move logs to SQLite DB. +- Planned: log filtering and pagination. + +--- + ## v4.1.0 - 2025-10-25 -### โจ New Features +### New Features - **Auto-Scroll Guide System** - Added `static/js/auto-scroll.js` enabling smooth, continuous automatic scrolling of the live TV guide. - Uses `requestAnimationFrame` with a `setInterval` watchdog fallback for consistent performance. @@ -26,24 +54,16 @@ This project follows [Semantic Versioning](https://semver.org/). - **New JavaScript Modules** - Added `tuner-settings.js` for handling tuner selection and dynamic UI updates. -### ๐งฐ Improvements +### Improvements - Updated `INSTALL.md`, `README.md`, and `ROADMAP.md` to document the new layout and structure. - `app.py` updated to serve new static assets and integrate template inheritance. - All installer scripts (`retroiptv_linux.sh`, `retroiptv_rpi.sh`, `retroiptv_windows.ps1`) updated for v4.1.0 compatibility and new folder paths. -### ๐ Fixes +### Fixes - Reduced redundancy across templates by introducing a unified base layout. - Improved guide performance and browser compatibility with the new auto-scroll implementation. - Minor visual and layout corrections across settings and guide pages. ---- - -## [Unreleased] - -- Planned: add `.m3u8` tuner support. -- Planned: move logs to SQLite DB. -- Planned: log filtering and pagination. - --- ## v4.0.0 โ 2025-10-19 **Status:** Public Release (Feature Complete) diff --git a/ROADMAP.md b/ROADMAP.md index 8eca213..0f760b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,9 +4,8 @@ This document tracks **planned upgrades** and ideas for improving the IPTV Flask These are **not yet implemented**, but provide a development path for future releases. --- -# Current Version: **v4.1.0 (2025-10-25)** -This version refines templates and adds an auto scroll feature w/ and enable/disable feature. This also has background improvements to align HTML/CSS templates. ---- +# Current Version: **v4.2.0 (2025-11-06)** +This version focuses on mobile responsiveness, new RetroIPTV Theme, backend API integration for future efforts ## ๐ฎ Feature Upgrades @@ -16,8 +15,8 @@ This version refines templates and adds an auto scroll feature w/ and enable/dis - [ ] Support for **.m3u8 single-channel playlists** as tuner sources (planned v3.2.0). - [x] Validate tuner URLs (ping/check format before saving) (v2.0.0). - [ ] Optional auto-refresh of tuner lineup on a schedule. -- [ ] Add per-user tuner assignment and default tuner preferences. ๐ *(v4.2.x planned)* -- [ ] Introduce combined tuner builder (custom tuner aggregation). ๐ *(v5.x.x planned)* +- [ ] Add per-user tuner assignment and default tuner preferences. *(v4.3.x planned)* +- [ ] Introduce combined tuner builder (custom tuner aggregation). *(v5.x.x planned)* --- @@ -27,7 +26,7 @@ This version refines templates and adds an auto scroll feature w/ and enable/dis - [ ] Add system health checks (tuner reachability, XMLTV freshness). - [x] **Admin log management**: clear logs + file size indicator (v2.3.1). - [x] Post-install HTTP service verification in Pi installer (v3.1.0). -- [ ] Unified โRefresh Guideโ scheduler. ๐ *(v4.2.x planned)* +- [x] Unified โRefresh Guideโ scheduler. *(v4.2.0)* --- @@ -35,31 +34,33 @@ This version refines templates and adds an auto scroll feature w/ and enable/dis - [x] **Auto-Scroll feature** added for the Live Guide (v4.1.0). - Uses `requestAnimationFrame` for smooth scroll with fallback watchdog. - Deterministic looping and localStorage preference tracking. +- [x] Improved auto-scroll performance and modular handling (v4.2.0). +- [x] Added responsive layout for mobile devices (v4.2.0). - [ ] Add search/filter box to guide. - [ ] Add ability to set favorites. - [x] Fallback message for missing EPG info (v3.0.1). - [ ] Add reminders/notifications for upcoming programs. -- [ ] Add EPG caching for faster guide reloads. ๐ *(v5.x.x planned)* +- [ ] Add EPG caching for faster guide reloads. *(v5.x.x planned)* --- ### 4. User Management -- [x] Add **manage_users.html** for integrated user control panel. โ *(v4.0.0)* +- [x] Add **manage_users.html** for integrated user control panel. *(v4.0.0)* - [ ] Role-based access control (admin/user/read-only). - [ ] Add email or 2FA support for login. - [ ] Show last login time in admin panel. -- [ ] User role/channel restrictions. ๐ *(v5.x.x planned)* +- [ ] User role/channel restrictions. *(v5.x.x planned)* --- ### 5. UI/UX Improvements - [x] Unified theming across all templates (v2.3.2). -- [x] Android / Fire / Google TV optimized mode with CRT glow header. โ *(v4.0.0)* -- [x] Consolidated UI templates (`guide.html`, `login.html`, etc.). โ *(v4.0.0)* +- [x] Android / Fire / Google TV optimized. *(v4.0.0)* +- [x] Consolidated UI templates (`guide.html`, `login.html`, etc.). *(v4.0.0)* - [x] **Refactored UI templates into shared `base.html` and `_header.html` (v4.1.0)**. - [x] **Modular CSS and JS added (v4.1.0)** โ per-page styling and script loading. - [x] Introduced new JS modules: `auto-scroll.js`, `tuner-settings.js`. -- [ ] Make guide responsive (mobile/tablet). +- [x] **Mobile responsive layout and navigation (v4.2.0)**. - [ ] Add dark/light theme auto-detect. - [ ] Frozen header timeline to prevent scrolling with channel listing. - [x] About page under Settings menu (v2.3.1). @@ -67,18 +68,18 @@ This version refines templates and adds an auto scroll feature w/ and enable/dis --- ### 6. Cross-Platform -- [x] Unified Linux, Windows, and Raspberry Pi installers. โ *(v4.0.0)* -- [x] Windows update/uninstall parity implemented. โ *(v4.1.0)* +- [x] Unified Linux, Windows, and Raspberry Pi installers. *(v4.0.0)* +- [x] Windows update/uninstall parity implemented. *(v4.1.0)* - [ ] Create MacOS install/executable. - [x] Validate/test installers on all Windows environments. -- [ ] Explore TrueNAS SCALE App Catalog certification. ๐ *(v5.x.x planned)* +- [ ] Explore TrueNAS SCALE App Catalog certification. *(v5.x.x planned)* --- ### 7. New Features - [ ] Add auto-play stream on login (ErsatzTV integration). - [ ] Default auto-play source selection. -- [ ] Begin integration path for **PlutoTV / external IPTV services**. ๐ *(v5.x.x)* +- [ ] Begin integration path for **PlutoTV / external IPTV services**. *(v5.x.x)* --- @@ -87,36 +88,37 @@ This version refines templates and adds an auto scroll feature w/ and enable/dis - [x] GPU verification after `raspi-config` (v3.1.0). - [x] Suppress `rfkill` Wi-Fi message during GPU config (v3.1.0). - [x] Adaptive HTTP check loop (v3.1.0). -- [x] **Project structure and documentation reorganized** โ *(v4.0.0โ4.1.0)* +- [x] **Project structure and documentation reorganized** *(v4.0.0โ4.2.0)* --- ## โ๏ธ Technical Improvements - [x] Add uninstall.sh (v2.3.0). - [ ] Validate/test uninstall script fully on Windows. -- [ ] Add HTTPS + optional token-based authentication. ๐ *(v4.5.x)* -- [x] Refactor tuner handling for unified DB. โ *(v4.0.0)* +- [ ] Add HTTPS + optional token-based authentication. *(v4.5.x)* +- [x] Refactor tuner handling for unified DB. *(v4.0.0)* - [x] **Updated bump_version and installer scripts to auto-track new version (v4.1.0)** - [x] Containerize app (Dockerfile + Compose). - [ ] Add migrations for DB schema changes. -- [ ] CI/CD automation for official builds. ๐ *(v5.x.x)* +- [ ] CI/CD automation for official builds. *(v5.x.x)* - [ ] Add test suite for tuner parsing, authentication, and logging. --- ## ๐ Installer Enhancements -- [x] Unified installer architecture. โ *(v4.0.0)* -- [x] Windows update/uninstall parity complete. โ *(v4.1.0)* +- [x] Unified installer architecture. *(v4.0.0)* +- [x] Windows update/uninstall parity complete. *(v4.1.0)* - [ ] Add kiosk/headless mode selector. - [ ] Add `--mode kiosk` flag for non-interactive installs. - [ ] Validate update/uninstall paths on all OSes. --- -## โ Completed (v4.1.0) -- [x] Modular CSS/JS introduced. -- [x] Base templating system (`base.html`, `_header.html`) added. -- [x] Auto-scroll feature integrated with toggle memory. +## โ Completed (v4.2.0) +- [x] Added mobile responsive layout. +- [x] Improved auto-scroll handling and modular JS design. - [x] Updated documentation (CHANGELOG, README, INSTALL, ROADMAP). -- [x] Windows installer parity update. -- [x] Release tagged as **v4.1.0** +- [x] Added RetroIPTV Theme (default). +- [x] Release tagged as **v4.2.0** + + diff --git a/retroiptv_linux.sh b/retroiptv_linux.sh index 16eb72a..225f961 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.1.0" +VERSION="4.2.0" 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 2a57222..3a3084c 100644 --- a/retroiptv_rpi.sh +++ b/retroiptv_rpi.sh @@ -1,5 +1,5 @@ #!/bin/bash -VERSION="4.1.0" +VERSION="4.2.0" # 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.ps1 b/retroiptv_windows.ps1 index c99bf7c..7e49187 100644 --- a/retroiptv_windows.ps1 +++ b/retroiptv_windows.ps1 @@ -55,7 +55,7 @@ if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdent $ErrorActionPreference = 'Stop' $PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' -$VERSION = "4.1.0" +$VERSION = "4.2.0" $ScriptDir = Split-Path -Parent -Path $MyInvocation.MyCommand.Path Set-Location $ScriptDir diff --git a/static/css/base.css b/static/css/base.css index a8b27e8..9cfc668 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -448,6 +448,27 @@ body.retro-magazine .now-line { background:#000; height:3px; } body.retro-magazine #current-tuner { color:#000000 !important; font-weight:bold; } body.retro-magazine .time-header-fixed .left-spacer { background: var(--timebar-bg, #fff) !important; } +/* RetroIPTV Theme */ +body.retroiptv{--timebar-bg:#f6e7cc;--timebar-border:#3b0f1a;--timebar-color:#3b0f1a;--chan-col-bg:#f6e1c8;--now-color:#ffd24a;--accent-teal:#3fb7b0;--accent-coral:#e86b74;background:#e9e8e6;color:#111;font-family:"Poppins",system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif} +body.retroiptv .header{background:linear-gradient(180deg,var(--accent-teal),#31988f);color:#fff;border-bottom:2px solid var(--timebar-border);box-shadow:0 3px 0 rgba(59,15,26,.12)} +body.retroiptv .header .links>a,body.retroiptv .header .links>.dropdown>.dropbtn,body.retroiptv .header .links>span,body.retroiptv .header .links>div#clock{color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em} +body.retroiptv .dropdown-content,body.retroiptv .submenu-content{background:linear-gradient(180deg,#f6e1c8,#fffaf0);border:1px solid rgba(59,15,26,.85);color:var(--timebar-border);box-shadow:0 6px 18px rgba(59,15,26,.08);border-radius:6px} +body.retroiptv .dropdown-content a,body.retroiptv .submenu-content li a{color:var(--timebar-border);padding:10px 14px;font-weight:600} +body.retroiptv .dropdown-content a:hover,body.retroiptv .submenu-content li a:hover{background:rgba(63,183,176,.06);color:var(--accent-coral)} +body.retroiptv .summary{background:linear-gradient(180deg,#fff4e6,#f6e1c8);border:2px solid rgba(59,15,26,.8);color:var(--timebar-border);padding:12px;border-radius:8px;box-shadow:0 6px 14px rgba(59,15,26,.06)} +body.retroiptv .summary h3{margin:0 0 8px 0;font-family:"Fredoka One","Poppins",sans-serif;font-weight:700;color:var(--timebar-border);font-size:1.05rem} +body.retroiptv #video{background:#000;border:4px solid rgba(59,15,26,.85);border-radius:6px;box-shadow:0 8px 20px rgba(59,15,26,.12);object-fit:cover;max-width:100%} +body.retroiptv .chan-col{background:linear-gradient(180deg,var(--chan-col-bg),#f3dbc0);border-right:2px solid rgba(59,15,26,.85)} +body.retroiptv .grid-col{background:linear-gradient(180deg,#fffefc,#fff)} +body.retroiptv .chan-name{color:var(--timebar-border);padding:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-weight:700} +body.retroiptv .time-header-wrap,body.retroiptv .time-header-fixed{background:var(--timebar-bg);color:var(--timebar-color);border-bottom:2px solid rgba(59,15,26,.9);box-shadow:0 3px 8px rgba(59,15,26,.06)} +body.retroiptv .time-cell{color:var(--timebar-color);font-weight:700;padding:6px 10px;border-right:1px solid rgba(255,255,255,.06)} +body.retroiptv .program{background:linear-gradient(180deg,#fff,#f3e8d6);border:2px solid rgba(59,15,26,.6);color:var(--timebar-border);border-radius:8px;padding:6px 8px;font-size:12px;box-shadow:0 4px 10px rgba(59,15,26,.06)} +body.retroiptv .program.now{background:linear-gradient(180deg,var(--now-color),#ffd97a);border:2px solid rgba(59,15,26,.9);color:#111;font-weight:700;box-shadow:0 6px 18px rgba(59,15,26,.12)} +body.retroiptv .now-line,body.retroiptv .time-header-fixed .now-line{background:linear-gradient(180deg,var(--now-color),#ffb81f);width:3px;box-shadow:0 0 8px rgba(241,185,63,.35)} +body.retroiptv a{color:var(--timebar-border)}body.retroiptv a:hover{color:var(--accent-coral)} +@media(max-width:900px){body.retroiptv .header .links{display:none}body.retroiptv .hamburger{display:inline-flex;color:#fff}body.retroiptv .summary{padding:10px;border-radius:6px}body.retroiptv .player{flex-direction:column}body.retroiptv #video{width:100%;max-height:45vh;border-width:3px}body.retroiptv .grid-row{height:48px!important}body.retroiptv .program{font-size:12px;border-radius:6px;padding:4px 6px}body.retroiptv .time-header-fixed{height:40px}} + /* Additional theme-specific overrides kept (no truncation) */ /* Ensure layering & z-index helpers */ diff --git a/static/css/change_tuner.css b/static/css/change_tuner.css index 15943ba..f6c99cc 100644 --- a/static/css/change_tuner.css +++ b/static/css/change_tuner.css @@ -1,5 +1,5 @@ -/* change_tuner page stylesheet โ restores boxed centered forms like the original view - Loaded only on change_tuner.html so it won't affect other pages. +/* change_tuner page stylesheet โ theme-aware via CSS variables + Falls back to original values if variables are not provided by theme CSS. */ /* Layout: center column and stack boxes */ @@ -15,10 +15,13 @@ margin: 28px 0; padding: 18px 20px; border-radius: 8px; - background: rgba(0,0,0,0.18); /* translucent panel to let theme show through */ - /* subtle inset to match the original boxed look */ + + /* Use CSS variables so theme files can override these. + Fallback to previous values when variables are not defined. */ + background: var(--panel-bg, rgba(0,0,0,0.18)); box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset, 0 6px 18px rgba(0,0,0,0.18); - border: 1px solid rgba(255,255,255,0.03); + border: 1px solid var(--panel-border, rgba(255,255,255,0.03)); + color: var(--text-color, #eaf6f6); } /* Headings */ @@ -26,7 +29,7 @@ margin: 0 0 12px; font-size: 1.35rem; font-weight: 700; - color: #fff; + color: var(--heading-color, #fff); text-shadow: 0 1px 0 rgba(0,0,0,0.25); } @@ -34,7 +37,7 @@ .box label { display: block; font-size: 0.9rem; - color: rgba(255,255,255,0.9); + color: var(--label-color, rgba(255,255,255,0.9)); margin: 8px 0 6px; } @@ -44,38 +47,38 @@ margin-bottom: 10px; } -/* Inputs & selects: full width, teal border, rounded */ +/* Inputs & selects: full width, use themed borders/background/text */ select, input[type="text"], input[type="url"], input[type="email"], input[type="password"], textarea { width: 100%; padding: 10px 12px; border-radius: 6px; - border: 1px solid rgba(100,200,200,0.25); - background: rgba(255,255,255,0.03); - color: #eaf6f6; + border: 1px solid var(--input-border, rgba(100,200,200,0.25)); + background: var(--input-bg, rgba(255,255,255,0.03)); + color: var(--input-color, #eaf6f6); box-sizing: border-box; outline: none; transition: box-shadow .12s ease, border-color .12s ease, background-color .12s ease; } /* slightly lighter placeholder text so it reads on dark backgrounds */ -::placeholder { color: rgba(255,255,255,0.35); } +::placeholder { color: var(--placeholder-color, rgba(255,255,255,0.35)); } /* Focus styles */ select:focus, input:focus, textarea:focus { - border-color: rgba(36,220,210,0.95); - box-shadow: 0 0 0 4px rgba(36,220,210,0.06); - background: rgba(255,255,255,0.02); - color: #fff; + border-color: var(--focus-border, rgba(36,220,210,0.95)); + box-shadow: 0 0 0 4px var(--focus-ring, rgba(36,220,210,0.06)); + background: var(--input-bg-focus, rgba(255,255,255,0.02)); + color: var(--input-color-focus, #fff); } -/* Primary button โ full width teal pill */ +/* Primary button โ full width pill */ button.primary, input[type="submit"].primary { display: block; width: 100%; padding: 10px 12px; border-radius: 6px; - background: #1ed3ce; - color: #002e2c; + background: var(--primary-color, #1ed3ce); + color: var(--primary-text-color, #002e2c); font-weight: 700; border: none; cursor: pointer; @@ -84,7 +87,7 @@ button.primary, input[type="submit"].primary { } /* Hover / active */ -button.primary:hover { transform: translateY(-1px); background: #16cfc9; } +button.primary:hover { transform: translateY(-1px); background: var(--primary-color-hover, #16cfc9); } button.primary:active { transform: translateY(0); box-shadow: 0 1px 0 rgba(0,0,0,0.12); } /* Secondary small inline buttons (e.g., Rename button row) */ @@ -92,9 +95,9 @@ button.primary:active { transform: translateY(0); box-shadow: 0 1px 0 rgba(0,0,0 display: inline-block; padding: 6px 10px; border-radius: 4px; - background: rgba(255,255,255,0.92); - color: #002e2c; - border: 1px solid rgba(0,0,0,0.06); + background: var(--btn-inline-bg, rgba(255,255,255,0.92)); + color: var(--btn-inline-color, #002e2c); + border: 1px solid var(--btn-inline-border, rgba(0,0,0,0.06)); cursor: pointer; font-weight: 600; margin-left: 8px; @@ -120,15 +123,31 @@ button.primary:active { transform: translateY(0); box-shadow: 0 1px 0 rgba(0,0,0 .box .sub-heading { margin-top: 12px; margin-bottom: 8px; - color: rgba(255,255,255,0.9); + color: var(--sub-heading-color, rgba(255,255,255,0.9)); font-weight: 600; } -/* Safety: ensure labels and form elements contrast on light theme too */ -body.light .box { background: rgba(255,255,255,0.95); color: #111; } -body.light .box h2 { color: #111; text-shadow: none; } -body.light select, body.light input, body.light textarea { color: #111; background: #fff; border-color: rgba(0,0,0,0.12); } -body.light button.primary { background: #06c; color: #fff; } +/* Theme-aware light adjustments: + Match how the theme script applies themes: html[data-theme="light"] */ +html[data-theme="light"] .box { + background: var(--panel-bg-light, rgba(255,255,255,0.95)); + color: var(--text-color-light, #111); +} +html[data-theme="light"] .box h2 { + color: var(--heading-color-light, #111); + text-shadow: none; +} +html[data-theme="light"] select, +html[data-theme="light"] input, +html[data-theme="light"] textarea { + color: var(--input-color-light, #111); + background: var(--input-bg-light, #fff); + border-color: var(--input-border-light, rgba(0,0,0,0.12)); +} +html[data-theme="light"] button.primary { + background: var(--primary-color-light, #06c); + color: var(--primary-text-color-light, #fff); +} /* small helper to hide an element if needed */ .hidden { display: none !important; } diff --git a/static/css/mobile-scroll-fix.css b/static/css/mobile-scroll-fix.css new file mode 100644 index 0000000..6fa89fe --- /dev/null +++ b/static/css/mobile-scroll-fix.css @@ -0,0 +1,46 @@ +/* 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. +*/ + +@media (max-width: 900px) { + /* Re-enable page scrolling on mobile so users can reach the guide and channels */ + html, body { + height: auto !important; + min-height: 100% !important; + overflow: auto !important; + -webkit-overflow-scrolling: touch !important; + } + + /* guideOuter should grow with content rather than being a fixed viewport-sub layout */ + .guide-outer { + height: auto !important; + max-height: none !important; + overflow: visible !important; + padding-bottom: 120px !important; /* ensure page can scroll past the fixed timebar */ + box-sizing: border-box !important; + } + + /* Keep the mobile nav itself scrollable and bounded to the viewport */ + .mobile-nav { + overflow-y: auto !important; + max-height: 100vh !important; + } + + /* When mobile nav is open, prevent the background page from scrolling */ + 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. */ + .time-header-fixed { + top: 0 !important; + left: 0 !important; + width: 100% !important; + position: fixed !important; + z-index: 1200 !important; + } +} diff --git a/static/css/mobile-submenu.css b/static/css/mobile-submenu.css new file mode 100644 index 0000000..bbde7c7 --- /dev/null +++ b/static/css/mobile-submenu.css @@ -0,0 +1,127 @@ +@media (max-width: 900px) { + :root { + /* adjust these to taste to match your desktop header look */ + --mobile-top-font-size: 16px; /* Home / Manage Users / Logout size */ + --mobile-top-font-weight: 700; + --mobile-top-padding: 12px 10px; + + --mobile-inner-font-size: 15px; /* Settings list items (Change Tuner, Logs, etc) */ + --mobile-inner-font-weight: 600; + --mobile-inner-padding: 10px 8px; + --mobile-top-letter-spacing: 0.06em; + } + + .mobile-submenu { + margin: 8px 0; + list-style: none; + } + + /* TOP-LEVEL link / toggle styling + Targets only top-level entries in the mobile-links list so "Settings" visually matches HOME/MANAGE/LOGOUT */ + .mobile-links > li > a, + .mobile-links > li > .mobile-submenu-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: none; + border: none; + padding: var(--mobile-top-padding); + color: inherit; + font-size: var(--mobile-top-font-size); + font-weight: var(--mobile-top-font-weight); + cursor: pointer; + box-sizing: border-box; + text-decoration: none; /* for anchors */ + + /* Make the top-level items ALL CAPS to match HOME / MANAGE USERS / LOGOUT */ + text-transform: uppercase; + letter-spacing: var(--mobile-top-letter-spacing); + } + + .mobile-links > li > a:focus, + .mobile-links > li > .mobile-submenu-toggle:focus { + outline: 2px solid rgba(255,255,255,0.12); + outline-offset: 2px; + } + + /* COLLAPSIBLE LIST (general) */ + .mobile-submenu-list { + margin: 0; + padding: 0 6px; + list-style: none; + max-height: 0; + overflow: hidden; + transition: max-height 220ms ease, padding 220ms ease; + box-sizing: border-box; + border-left: 2px solid rgba(255,255,255,0.03); + margin-left: 8px; + } + + /* allow nested submenu lists to indent more */ + .mobile-submenu .mobile-submenu .mobile-submenu-list { + margin-left: 12px; + border-left: 2px solid rgba(255,255,255,0.02); + } + + .mobile-submenu.open > .mobile-submenu-list { + /* large enough to show content; mobile-nav is scrollable for long content */ + max-height: 1000px; + padding: 8px 6px; + } + + /* INNER list items (Change Tuner / Logs / About / Toggle Auto-Scroll / Change Password) + and also the nested Themes list items */ + /* These target items that are direct children of a submenu list */ + .mobile-submenu-list > li > a, + .mobile-submenu-list > li > button, + /* nested submenu toggle (e.g. Themes inside Settings) should look like inner items */ + .mobile-submenu-list > li > .mobile-submenu-toggle { + display: block; + padding: var(--mobile-inner-padding); + border-radius: 4px; + text-decoration: none; + color: inherit; + font-size: var(--mobile-inner-font-size); + font-weight: var(--mobile-inner-font-weight); + background: none; + border: none; + width: 100%; + text-align: left; + box-sizing: border-box; + cursor: pointer; + + /* INNER items are not uppercase (match Change Tuner / Logs / etc) */ + text-transform: none; + letter-spacing: normal; + } + + .mobile-submenu-list > li > a:hover, + .mobile-submenu-list > li > button:hover, + .mobile-submenu-list > li > .mobile-submenu-toggle:hover { + background: rgba(255,255,255,0.04); + } + + /* caret styling (keeps visual affordance) */ + .mobile-submenu-caret { + display:inline-block; + transition: transform 180ms ease; + margin-left: 6px; + font-size: 0.9em; + line-height: 1; + } + + /* rotate caret when open; ensure nested caret rotates as well */ + .mobile-submenu.open > .mobile-submenu-toggle .mobile-submenu-caret, + .mobile-submenu.open > .mobile-submenu-toggle .mobile-submenu-caret { + transform: rotate(90deg); + } + + /* accessibility: ensure focus states for inner items are visible */ + .mobile-submenu-list > li > a:focus, + .mobile-submenu-list > li > button:focus, + .mobile-submenu-list > li > .mobile-submenu-toggle:focus { + outline: 2px solid rgba(255,255,255,0.12); + outline-offset: 2px; + } +} diff --git a/static/css/mobile.css b/static/css/mobile.css new file mode 100644 index 0000000..a0aab31 --- /dev/null +++ b/static/css/mobile.css @@ -0,0 +1,164 @@ +/* Mobile / responsive styles (includes header/nav fixes + player/grid responsive rules) + Loaded after base.css. +*/ + +/* Prevent mobile browsers from auto-resizing text */ +html, body { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +/* --- Header / nav / mobile menu (unchanged core rules, ensure nav scroll) --- */ +.hamburger { + display: none; + background: none; + border: none; + padding: 8px; + margin-right: 6px; + align-items: center; + justify-content: center; + cursor: pointer; + color: inherit; + height: 40px; + width: 40px; + box-sizing: border-box; +} +.hamburger svg rect { fill: currentColor; } + +/* Off-canvas mobile nav: MUST be independently scrollable */ +.mobile-nav { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 280px; + max-width: 90%; + background: var(--chan-col-bg, #111); + color: var(--timebar-color, #fff); + transform: translateX(-110%); + transition: transform 220ms ease; + z-index: 5000; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + -webkit-overflow-scrolling: touch; + overflow-y: auto; + max-height: 100vh; +} +.mobile-nav.open { transform: translateX(0); } +.mobile-nav-inner { padding: 12px; box-sizing: border-box; } +.mobile-nav-header { display:flex; align-items:center; justify-content:space-between; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,0.06); } +.mobile-nav .mobile-links { list-style:none; margin:8px 0; padding:0; } +.mobile-nav .mobile-links li { margin:8px 0; } +.mobile-nav .mobile-links a { color: inherit; text-decoration:none; display:block; padding:10px 6px; border-radius:4px; } +.mobile-nav .mobile-links a:hover { background: rgba(255,255,255,0.06); } + +/* --- Mobile UX breakpoints --- */ +@media (max-width: 900px) { + /* header */ + .header { height: 40px !important; min-height: 40px !important; padding: 0 8px !important; box-sizing: border-box; } + .header .links { display: none !important; } + .hamburger { display: inline-flex !important; } + .header .clock { display: none !important; } /* optional */ + + /* Dropdowns bounded to viewport and scrollable */ + .dropdown-content, .submenu-content { + position: fixed !important; + left: 8px !important; + right: 8px !important; + top: 48px !important; + max-height: calc(100vh - 56px) !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch !important; + box-shadow: 0 8px 20px rgba(0,0,0,0.35) !important; + margin: 0 !important; + border-radius: 6px !important; + } + + /* keep header stable for directv/comcast */ + body.directv .header, + body.comcast .header { + height: 40px !important; + min-height: 40px !important; + padding: 0 8px !important; + box-sizing: border-box !important; + display: flex !important; + align-items: center !important; + overflow: visible !important; + transform: none !important; + -webkit-transform: none !important; + } + + /* neutralize heavy text-styles for small screens */ + body.directv .header .links > a, + body.comcast .header .links > a, + body.directv .header .links > .dropdown > .dropbtn, + body.comcast .header .links > .dropdown > .dropbtn, + body.directv .header .links > span, + body.comcast .header .links > span, + body.directv #clock, + body.comcast #clock { + -webkit-text-stroke: 0 !important; + text-shadow: none !important; + height: 40px !important; + line-height: 40px !important; + padding: 0 10px !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + box-sizing: border-box !important; + } + + body.directv .header::before, + body.directv .header::after, + body.comcast .header::before, + body.comcast .header::after { + display: none !important; + content: none !important; + } + + body.mobile-nav-open { overflow: hidden !important; touch-action: none !important; } + + /* --- PLAYER / SUMMARY responsiveness --- */ + /* Make the player row stack vertically on small screens */ + .player { + display: flex !important; + flex-direction: column !important; + gap: 12px !important; + padding: 12px !important; + box-sizing: border-box; + } + + /* Program Info summary sits above the video and is full width */ + .player .summary { + order: 1; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box; + padding: 10px !important; + } + + /* Video fills the width and is constrained in height so it does not become huge */ + #video { + order: 2; + width: 100% !important; + max-width: 100% !important; + height: auto !important; + max-height: 42vh !important; /* cap video height to a comfortable viewport fraction */ + background: #000 !important; + object-fit: contain !important; /* ensure video scales without cropping */ + } + + /* If you prefer the video to be slightly taller on large phones, override on portrait large screens */ + @media (min-height: 700px) and (max-width: 900px) { + #video { max-height: 48vh !important; } + } + + /* Reduce timeline & program sizes for narrow viewports */ + .grid-row { height: 48px !important; } + .program { top: 6px !important; height: 36px !important; font-size: 12px !important; padding: 4px 6px !important; } + .time-header-fixed { height: 40px !important; } + .time-cell { font-size: 12px !important; padding: 0 6px !important; } + + /* Ensure grid-content transform is GPU-friendly */ + .grid-content { will-change: transform; -webkit-transform-origin: left top; transform-origin: left top; } +} diff --git a/static/js/auto-scroll-manager.js b/static/js/auto-scroll-manager.js new file mode 100644 index 0000000..48e7904 --- /dev/null +++ b/static/js/auto-scroll-manager.js @@ -0,0 +1,150 @@ +// auto-scroll-manager.js +// Defensive helper additions: remove auto-scroll clones and respond to theme changes. +// Add/merge this into your existing auto-scroll implementation. This file is safe if +// your existing auto-scroll code exposes window.__autoScroll or uses localStorage.autoScrollEnabled. + +(function () { + 'use strict'; + + // Class name used by the auto-scroll clones in this app + var CLONE_SELECTOR = '.__auto_scroll_clone'; + + function removeAutoScrollClones() { + try { + var clones = document.querySelectorAll(CLONE_SELECTOR); + if (!clones || clones.length === 0) return 0; + clones.forEach(function (c) { + // If the clone has any attached cleanup hooks, try to call them + try { + if (c.__autoScrollCleanup && typeof c.__autoScrollCleanup === 'function') { + try { c.__autoScrollCleanup(); } catch (e) { /* ignore */ } + } + } catch (e) {} + // Remove the clone node from DOM + if (c.parentNode) c.parentNode.removeChild(c); + }); + return clones.length; + } catch (err) { + console.error('removeAutoScrollClones error', err); + return 0; + } + } + + // Public helper so other code can call it: window.__autoScrollCleanup() + try { + window.__autoScrollCleanup = function () { + return removeAutoScrollClones(); + }; + } catch (e) {} + + // Disable auto-scroll safely using whatever API is available, + // and ensure localStorage flag is set so future loads know it's disabled. + function disableAutoScrollForTheme() { + try { localStorage.setItem('autoScrollEnabled', 'false'); } catch (e) {} + try { + if (window.__autoScroll) { + if (typeof window.__autoScroll.disable === 'function') { + try { window.__autoScroll.disable(); } catch (e) {} + } else if (typeof window.__autoScroll.toggle === 'function') { + // Try to get current status and toggle off if necessary + try { + var s = (typeof window.__autoScroll.status === 'function') ? window.__autoScroll.status() : null; + var pref = s && typeof s.pref !== 'undefined' ? s.pref : null; + if (pref === null && typeof window.__autoScroll.pref === 'function') { + pref = window.__autoScroll.pref(); + } + if (pref) window.__autoScroll.toggle(); + } catch (e) { /* ignore */ } + } + try { window.__autoScroll._disabledByTheme = true; } catch (e) {} + } + } catch (e) { /* ignore */ } + + // Remove remaining clones from the DOM + try { removeAutoScrollClones(); } catch (e) {} + } + + // When leaving the theme we clear the marker but do not automatically re-enable auto-scroll. + function clearThemeDisableMarker() { + try { + if (window.__autoScroll && window.__autoScroll._disabledByTheme) { + try { delete window.__autoScroll._disabledByTheme; } catch (e) {} + } + } catch (e) {} + } + + // Listen for the theme:applied custom event dispatched by theme.js.applyTheme + // (or emitted earlier by other code) + function onThemeApplied(e) { + try { + var theme = (e && e.detail && e.detail.theme) ? e.detail.theme : null; + if (!theme) return; + if (theme === 'tvguide1990') { + disableAutoScrollForTheme(); + } else { + clearThemeDisableMarker(); + // Optionally remove clones when switching away too (keep DOM tidy) + removeAutoScrollClones(); + } + } catch (err) { + console.error('onThemeApplied error', err); + } + } + + // Also guard if theme gets applied via attribute/class changes on the document + // (some code may set body class or html data-theme directly). Observe body attributes. + function watchBodyForThemeClass() { + try { + var observer = new MutationObserver(function (mutations) { + mutations.forEach(function (m) { + if (m.type === 'attributes' && m.attributeName === 'class') { + try { + var cls = document.body.className || ''; + if (cls.split(/\s+/).indexOf('tvguide1990') !== -1) { + disableAutoScrollForTheme(); + } else { + // No tvguide1990 in classes: clear marker and remove clones + clearThemeDisableMarker(); + removeAutoScrollClones(); + } + } catch (e) {} + } + }); + }); + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + } catch (e) {} + } + + // On load: if current theme is tvguide1990, disable auto-scroll and clean clones. + function initRunChecks() { + try { + var saved = null; + try { saved = localStorage.getItem('theme'); } catch (e) {} + if (saved === 'tvguide1990') { + disableAutoScrollForTheme(); + } + // If auto-scroll is disabled in localStorage, ensure clones are not left behind + try { + var enabled = localStorage.getItem('autoScrollEnabled'); + if (enabled === 'false') removeAutoScrollClones(); + } catch (e) {} + } catch (e) {} + } + + // Wire up listeners + try { + document.addEventListener('theme:applied', onThemeApplied, false); + } catch (e) {} + try { watchBodyForThemeClass(); } catch (e) {} + + // Run initial checks on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initRunChecks, false); + } else { + setTimeout(initRunChecks, 0); + } + + // Expose a console-friendly alias to remove clones immediately + try { window.removeAutoScrollClones = removeAutoScrollClones; } catch (e) {} + +})(); diff --git a/static/js/auto-scroll.js b/static/js/auto-scroll.js index 3d7869d..2d9f9a1 100644 --- a/static/js/auto-scroll.js +++ b/static/js/auto-scroll.js @@ -445,4 +445,4 @@ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); -})(); \ No newline at end of file +})(); diff --git a/static/js/clock-fix.js b/static/js/clock-fix.js new file mode 100644 index 0000000..9bc8fb1 --- /dev/null +++ b/static/js/clock-fix.js @@ -0,0 +1,62 @@ +// Small resilient clock updater (fallback) +// Place at: static/js/clock-fix.js and include with defer. +// +// This will update #clock every second and also attempt an initial update on DOMContentLoaded/load. +// It also listens for DOM mutations so it recovers if the header is re-rendered dynamically. + +(function(){ + function formatNow() { + try { + const now = new Date(); + return now.toLocaleString([], { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } catch (e) { + // fallback simple formatting + return new Date().toString(); + } + } + + function doUpdateClock() { + try { + const el = document.getElementById('clock'); + if (!el) return; + el.textContent = formatNow(); + } catch (e) { + // swallow errors so this helper can't break other scripts + console.debug('clock-fix update error', e); + } + } + + // Expose a legacy-compatible global function name so existing code that calls + // updateClock() won't throw ReferenceError. + // If another script already defines updateClock, do not overwrite it. + if (typeof window !== 'undefined' && typeof window.updateClock !== 'function') { + window.updateClock = doUpdateClock; + } + + // Update immediately on DOM ready and on load (cover different load orders) + document.addEventListener('DOMContentLoaded', doUpdateClock); + window.addEventListener('load', doUpdateClock); + + // Continuous interval update + setInterval(doUpdateClock, 1000); + + // Observe DOM mutations (in case header is re-rendered/replaced by other scripts) + const observer = new MutationObserver(() => { + // small debounce + if (observer._t) clearTimeout(observer._t); + observer._t = setTimeout(() => { + doUpdateClock(); + }, 80); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + // Also try a best-effort immediate run in case this file is included late + doUpdateClock(); +})(); diff --git a/static/js/grid-adapt.js b/static/js/grid-adapt.js new file mode 100644 index 0000000..3037902 --- /dev/null +++ b/static/js/grid-adapt.js @@ -0,0 +1,119 @@ +// Adaptive grid scaling for narrow/mobile viewports. +// Place at: static/js/grid-adapt.js +// Include after your other scripts, e.g.: +// + +(function(){ + const MOBILE_MAX = 900; // only adapt on viewports <= this width + + function parsePx(value) { + if (!value) return NaN; + return parseFloat(String(value).replace('px','')) || NaN; + } + + function findGridContent() { + // Prefer the main guide grid-content inside guide-outer + let el = document.querySelector('#guideOuter .grid-content'); + if (el) return el; + // fallback to the first .grid-content found + return document.querySelector('.grid-content'); + } + + function adaptGridToViewport(){ + try { + const gridContent = findGridContent(); + if (!gridContent) return; + + // On large screens reset any transforms + if (window.innerWidth > MOBILE_MAX) { + gridContent.style.transform = ''; + gridContent.style.transformOrigin = ''; + // restore guide-outer height if modified + const guideOuter = document.getElementById('guideOuter'); + if (guideOuter) guideOuter.style.height = ''; + return; + } + + // Determine total timeline width: + // Prefer CSS var --total-width if set, otherwise measured scrollWidth + const rootStyle = getComputedStyle(document.documentElement); + let totalWidth = parsePx(rootStyle.getPropertyValue('--total-width')); + if (!totalWidth || Number.isNaN(totalWidth) || totalWidth <= 0) { + // gridContent may be transformed already; measure scrollWidth which is unscaled + totalWidth = gridContent.scrollWidth || gridContent.getBoundingClientRect().width || 1; + } + + // Determine channel column width (prefer CSS var) + let chanColWidth = parsePx(rootStyle.getPropertyValue('--chan-col-width')); + if (!chanColWidth || Number.isNaN(chanColWidth) || chanColWidth <= 0) { + const firstChan = document.querySelector('.guide-row .chan-col'); + chanColWidth = firstChan ? firstChan.getBoundingClientRect().width : 200; + } + + // Compute available width for the timeline (viewport minus chan column) + const available = Math.max(120, window.innerWidth - chanColWidth); + let scale = 1; + if (totalWidth > 0 && available > 0) { + scale = Math.min(1, available / totalWidth); + } + + // Only apply scaling when we need to shrink (never expand) + if (scale < 1) { + gridContent.style.transformOrigin = 'left top'; + gridContent.style.transform = `scale(${scale})`; + gridContent.style.willChange = 'transform'; + } else { + gridContent.style.transform = ''; + gridContent.style.transformOrigin = ''; + } + + // 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'; + } else { + guideOuter.style.height = ''; + } + } + + // Recompute fixed header/now-line if helper exists + if (typeof window.createOrUpdateFixedTimeBar === 'function') { + window.createOrUpdateFixedTimeBar(); + } + if (typeof window.updateNowLine === 'function') { + window.updateNowLine(); + } + } catch (e) { + console.debug('grid-adapt error', e); + } + } + + // Run on load + document.addEventListener('DOMContentLoaded', adaptGridToViewport); + // And on resize + window.addEventListener('resize', adaptGridToViewport); + + // Recompute on theme changes or other body-class mutations + const observer = new MutationObserver(adaptGridToViewport); + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + + // Also watch for layout changes to the grid-content (images, dynamic content) + const gridEl = findGridContent(); + if (gridEl) { + const gridObserver = new MutationObserver(() => { + // small debounce to avoid thrashing + clearTimeout(gridAdaptDebounce); + gridAdaptDebounce = setTimeout(adaptGridToViewport, 100); + }); + let gridAdaptDebounce = null; + gridObserver.observe(gridEl, { childList: true, subtree: true, attributes: true }); + } +})(); diff --git a/static/js/mobile-nav.js b/static/js/mobile-nav.js new file mode 100644 index 0000000..088d291 --- /dev/null +++ b/static/js/mobile-nav.js @@ -0,0 +1,252 @@ +// mobile-nav.js - robust open/close for off-canvas mobile nav +// Includes: +// - safe open/close/toggle for #mobileNav (original behavior) +// - delegated click handler to ensure links inside mobile nav/settings navigate +// - mobile submenu toggle behavior for .mobile-submenu-toggle buttons +// This file is idempotent and safe to drop in place (overwrites existing mobile-nav.js). + +(function () { + // Guard so this file can be safely included multiple times + if (window.__mobileNavInitialized) return; + window.__mobileNavInitialized = true; + + const BODY_OPEN_CLASS = 'mobile-nav-open'; + const NAV_OPEN_CLASS = 'open'; + + function qs(sel, root = document) { try { return root.querySelector(sel); } catch (e) { return null; } } + + const mobileNav = qs('#mobileNav'); + const hamburger = qs('#mobileHamburger'); + const mobileNavClose = qs('#mobileNavClose'); + + // simple re-entrancy guard + let isClosing = false; + let isOpening = false; + + function setAriaExpanded(el, expanded) { + if (!el) return; + try { el.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } catch (e) {} + } + + function openNav() { + if (!mobileNav || isOpening) return; + if (mobileNav.classList.contains(NAV_OPEN_CLASS)) { + setAriaExpanded(hamburger, true); + mobileNav.setAttribute('aria-hidden', 'false'); + document.body.classList.add(BODY_OPEN_CLASS); + return; + } + isOpening = true; + mobileNav.classList.add(NAV_OPEN_CLASS); + mobileNav.setAttribute('aria-hidden', 'false'); + document.body.classList.add(BODY_OPEN_CLASS); + setAriaExpanded(hamburger, true); + // let CSS transitions finish; but dispatch resize immediately so adapters can recalc + window.dispatchEvent(new Event('resize')); + // clear flag after next tick (allow handlers to run) + setTimeout(() => { isOpening = false; }, 250); + } + + function closeNav() { + if (!mobileNav || isClosing) return; + if (!mobileNav.classList.contains(NAV_OPEN_CLASS)) { + setAriaExpanded(hamburger, false); + mobileNav.setAttribute('aria-hidden', 'true'); + document.body.classList.remove(BODY_OPEN_CLASS); + return; + } + isClosing = true; + // close visual state (don't programmatically trigger clicks that may re-enter this function) + mobileNav.classList.remove(NAV_OPEN_CLASS); + setAriaExpanded(hamburger, false); + // remove the body class after a short delay to allow CSS to animate + setTimeout(() => { + document.body.classList.remove(BODY_OPEN_CLASS); + mobileNav.setAttribute('aria-hidden', 'true'); + isClosing = false; + // dispatch a resize so other scripts can recompute layout now nav is closed + window.dispatchEvent(new Event('resize')); + }, 220); + } + + function toggleNav() { + if (!mobileNav) return; + if (mobileNav.classList.contains(NAV_OPEN_CLASS)) closeNav(); else openNav(); + } + + // Wire controls (safe: uses addEventListener and prevents duplicate wiring) + function safeAddListener(el, type, fn) { + if (!el) return; + // attach a symbol on element to avoid duplicate binding if script included twice + const key = '__bound_' + type; + if (el[key]) return; + el.addEventListener(type, fn); + el[key] = true; + } + + safeAddListener(hamburger, 'click', (e) => { + e.preventDefault(); + toggleNav(); + }); + + safeAddListener(mobileNavClose, 'click', (e) => { + e.preventDefault(); + closeNav(); + }); + + // close when clicking outside the inner nav area (if overlay exists or user clicks backdrop) + safeAddListener(mobileNav, 'click', (e) => { + // only close when clicking the backdrop element (not when clicking inside .mobile-nav-inner) + if (e.target === mobileNav) { + closeNav(); + } + }); + + // close on Escape + safeAddListener(document, 'keydown', (e) => { + if (e.key === 'Escape' && mobileNav && mobileNav.classList.contains(NAV_OPEN_CLASS)) { + closeNav(); + } + }); + + // ensure hamburger aria state is initialized + setAriaExpanded(hamburger, false); + if (mobileNav) mobileNav.setAttribute('aria-hidden', 'true'); + + // Expose small API for debugging or external control + window.mobileNavControl = { + open: openNav, + close: closeNav, + toggle: toggleNav, + isOpen: () => mobileNav && mobileNav.classList.contains(NAV_OPEN_CLASS) + }; + +})(); + +/* ---------------------------------------------------------------------- + Delegated mobile nav click handler (idempotent) + - Ensures links inside #mobileNav and #settingsMenu navigate on mobile, + even if other handlers call preventDefault or otherwise interfere. + - Closes the mobile nav on navigation for better UX. +---------------------------------------------------------------------- */ +(function () { + if (window.__mobileNavDelegationBound) return; + window.__mobileNavDelegationBound = true; + + function isLinkInMobileNav(a) { + if (!a) return false; + const mobileNav = document.getElementById('mobileNav'); + const settings = document.getElementById('settingsMenu'); + return (mobileNav && mobileNav.contains(a)) || (settings && settings.contains(a)); + } + + function delegateNavClicks(e) { + try { + const a = e.target && e.target.closest && e.target.closest('a'); + if (!a) return; + if (!isLinkInMobileNav(a)) return; + + const href = (a.getAttribute('href') || '').trim(); + // Ignore anchors intended for JS handlers or toggles + if (!href || href === '#' || href.toLowerCase().startsWith('javascript:')) return; + + // Close the mobile nav if open + if (window.mobileNavControl && typeof window.mobileNavControl.close === 'function') { + try { window.mobileNavControl.close(); } catch (err) {} + } else { + const mobileNav = document.getElementById('mobileNav'); + if (mobileNav) mobileNav.classList.remove('open'); + document.body.classList.remove('mobile-nav-open'); + } + + // Ensure navigation occurs even if other handlers prevented it. + // Use a tiny timeout to let other handlers finish. + setTimeout(() => { + try { + // only navigate if target differs from current location hash or pathname + const dest = new URL(href, window.location.href); + const sameOrigin = dest.origin === window.location.origin; + const samePathAndHash = (dest.pathname === window.location.pathname && dest.hash === window.location.hash); + if (!samePathAndHash) { + window.location.href = dest.href; + } + } catch (err) { + // if URL parsing fails, fall back to setting location directly + try { window.location.href = href; } catch (e) {} + } + }, 20); + } catch (err) { + console.error('delegateNavClicks err', err); + } + } + + // Use capture phase so this runs before many listeners that may call preventDefault + document.addEventListener('click', delegateNavClicks, true); +})(); + +/* ---------------------------------------------------------------------- + Mobile submenu toggle behavior (idempotent) + - Wires .mobile-submenu-toggle buttons to expand/collapse their lists. + - Updates aria-expanded and aria-hidden attributes and toggles .open class. +---------------------------------------------------------------------- */ +(function () { + if (window.__mobileSubmenuToggleBound) return; + window.__mobileSubmenuToggleBound = true; + + function toggleSubmenu(button, expand) { + try { + if (!button) return; + const controls = button.getAttribute('aria-controls'); + const list = controls ? document.getElementById(controls) : button.nextElementSibling; + const parentLi = button.closest && button.closest('.mobile-submenu'); + // NOTE: use the class 'open' so it matches mobile-submenu.css expectations + const willExpand = (typeof expand === 'boolean') ? expand : !(button.getAttribute('aria-expanded') === 'true'); + + button.setAttribute('aria-expanded', willExpand ? 'true' : 'false'); + if (list) { + list.setAttribute('aria-hidden', willExpand ? 'false' : 'true'); + } + if (parentLi) { + if (willExpand) parentLi.classList.add('open'); else parentLi.classList.remove('open'); + } + // dispatch resize so other scripts can recompute layout + window.dispatchEvent(new Event('resize')); + } catch (err) { console.debug('submenu toggle err', err); } + } + + // Handle click on toggle buttons + document.addEventListener('click', function (ev) { + try { + const btn = ev.target && ev.target.closest && ev.target.closest('.mobile-submenu-toggle'); + if (!btn) return; + ev.preventDefault(); + toggleSubmenu(btn); + } catch (e) { /* ignore */ } + }); + + // Keyboard support for Enter / Space + document.addEventListener('keydown', function (ev) { + try { + if (!ev || (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar')) return; + const btn = ev.target && ev.target.closest && ev.target.closest('.mobile-submenu-toggle'); + if (!btn) return; + ev.preventDefault(); + toggleSubmenu(btn); + } catch (e) { /* ignore */ } + }); + + // Initialize current state for any toggle buttons already in DOM + document.addEventListener('DOMContentLoaded', () => { + const toggles = Array.from(document.querySelectorAll('.mobile-submenu-toggle')); + toggles.forEach(btn => { + const controls = btn.getAttribute('aria-controls'); + const list = controls ? document.getElementById(controls) : btn.nextElementSibling; + const expanded = (btn.getAttribute('aria-expanded') === 'true'); + if (list) list.setAttribute('aria-hidden', expanded ? 'false' : 'true'); + const parentLi = btn.closest && btn.closest('.mobile-submenu'); + if (parentLi) { + if (expanded) parentLi.classList.add('open'); else parentLi.classList.remove('open'); + } + }); + }); +})(); diff --git a/static/js/mobile-player-adapt.js b/static/js/mobile-player-adapt.js new file mode 100644 index 0000000..970b20c --- /dev/null +++ b/static/js/mobile-player-adapt.js @@ -0,0 +1,50 @@ +(function(){ + // This helper keeps the video height reasonable on narrow viewports, + // accounting for the header height and the fixed timebar (if present). + function adjustVideoHeight() { + try { + if (window.innerWidth > 900) { + // desktop: let CSS/base rules handle sizing + const video = document.getElementById('video'); + if (video) { + video.style.maxHeight = ''; + } + return; + } + + const video = document.getElementById('video'); + const playerRow = document.getElementById('playerRow'); + const header = document.querySelector('.header'); + const fixedBar = document.getElementById('fixedTimeBar'); + + if (!video || !playerRow) return; + + const vh = window.innerHeight; + const headerHeight = header ? Math.round(header.getBoundingClientRect().height) : 40; + const fixedBarHeight = fixedBar ? Math.round(fixedBar.getBoundingClientRect().height) : 0; + const reserved = headerHeight + fixedBarHeight + 80; // 80px for summary + paddings/controls + // Give the video a max height that's the remaining viewport minus some buffer + const maxH = Math.max(160, Math.round(vh - reserved)); + // Restrict to a reasonable share of viewport (so timeline remains reachable) + const cap = Math.round(vh * 0.55); // at most 55% of viewport height + const finalH = Math.min(maxH, cap); + + video.style.maxHeight = finalH + 'px'; + video.style.height = 'auto'; + + // If using scaled grid via grid-adapt.js we expect grid-adapt to recompute when this runs. + if (typeof window.createOrUpdateFixedTimeBar === 'function') { + window.createOrUpdateFixedTimeBar(); + } + } catch(e){ + console.debug('mobile-player-adapt err', e); + } + } + + window.addEventListener('resize', adjustVideoHeight); + document.addEventListener('DOMContentLoaded', adjustVideoHeight); + + // Recompute when body class mutates (theme change) or when mobile nav opens/closes (mobile-nav dispatches resize) + const observer = new MutationObserver(function(){ adjustVideoHeight(); }); + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); +})(); diff --git a/static/js/mobile-scroll-fix.js b/static/js/mobile-scroll-fix.js new file mode 100644 index 0000000..9a16115 --- /dev/null +++ b/static/js/mobile-scroll-fix.js @@ -0,0 +1,52 @@ +// Keep fixed timebar correctly positioned on mobile when the page scrolls +// 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(); + }); + } + } 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(); + }); + bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + + // Recompute on resize / orientation change + window.addEventListener('resize', recomputeThrottled); +})(); diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..f837aeb --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,110 @@ +(function () { + 'use strict'; + var STORAGE_KEY = 'theme'; + + function setHtmlAndBodyTheme(name) { + try { + if (name) { + document.documentElement.setAttribute('data-theme', name); + } else { + document.documentElement.removeAttribute('data-theme'); + } + } catch (e) { /* ignore */ } + + try { + // Backwards compatibility: add a body class for themes that use body.themeName selectors + var prev = document.body.getAttribute('data-theme-class'); + if (prev) document.body.classList.remove(prev); + if (name) { + document.body.classList.add(name); + document.body.setAttribute('data-theme-class', name); + } else { + document.body.removeAttribute('data-theme-class'); + } + } catch (e) { /* ignore */ } + } + + function applyTheme(name) { + if (!name) return; + setHtmlAndBodyTheme(name); + try { localStorage.setItem(STORAGE_KEY, name); } catch (e) {} + + // If TV Guide (Classic) selected, ensure auto-scroll is turned off and disabled. + // We set localStorage autoScrollEnabled to 'false' and call any available auto-scroll API. + try { + if (name === 'tvguide1990') { + try { localStorage.setItem('autoScrollEnabled', 'false'); } catch (e) {} + if (window.__autoScroll) { + try { + // Preferred API: disable() + if (typeof window.__autoScroll.disable === 'function') { + window.__autoScroll.disable(); + } else if (typeof window.__autoScroll.toggle === 'function') { + // If toggle exists and status shows enabled, toggle it off + try { + var status = (typeof window.__autoScroll.status === 'function') ? window.__autoScroll.status() : null; + var pref = status && typeof status.pref !== 'undefined' ? status.pref : null; + if (pref === null && typeof window.__autoScroll.pref === 'function') { + pref = window.__autoScroll.pref(); + } + if (pref) window.__autoScroll.toggle(); + } catch (inner) { /* ignore */ } + } + // Mark that auto-scroll was disabled due to theme so other code can detect it + try { window.__autoScroll._disabledByTheme = true; } catch (e) {} + } catch (e) { /* ignore */ } + } + } else { + // Leaving tvguide1990: clear the theme-disable marker (do NOT auto-enable auto-scroll) + if (window.__autoScroll && window.__autoScroll._disabledByTheme) { + try { delete window.__autoScroll._disabledByTheme; } catch (e) {} + } + } + } catch (e) { /* ignore */ } + + // update any controls that use data-theme-selector + try { + document.querySelectorAll('[data-theme-selector]').forEach(function (el) { + el.classList.toggle('active', el.getAttribute('data-theme') === name); + }); + } catch (e) {} + + // Let other parts of the app know theme changed: mutation observers on body will also fire. + try { + var ev = new CustomEvent('theme:applied', { detail: { theme: name } }); + window.dispatchEvent(ev); + } catch (e) {} + } + + // Expose global API (keeps existing inline onclick="setTheme(...)" working). + // If there is an existing applyTheme defined elsewhere, prefer it. + window.setTheme = function (name) { + if (typeof window.applyTheme === 'function' && window.applyTheme !== applyTheme) { + try { window.applyTheme(name); return; } catch (e) { /* fall through */ } + } + applyTheme(name); + }; + window.applyTheme = applyTheme; + + // Delegated click handler for elements with data-theme-selector/data-theme + document.addEventListener('click', function (e) { + try { + var btn = e.target.closest && e.target.closest('[data-theme-selector]'); + if (!btn) return; + if (e && typeof e.preventDefault === 'function') e.preventDefault(); + var t = btn.getAttribute('data-theme'); + if (t) setTheme(t); + } catch (e) { /* ignore */ } + }, false); + + // Restore theme on load (deferred scripts execute before DOMContentLoaded). + document.addEventListener('DOMContentLoaded', function () { + try { + var saved = null; + try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {} + if (saved) { + setTheme(saved); + } + } catch (e) { /* ignore */ } + }); +})(); diff --git a/templates/_header.html b/templates/_header.html index 4ff08eb..f7fe684 100644 --- a/templates/_header.html +++ b/templates/_header.html @@ -1,37 +1,110 @@ -