diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac3520..b506a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,47 @@ All notable changes to this project will be documented here. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project follows [Semantic Versioning](https://semver.org/). +--- + +## v4.1.0 - 2025-10-25 +### โจ 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. + - Deterministic wraparound ensures seamless looping without scroll jitter. + - Waits up to 5 seconds for guide data to populate before activating. + - Stores preference in localStorage (`autoScrollEnabled`) and exposes simple APIs (`cloneNow`, `status`). + +- **Per-Page Modular CSS** + - Introduced separate per-page stylesheets: `about.css`, `change_password.css`, `change_tuner.css`, `logs.css`, and `manage_users.css`. + - Shared global styling moved to `base.css` for consistency. + +- **Unified Template Structure** + - New `base.html` and `_header.html` templates consolidate common layout and navigation. + - All major pages now extend from `base.html` for easier maintenance. + +- **New JavaScript Modules** + - Added `tuner-settings.js` for handling tuner selection and dynamic UI updates. + +### ๐งฐ 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 +- 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) @@ -34,16 +74,6 @@ This project follows [Semantic Versioning](https://semver.org/). - PlutoTV / custom tuner aggregation features - Enhanced guide refresh logic for long-running sessions ---- - -## [Unreleased] - -- Planned: add `.m3u8` tuner support. -- Planned: move logs to SQLite DB. -- Planned: log filtering and pagination. - ---- - ## v3.3.0 - 2025-10-15 ### Added diff --git a/INSTALL.md b/INSTALL.md index b67c31d..421f9a2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,7 +1,7 @@ # Installation Guide -**Version:** v4.0.0 -**Last Updated:** 2025-10-19 +**Version:** v4.1.0 +**Last Updated:** 2025-10-25 --- diff --git a/README.md b/README.md index cb62500..1a59837 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# ๐บ RetroIPTVGuide v4.0.0 +# ๐บ RetroIPTVGuide v4.1.0
-
+
@@ -59,7 +59,7 @@ docker run -d -p 5000:5000 ghcr.io/thehack904/retroiptvguide:latest
```
### ๐งฉ TrueNAS SCALE App
-- Upload the provided `retroiptvguide-3.2.0.zip` chart.
+- Upload the provided `retroiptvguide-4.1.0.zip` chart.
- Repository: `ghcr.io/thehack904/retroiptvguide`
- Tag: `latest`
- Exposes port `5000`.
diff --git a/ROADMAP.md b/ROADMAP.md
index 1ec7f0b..8eca213 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.0.0 (2025-10-19)
-The 4.0.0 release merges all Testing branch updates into Main, introducing unified installers, new UI templates, and Android TV optimizations.
-
+# 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.
---
## ๐ฎ Feature Upgrades
@@ -15,114 +14,109 @@ The 4.0.0 release merges all Testing branch updates into Main, introducing unifi
- [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).
- [ ] Support for **.m3u8 single-channel playlists** as tuner sources (planned v3.2.0).
- - Option A: Special-case `.m3u8` handling in parser.
- - Option B: Add explicit `hls` column to `tuners.db`.
- [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.1.x planned)*
+- [ ] Add per-user tuner assignment and default tuner preferences. ๐ *(v4.2.x planned)*
- [ ] Introduce combined tuner builder (custom tuner aggregation). ๐ *(v5.x.x planned)*
---
### 2. Logging & Monitoring
- [ ] Move logs from flat file (`activity.log`) into **SQLite DB** for better querying.
-- [ ] Add filtering and pagination in logs view (by user, action, or date).
-- [ ] Add system health checks (e.g., tuner reachability, XMLTV freshness) to logs.
-- [x] **Admin log management**: add button/route to clear logs (with confirmation) (v2.3.1).
-- [x] Display log file size on logs page (v2.3.1).
-- [x] Post-install HTTP service verification added in Pi installer (v3.1.0).
-- [ ] Add unified โRefresh Guideโ scheduler (configurable intervals). ๐ *(v4.2.x planned)*
+- [ ] Add filtering and pagination in logs view.
+- [ ] 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)*
---
### 3. Guide & Playback
-- [ ] Add **search/filter box** to guide for channels/programs.
-- [ ] Add ability to set **favorites** for quick channel access.
-- [x] Add fallback message (โNo Guide Data Availableโ) for channels missing EPG info (v3.0.1).
-- [ ] Add **reminders/notifications** for upcoming programs.
+- [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.
+- [ ] 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)*
---
### 4. User Management
- [x] Add **manage_users.html** for integrated user control panel. โ
*(v4.0.0)*
-- [ ] Add role-based access control (admin, regular user, read-only).
-- [ ] Add **email or 2FA support** for login (optional).
+- [ ] Role-based access control (admin/user/read-only).
+- [ ] Add email or 2FA support for login.
- [ ] Show last login time in admin panel.
-- [ ] Enhance user management (roles, channel restrictions). ๐ *(v5.x.x planned)*
+- [ ] User role/channel restrictions. ๐ *(v5.x.x planned)*
---
### 5. UI/UX Improvements
-- [x] Unified theming across all templates (Light, Dark, AOL/CompuServe, TV Guide Magazine) (v2.3.2).
+- [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 and modernized UI templates (`guide.html`, `login.html`, `about.html`, `logs.html`, etc.). โ
*(v4.0.0)*
-- [ ] Unify CSS across all templates (minimize inline styles).
-- [ ] Make guide responsive (mobile/tablet view).
-- [ ] Add dark/light theme auto-detect from browser/system.
+- [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).
+- [ ] Add dark/light theme auto-detect.
- [ ] Frozen header timeline to prevent scrolling with channel listing.
- [x] About page under Settings menu (v2.3.1).
---
-### 6. Cross-platform
-- [x] Create installable container.
+### 6. Cross-Platform
- [x] Unified Linux, Windows, and Raspberry Pi installers. โ
*(v4.0.0)*
-- [x] Windows installer via PowerShell + NSSM service (v3.0.0).
-- [x] Pi installer auto-configures GPU and verifies HTTP service (v3.1.0).
-- [x] Add **Windows update/uninstall parity planned**. ๐ *(v4.1.x target)*
+- [x] Windows update/uninstall parity implemented. โ
*(v4.1.0)*
- [ ] Create MacOS install/executable.
-- [x] Validate/test installers fully on all Windows environments.
+- [x] Validate/test installers on all Windows environments.
- [ ] Explore TrueNAS SCALE App Catalog certification. ๐ *(v5.x.x planned)*
---
### 7. New Features
-- [ ] Add the ability to have an **auto-play video stream** upon login (ErsatzTV source).
-- [ ] Option to play a known or unlisted channel as default auto-play source.
+- [ ] Add auto-play stream on login (ErsatzTV integration).
+- [ ] Default auto-play source selection.
- [ ] Begin integration path for **PlutoTV / external IPTV services**. ๐ *(v5.x.x)*
---
### 8. Planned Enhancements
-- [ ] Add **safety checks** in `add_tuner()`:
- - Prevent inserting duplicate tuner names.
- - Validate XML/M3U URLs before commit.
-- [x] Add **GPU verification** after `raspi-config` call (v3.1.0).
-- [x] Suppress `rfkill` Wi-Fi message during GPU configuration (v3.1.0).
-- [x] Post-install adaptive HTTP check loop (15s poll) (v3.1.0).
-- [x] Reorganized project structure and documentation. โ
*(v4.0.0)*
+- [ ] Add safety checks in `add_tuner()` (duplicate prevention + URL validation).
+- [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)*
---
## โ๏ธ Technical Improvements
- [x] Add uninstall.sh (v2.3.0).
-- [ ] Validate/test uninstall script fully on Windows environments.
+- [ ] Validate/test uninstall script fully on Windows.
- [ ] Add HTTPS + optional token-based authentication. ๐ *(v4.5.x)*
-- [x] Refactor tuner handling for unified DB structure. โ
*(v4.0.0)*
+- [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.
-- [x] Containerize app (Dockerfile + Compose for deployment).
-- [x] Automated version bump tool updates all key scripts (v3.1.0).
-- [ ] Add CI/CD automation for official .deb and .zip 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 Linux/Windows/RPi installer architecture. โ
*(v4.0.0)*
-- [ ] Add interactive mode selector (Kiosk vs Headless).
-- [ ] Add command-line flag `--mode kiosk` for non-interactive installs.
-- [ ] Ensure logs/services properly isolated between modes.
+- [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.0.0)
-- [x] Unified cross-platform installers (`retroiptv_linux.sh`, `retroiptv_windows.ps1`, `retroiptv_rpi.sh`)
-- [x] Android/Fire/Google TV mode added with animated CRT glow
-- [x] Added `manage_users.html` for full web-based user management
-- [x] Modernized `guide.html`, `login.html`, `about.html`, `logs.html`, etc.
-- [x] Refactored `app.py` for unified configuration + session logic
-- [x] Removed legacy installers (`install.*`, `uninstall.*`, `iptv-server.service`)
-- [x] Reorganized documentation (CHANGELOG, README, ROADMAP)
-- [x] Release tagged as **v4.0.0**
+## โ
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.
+- [x] Updated documentation (CHANGELOG, README, INSTALL, ROADMAP).
+- [x] Windows installer parity update.
+- [x] Release tagged as **v4.1.0**
diff --git a/app.py b/app.py
index d40cea6..1f1b150 100644
--- a/app.py
+++ b/app.py
@@ -1,5 +1,5 @@
-APP_VERSION = "v4.0.0"
-APP_RELEASE_DATE = "2025-10-11"
+APP_VERSION = "v4.1.0"
+APP_RELEASE_DATE = "2025-10-25"
from flask import Flask, render_template, request, redirect, url_for, flash, session
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
diff --git a/retroiptv_linux.sh b/retroiptv_linux.sh
index cd38282..16eb72a 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.0.0"
+VERSION="4.1.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 6546bd3..2a57222 100644
--- a/retroiptv_rpi.sh
+++ b/retroiptv_rpi.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-VERSION="4.0.0"
+VERSION="4.1.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 4613cfe..c99bf7c 100644
--- a/retroiptv_windows.ps1
+++ b/retroiptv_windows.ps1
@@ -1,7 +1,7 @@
<#
RetroIPTVGuide Windows Installer/Uninstaller
Filename: retroiptv_windows.ps1
-Version: 4.0.0
+Version: 4.1.0
License: Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
https://creativecommons.org/licenses/by-nc-sa/4.0/
@@ -55,7 +55,7 @@ if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdent
$ErrorActionPreference = 'Stop'
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
-$VERSION = "4.0.0"
+$VERSION = "4.1.0"
$ScriptDir = Split-Path -Parent -Path $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
diff --git a/static/css/about.css b/static/css/about.css
new file mode 100644
index 0000000..9a99a65
--- /dev/null
+++ b/static/css/about.css
@@ -0,0 +1,67 @@
+/* Per-page styles for About page (scoped) */
+
+.container-about {
+ max-width: 820px;
+ margin: 40px auto 80px;
+ padding: 0 16px;
+ box-sizing: border-box;
+}
+
+.about-box {
+ padding: 20px;
+ border-radius: 8px;
+ background: rgba(255,255,255,0.04);
+ border: 1px solid rgba(255,255,255,0.03);
+ box-shadow: 0 6px 18px rgba(0,0,0,0.12);
+}
+
+.about-box h2 {
+ margin: 0 0 12px;
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: inherit;
+ text-align: center;
+}
+
+.about-box ul {
+ list-style: none;
+ padding: 0;
+ margin: 8px 0 0 0;
+ font-size: 0.98rem;
+}
+
+.about-box li {
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.about-box li:last-child {
+ border-bottom: none;
+}
+
+/* Ensure labels and values wrap nicely on small screens */
+.about-box li strong { min-width: 160px; display:inline-block; color:inherit; }
+.about-box li span { color: inherit; word-break: break-word; }
+
+/* Light theme overrides */
+body.light .about-box {
+ background: #fff;
+ border-color: rgba(0,0,0,0.06);
+ color: #000;
+ box-shadow: 0 4px 10px rgba(0,0,0,0.06);
+}
+body.light .about-box li { border-bottom: 1px solid rgba(0,0,0,0.06); }
+
+/* Retro magazine tweaks */
+body.retro-magazine .about-box { background: #fff; border: 1px solid #000; }
+
+/* Responsive */
+@media (max-width: 640px) {
+ .container-about { padding: 0 12px; margin: 28px 12px 60px; }
+ .about-box { padding: 14px; }
+ .about-box li { padding: 8px 6px; display:block; }
+ .about-box li strong { display:block; margin-bottom:6px; }
+}
diff --git a/static/css/base.css b/static/css/base.css
new file mode 100644
index 0000000..a8b27e8
--- /dev/null
+++ b/static/css/base.css
@@ -0,0 +1,479 @@
+/* Base CSS - authoritative header/menu + full existing guide & theme rules
+ NOTE: header/menu block is authoritative; the rest of your theme data is preserved exactly.
+ Place this at /static/css/base.css
+*/
+
+/* Make the page itself non-scrollable so only the guide area scrolls */
+html, body { height: 100%; overflow: hidden; }
+
+/* Authoritative Header + menus (single source of truth for site) */
+body { font-family: Arial, sans-serif; margin:0; }
+.header {
+ height: 40px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 10px;
+ position: relative;
+ z-index: 2000;
+ box-shadow: none;
+ background: #222;
+}
+
+/* header link container */
+.header .links {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+}
+
+/* header items: links, dropbtns, clock, spans */
+.header .links > a,
+.header .links > .dropdown > .dropbtn,
+.header .links > span,
+.header .links > div#clock {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 12px;
+ color: #eee;
+ font-weight: bold;
+ text-decoration: none;
+ height: 40px;
+ line-height: 40px;
+}
+
+/* hover state */
+.header .links > a:hover,
+.header .links > .dropdown:hover > .dropbtn {
+ background: #111;
+ color: #0af;
+}
+
+/* dropdown basics */
+.dropdown {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+}
+.dropbtn {
+ background: none;
+ border: none;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ padding: 0 12px;
+ height: 40px;
+ line-height: 40px;
+}
+
+/* dropdown menu */
+.dropdown-content {
+ display: none;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ background: #333;
+ min-width: 180px;
+ border-radius: 3px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.3);
+ z-index: 2100;
+ margin-top: 0;
+}
+.dropdown-content a {
+ padding: 10px 14px;
+ display: block;
+ color: #eee;
+ text-decoration: none;
+}
+.dropdown-content a:hover { background: #0af; color:#fff; }
+.dropdown:hover .dropdown-content { display: block; }
+
+/* Submenu (fly-out for Themes) */
+.submenu { position: relative; }
+.submenu > a { display: flex; justify-content: space-between; align-items: center; }
+.submenu-content {
+ display: none;
+ position: absolute;
+ top: 0;
+ left: 100%;
+ background-color: #333;
+ min-width: 160px;
+ border-radius: 3px;
+ box-shadow: 0 4px 6px rgba(0,0,0,0.3);
+ z-index: 2200;
+}
+.submenu-content li a { padding: 10px 14px; display: block; color: #eee; text-decoration: none; }
+.submenu-content li a:hover { background-color: #0af; color: #fff; }
+.submenu:hover .submenu-content { display: block; }
+
+/* Remove default list bullets and padding for all dropdowns & submenus */
+.dropdown-content,
+.submenu ul,
+.submenu-content { list-style: none; margin: 0; padding: 0; }
+
+/* Player row: Program Info (left) | Video (right) */
+.player { display:flex; gap:16px; padding:12px; }
+.summary { flex:1; }
+#video { width:620px; height:350px; }
+
+/* === Guide grid === */
+.guide-outer { height: calc(100vh - 420px); overflow-y: auto; padding-bottom: 80px; position: relative; }
+.guide-row { display:flex; }
+.chan-col { width: 200px; flex-shrink:0; border-right:1px solid; position: relative; z-index: 2; background: var(--chan-col-bg, #1a1a1a); }
+
+/* grid-col allows vertical scrolling but we hide visual scrollbars by default */
+.grid-col {
+ position:relative;
+ flex:1;
+ overflow-x:auto;
+ overflow-y:auto;
+ z-index:1;
+ /* Firefox: hide scrollbar */
+ scrollbar-width: none;
+ /* IE10+ */
+ -ms-overflow-style: none;
+}
+/* hide webkit scrollbars by default */
+.grid-col::-webkit-scrollbar { width: 0; height: 0; }
+
+/* keep headers same */
+.chan-header { height: 34px; border-bottom:1px solid; position:sticky; top:0; z-index:5; }
+.time-header-wrap { position:sticky; top:0; z-index:6; border-bottom:1px solid; }
+
+/* Use CSS variable for per-cell width; keep both flex-basis and width in sync */
+.time-cell { flex: 0 0 var(--timecell-width); width: var(--timecell-width); text-align:center; border-right:1px solid; font-weight:600; }
+
+.chan-name {
+ padding: 10px;
+ height: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ gap: 6px;
+ border-bottom: 1px solid;
+ cursor: pointer;
+ position: relative;
+ z-index: 10;
+ pointer-events: auto;
+ user-select: none;
+}
+.chan-name img { width:36px; height:36px; object-fit:contain; margin-bottom: 4px; }
+.grid-row { position:relative; height:60px; border-bottom:1px solid; }
+.grid-content { position:relative; width: var(--total-width); min-height:100%; }
+.program {
+ position:absolute;
+ top:6px;
+ height:48px;
+ border:1px solid;
+ padding:4px 6px;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ white-space:nowrap;
+ font-size:12px;
+ border-radius:6px;
+ z-index:1;
+}
+.program.now { font-weight:bold; }
+.now-line { position:absolute; top:0; bottom:0; width:2px; z-index:8; pointer-events:none; left: var(--now-offset); }
+
+/* Scrollbar reveal styles */
+.grid-col.show-scroll,
+.grid-col:focus-within {
+ scrollbar-width: thin;
+}
+.grid-col.show-scroll::-webkit-scrollbar { width: 10px; height: 8px; }
+.grid-col.show-scroll::-webkit-scrollbar-track { background: rgba(0,0,0,0.08); border-radius: 6px; }
+.grid-col.show-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.28); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; }
+
+/* Apply the variable to the fixed-left spacer */
+.time-header-fixed .left-spacer {
+ width: var(--chan-col-width, 200px);
+ min-width: var(--chan-col-width, 200px);
+ max-width: var(--chan-col-width, 200px);
+ height: var(--chan-col-height, auto);
+}
+
+/* Fixed time header (core) */
+.time-header-fixed {
+ position: fixed;
+ z-index: 1200;
+ left: 0;
+ display: flex;
+ align-items: stretch;
+ overflow: hidden;
+ background: transparent;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.12);
+ height: 34px;
+ top: 0;
+}
+.time-header-fixed .grid-content { display:flex; align-items:stretch; position:relative; height:100%; width:100%; }
+.time-header-fixed .time-header { display:flex; align-items:center; height:100%; flex: 0 0 auto; }
+.time-header-fixed .time-cell {
+ display:inline-flex; align-items:center; justify-content:center;
+ height:100%; border-right:1px solid var(--timebar-border, #ccc);
+ padding:0 6px; font-weight:600; background: var(--timebar-bg, rgba(255,255,255,0.95));
+ color:var(--timebar-color,#000);
+}
+.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; }
+
+/* Theme variables & theme-specific rules preserved below (copied/preserved from original base.css) */
+
+/* Dark theme */
+body.dark { --timebar-bg:#222; --timebar-border:#444; --timebar-color:#ddd; --chan-col-bg:#1a1a1a; background:#111; color:#ddd; }
+body.dark .time-header-fixed .now-line { background:#0f0; }
+body.dark .header { background:#222; }
+body.dark #video { background:#000; }
+body.dark .summary { color:#ccc; }
+body.dark .chan-col { background:#1a1a1a; border-color:#333; }
+body.dark .grid-col { background:#181818; }
+body.dark .chan-header, body.dark .time-header-wrap { background:#222; border-color:#444; }
+body.dark .time-cell { color:#bbb; border-color:#333; }
+body.dark .chan-name { color:#fff; border-color:#333; }
+body.dark .program { background:#3a3a3a; border-color:#555; color:#eee; }
+body.dark .program.now { background:#2d5030; border-color:#47a447; }
+body.dark .now-line { background:#0f0; }
+
+/* Light theme */
+body.light { --timebar-bg:#fff; --timebar-border:#ddd; --timebar-color:#000; --chan-col-bg:#f9f9f9; background:#fff; color:#000; }
+body.light .time-header-fixed .now-line { background:#090; }
+body.light .header { background:#222; }
+body.light #video { background:#fff; }
+body.light .summary { color:#000; }
+body.light .chan-col { background:#f9f9f9; border-color:#ccc; }
+body.light .grid-col { background:#fafafa; }
+body.light .chan-header, body.light .time-header-wrap { background:#ddd; border-color:#bbb; }
+body.light .time-cell { color:#000; border-color:#ccc; }
+body.light .chan-name { color:#000; border-color:#ccc; }
+body.light .program { background:#ddd; border-color:#aaa; color:#000; }
+body.light .program.now { background:#cfe8cf; border-color:#090; }
+body.light .now-line { background:#090; }
+body.light .header #clock { color: #ffffff !important; }
+
+/* Retro-AOL */
+body.retro-aol { --timebar-bg:#004466; --timebar-border:#33cccc; --timebar-color:#33cccc; --chan-col-bg:#004466; background:#2f4f4f; color:#f0f0f0; font-family:"Tahoma","Arial",sans-serif; }
+body.retro-aol .header { background:#004466; color:#ffcc00; border-bottom:2px solid #33cccc; }
+body.retro-aol .dropdown-content,body.retro-aol .submenu-content{background:#004466;border:1px solid #33cccc;color:#ffcc01;box-shadow:0 4px 6px rgba(0,0,0,.3)}
+body.retro-aol .dropdown-content a,body.retro-aol .submenu-content li a,body.retro-aol .dropdown-content .submenu> a,body.retro-aol .submenu-content .submenu> a{color:#ffcc01!important}
+body.retro-aol .dropdown-content a:hover,body.retro-aol .submenu-content li a:hover{background:#33cccc!important;color:#004466!important}
+body.retro-aol .summary{background:linear-gradient(to bottom,var(--panel-bg-top,#e6f7f5) 0%,var(--panel-bg-bottom,#cfeff0) 100%);color:#00313a;border:2px solid #004466;padding:12px;border-radius:6px;box-sizing:border-box}
+body.retro-aol .summary h3{margin:0 0 6px 0;color:#004466;font-size:1.05em;font-weight:700}
+body.retro-aol .summary p{margin:0;color:#05383a;line-height:1.3}
+body.retro-aol .time-header-fixed .now-line,body.retro-aol .now-line{background:var(--now-line-panel-color);box-shadow:0 0 6px var(--now-line-glow);width:3px}
+body.retro-aol .chan-col{background:#355f5f;border-color:#33cccc}
+body.retro-aol .grid-col{background:#3b6b6b}
+body.retro-aol .chan-header,body.retro-aol .time-header-wrap{background:#3b6b6b;color:#ffcc00;border-color:#33cccc}
+body.retro-aol .time-cell{color:#ffcc00;border-color:var(--timebar-border,#33cccc);border-top:1px solid var(--timebar-border,#33cccc)}
+body.retro-aol .time-header-fixed .time-cell{border-top:1px solid var(--timebar-border,#33cccc)}
+body.retro-aol .chan-name{color:#fff;border-color:#33cccc;font-weight:700}
+body.retro-aol .program{background:#406d6d;border-color:#33cccc;color:#fff}
+body.retro-aol .program.now{background:#33cccc;border-color:#004466;color:#000;font-weight:700}
+body.retro-aol .now-line{background:#ffcc00}
+body.retro-aol .header .links>a,body.retro-aol .header .links>.dropdown>.dropbtn,body.retro-aol .header .links>span,body.retro-aol .header .links>div#clock{color:#ffcc01}
+body.retro-aol .grid-col.show-scroll::-webkit-scrollbar-thumb{background:rgba(0,68,102,.55)}
+body.retro-aol .grid-col.show-scroll::-webkit-scrollbar-track{background:rgba(51,204,204,.06)}
+
+/* === RetroIPTVGuide Theme: TV Guide 1990 Edition (Final Compact) === */
+body.tvguide1990{--chan-col-width:200px;--chan-col-height:38px}
+body.tvguide1990 .time-header-fixed .left-spacer{width:var(--chan-col-width,200px)!important;min-width:var(--chan-col-width,200px)!important;max-width:var(--chan-col-width,200px)!important;height:var(--chan-col-height,auto)!important;flex:0 0 var(--chan-col-width,200px)!important}
+body.tvguide1990 { --timebar-bg: #fff; --timebar-border: #000; --timebar-color: #000; --chan-col-bg: #d8d7d3; }
+body.tvguide1990 { --timebar-bg: #fff; --timebar-border: #000; --timebar-color: #000; --now-line-color: #000; --chan-col-bg: #d8d7d3; }
+body.tvguide1990{background:#d8d7d3;color:#000;font-family:'Times New Roman',serif;font-size:.9em;}
+body.tvguide1990 .header{background:#fff;color:#000;border-bottom:2px solid #000;box-shadow:none;}
+body.tvguide1990 .header .links > a,
+body.tvguide1990 .header .links>.dropdown>.dropbtn,
+body.tvguide1990 .header .links>span,
+body.tvguide1990 .header .links>#clock{color:#000!important;background:#fff!important;}
+body.tvguide1990 .header .links > a:hover,
+body.tvguide1990 .header .links > .dropdown:hover > .dropbtn{background:#e0e0e0!important;color:#000!important;}
+body.tvguide1990 .dropdown-content{background:#fff!important;color:#000!important;border:1px solid #000!important;box-shadow:none!important;}
+body.tvguide1990 .dropdown-content a{color:#000!important;}
+body.tvguide1990 .dropdown-content a:hover{background:#e0e0e0!important;color:#000!important;}
+body.tvguide1990 .submenu-content{background:#fff!important;border:1px solid #000!important;box-shadow:none!important;z-index:1000;}
+body.tvguide1990 .submenu-content li a{color:#000!important;}
+body.tvguide1990 .submenu-content li a:hover{background:#e0e0e0!important;color:#000!important;}
+body.tvguide1990 .dropdown-content .submenu>a{color:#000!important;padding-right:22px!important;position:relative;}
+body.tvguide1990 .dropdown-content .submenu>a::after{content:"โธ";position:absolute;right:10px;top:50%;transform:translateY(-50%);color:#000;}
+body.tvguide1990 .dropdown-content .submenu:hover>a{background:#e0e0e0!important;color:#000!important;}
+body.tvguide1990 .footer{background:transparent;color:#000;border-top:2px solid #000;}
+body.tvguide1990 .chan-name img{display:none!important;}
+body.tvguide1990 .chan-name span:not(.channel-number){display:none!important;}
+body.tvguide1990 .chan-col{height:38px!important;padding:2px 0!important;display:flex;align-items:center;justify-content:center;background:#d8d7d3;border-bottom:1px solid #000;}
+body.tvguide1990 .guide-row>.chan-col{border-right:1px solid #000!important;} /* vertical divider */
+body.tvguide1990 .channel-number{display:inline-flex;align-items:center;justify-content:center;font-family:'Arial Narrow','Helvetica Neue',sans-serif;font-weight:700;font-size:.9em;min-width:32px;padding:3px 12px;border-radius:999px;background:#000;color:#fff;letter-spacing:-.2px;border:1px solid #000;line-height:1;transform:scale(.9);}
+body.tvguide1990 .chan-header{border-bottom:none!important;}
+body.tvguide1990 .guide-row{align-items:stretch!important;}
+body.tvguide1990 .time-header-wrap{border-top:1px solid #000!important;border-bottom:1px solid #000!important;margin-bottom:0!important;padding-bottom:0!important;height:38px!important;box-sizing:border-box;}
+body.tvguide1990 .chan-col{height:38px!important;box-sizing:border-box;}
+body.tvguide1990 .grid-content,body.tvguide1990 .time-header{transform:scaleX(.9);transform-origin:left center;}
+body.tvguide1990 .time-cell{border-right:1px solid #000;color:#000;font-weight:700;font-size:.8em;padding:0 2px;min-width:0;}
+body.tvguide1990 .grid-row{height:38px!important;position:relative;padding:1px 0;box-sizing:border-box;border-bottom:1px solid #000;}
+body.tvguide1990 .program,
+body.tvguide1990 .program.now{position:absolute;top:1px!important;height:calc(100% - 2px)!important;margin:0!important;padding:2px 4px!important;border:1px solid #000;box-sizing:border-box;border-radius:0!important;line-height:1.2;font-size:.8em!important;display:flex;align-items:center;background:#fff;font-weight:400!important;}
+body.tvguide1990 .program.now{background:#e7e5dd!important;font-weight:700!important;}
+body.tvguide1990 .grid-col{overflow-x:auto!important;overflow-y:hidden!important;height:38px!important;box-sizing:border-box;}
+body.tvguide1990 .grid-content{height:100%!important;}
+body.tvguide1990 #clock::before{content:"IPTV GUIDE";display:inline-block;margin-right:10px;padding:4px 10px;background:#fff;color:#000;font-weight:900;font-size:12px;line-height:1;border-radius:50px;border:2px solid #000;box-shadow:none;letter-spacing:.3px;vertical-align:middle;}
+body.tvguide1990 #summary{border:2px solid #000;background:#fff;padding:12px;margin:10px;box-shadow:2px 2px 5px rgba(0,0,0,.25);}
+body.tvguide1990 #video{border:2px solid #000;background:#000;box-shadow:3px 3px 6px rgba(0,0,0,.35);}
+body.tvguide1990 .chan-col{position:relative;}
+body.tvguide1990 .chan-col::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:20px;height:60%;background:#000;border-right:1px solid #000;}
+body.tvguide1990 { --chan-col-width: 200px; --chan-col-height: 38px; }
+
+/* DirecTV */
+body.directv { --timebar-bg:#002b80; --timebar-border:#001f66; --timebar-color:#d8ebff; --chan-col-bg:#001b50; }
+body.directv .time-header-fixed .now-line { background:#ffcc00; }
+body.directv { --timebar-bg: #002b80; --timebar-border: #001f66; --timebar-color: #d8ebff; --now-line-color: #ffcc00; --chan-col-bg: #001b50; }
+body.directv { background:#049fff; color:#fff; font-family:"Segoe UI","Arial",sans-serif; }
+body.directv .header { background:linear-gradient(to bottom,#e7e4ff,#d2f3ff); border-bottom:2px solid #001f66; }
+body.directv .header .links > a, body.directv .header .links > .dropdown > .dropbtn, body.directv .header .links > span, body.directv .header .links > div#clock { color:#ffffff !important; font-weight:bold !important; text-shadow:1px 1px 2px #000 !important; }
+body.directv .header .links > a:hover, body.directv .header .links > .dropdown:hover > .dropbtn { background:#003f9e !important; color:#ffd802 !important; }
+body.directv .dropdown-content, body.directv .submenu-content { background:#003a8c !important; border:1px solid #0070ff !important; box-shadow:0 4px 8px rgba(0,0,0,.4) !important; }
+body.directv .dropdown-content a, body.directv .submenu-content li a { color:#ffffff !important; font-weight:bold !important; }
+body.directv .dropdown-content a:hover, body.directv .submenu-content li a:hover { background:#0070ff !important; color:#fff !important; }
+body.directv .dropdown-content .submenu > a { color:#fff !important; padding-right:28px !important; position:relative; }
+body.directv .dropdown-content .submenu > a::after { content:"โธ"; position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#fff; }
+body.directv .dropdown-content .submenu:hover > a { background:#0070ff !important; color:#fff !important; }
+body.directv .summary, body.directv #program-info { background:linear-gradient(to bottom,#66b2ff,#003f9e); color:#fff; border:1px solid #004bdb; }
+body.directv .time-header-wrap { background:#002b80; color:#d8ebff; border-bottom:2px solid #001f66; }
+body.directv .time-cell { background:#002b80; color:#b9dcff; border-color:#001f66; font-weight:bold; }
+body.directv .chan-col { background:#001f66; border-right:1px solid #004bdb; color:#fff; font-weight:bold; text-shadow:1px 1px 2px #000; }
+body.directv .chan-header { background:linear-gradient(to bottom,#0b3b8c,#3c8dff); color:#fff; border-color:#003b91; }
+body.directv .grid-col { background:#049fff; }
+body.directv .program { background:#001f66; border:1px solid #004bdb; color:#fff; border-radius:2px; }
+body.directv .program.now { background:#ffd802; border:1px solid #caa600; color:#000; font-weight:bold; box-shadow:none; }
+body.directv .program:hover { background:linear-gradient(to right,#1d67d9,#7fbfff); color:#fff; }
+body.directv .now-line { background:#ffcc00; }
+body.directv .footer { background:linear-gradient(to top,#001f66,#004bdb); color:#fff; border-top:2px solid #003580; padding:5px 10px; font-size:0.9em; }
+body.directv .footer .dot-red { color:#ff3c3c; } body.directv .footer .dot-green { color:#00cc00; } body.directv .footer .dot-yellow { color:#ffd700; }
+body.directv .footer .dot-blue { color:#4ca9ff; }
+body.directv #video { background:#000; border:2px solid #004bdb; box-shadow:0 0 6px rgba(0,0,0,0.6); }
+body.directv #clock {
+ color: #ffffff !important;
+ font-weight: 800 !important;
+ font-size: 0.95rem;
+ display: inline-flex;
+ align-items: center;
+ height: 40px;
+ line-height: 40px;
+ padding: 0 12px;
+ /* same shadow used for header links to match look */
+ text-shadow: 1px 1px 2px #000 !important;
+ -webkit-text-stroke: 0.5px rgba(0,0,0,0.9); /* subtle extra crispness where supported */
+}
+body.directv #clock::before { content:"IPTV GUIDE"; display:inline-block; margin-right:10px; padding:3px 8px; background:#e41e26; color:#fff; font-weight:900; font-size:12px; line-height:1; border-radius:3px; border:1px solid rgba(255,255,255,.85); box-shadow:0 1px 2px rgba(0,0,0,.35); letter-spacing:.4px; vertical-align:middle; }
+body.directv .summary,body.directv #program-info{background:linear-gradient(to bottom,#66b2ff,#003f9e);color:#fff;border:2px solid #004bdb;padding:12px;border-radius:6px;box-sizing:border-box;box-shadow:4px 4px 0 rgba(0,0,0,0.12);line-height:1.35}
+body.directv .summary h3,body.directv #program-info h3{margin:0 0 6px 0;font-size:1.05em;font-weight:700;color:inherit}
+body.directv .summary p,body.directv #program-info p{margin:0;color:inherit}
+
+/* Comcast */
+body.comcast { --timebar-bg:linear-gradient(to bottom,#0055cc,#001b50); --timebar-border:#003090; --timebar-color:#fff; --chan-col-bg:#001b50; }
+body.comcast .time-header-fixed .now-line { background:#ffcc00; }
+body.comcast { --timebar-bg: linear-gradient(to bottom,#0055cc,#001b50); --timebar-border: #003090; --timebar-color: #fff; --now-line-color: #ffcc00; --chan-col-bg: #001b50; }
+body.comcast { background:#1a3666; color:#fff; font-family:"Segoe UI","Arial",sans-serif; }
+body.comcast .header { background:linear-gradient(to bottom,#7493c2,#38608e); border-bottom:2px solid #003090; }
+body.comcast .header .links > a, body.comcast .header .links > .dropdown > .dropbtn, body.comcast .header .links > span, body.comcast .header .links > div#clock { color:#ffffff !important; font-weight:bold !important; text-shadow:1px 1px 2px #000 !important; }
+body.comcast .header .links > a:hover, body.comcast .header .links > .dropdown:hover > .dropbtn { background:#003890 !important; color:#ffcc00 !important; }
+body.comcast .dropdown-content, body.comcast .submenu-content { background:#002a70 !important; border:1px solid #0044cc !important; box-shadow:0 4px 8px rgba(0,0,0,.4) !important; }
+body.comcast .dropdown-content a, body.comcast .submenu-content li a { color:#ffffff !important; font-weight:bold !important; }
+body.comcast .dropdown-content a:hover, body.comcast .submenu-content li a:hover { background:#0044cc !important; color:#fff !important; }
+body.comcast .summary, body.comcast #program-info { background:linear-gradient(to bottom,#7493c2,#38608e); color:#fff; border:1px solid #003090; }
+body.comcast .time-header-wrap { background:#003890; color:#bcd8ff; border-bottom:2px solid #002b80; }
+body.comcast .time-cell { background:#003890; color:#ffffff; border-color:#002b80; font-weight:bold; }
+body.comcast .chan-col { background:#001b50; border-right:1px solid #0044cc; color:#fff; font-weight:bold; text-shadow:1px 1px 2px #000; }
+body.comcast .chan-header { background:#002a70; color:#fff; border-color:#0044cc; }
+body.comcast .grid-col { background:#193564; }
+body.comcast .program { background:#003890; border:1px solid #0044cc; color:#fff; border-radius:2px; }
+body.comcast .program.now { background:#ffffff; border:1px solid #cccccc; color:#000; font-weight:bold; }
+body.comcast .program:hover { background:#0044cc; color:#fff; }
+body.comcast .now-line { background:#ffcc00; }
+body.comcast #video { background:#000; border:2px solid #0044cc; box-shadow:0 0 8px rgba(0,0,0,.6); }
+body.comcast .footer { background:linear-gradient(to top,#001b50,#0044cc); color:#fff; border-top:2px solid #002b80; padding:5px 10px; font-size:0.9em; }
+body.comcast .footer .dot-red { color:#ff3c3c; } body.comcast .footer .dot-green { color:#00cc00; } body.comcast .footer .dot-yellow { color:#ffd700; }
+body.comcast .footer .dot-blue { color:#4ca9ff; }
+body.comcast #clock {
+ color: #ffffff !important;
+ font-weight: 800 !important;
+ font-size: 0.95rem;
+ display: inline-flex;
+ align-items: center;
+ height: 40px;
+ line-height: 40px;
+ padding: 0 12px;
+ /* same shadow used for header links to match look */
+ text-shadow: 1px 1px 2px #000 !important;
+ -webkit-text-stroke: 0.5px rgba(0,0,0,0.9); /* subtle extra crispness where supported */
+}
+body.comcast #clock::before { content:"IPTV GUIDE"; display:inline-block; margin-right:10px; padding:3px 8px; background:#e41e26; color:#fff; font-weight:900; font-size:12px; line-height:1; border-radius:3px; border:1px solid rgba(255,255,255,.85); box-shadow:0 1px 2px rgba(0,0,0,.35); letter-spacing:.4px; vertical-align:middle; }
+body.comcast .summary,body.comcast #program-info{background:linear-gradient(to bottom,#7493c2,#38608e);color:#fff;border:2px solid #003090;padding:12px;border-radius:6px;box-sizing:border-box;box-shadow:4px 4px 0 rgba(0,0,0,0.12);line-height:1.35}
+body.comcast .summary h3,body.comcast #program-info h3{margin:0 0 6px 0;font-size:1.05em;font-weight:700;color:inherit}
+body.comcast .summary p,body.comcast #program-info p{margin:0;color:inherit}
+
+/* Retro TV Guide Magazine Theme */
+body.retro-magazine{--panel-bg-top:#ffffff;--panel-bg-bottom:#f2f2f2;--now-line-panel-color:#fff;--now-line-glow:rgba(0,0,0,0.22);background:#fff;color:#000;font-family:"Times New Roman","Georgia",serif}
+body.retro-magazine .summary{background:linear-gradient(to bottom,var(--panel-bg-top) 0%,var(--panel-bg-bottom) 100%);color:#000;border:2px solid #000;border-top-color:#000;border-left-color:#000;border-right-color:#000;border-bottom-color:#000;box-shadow:4px 4px 0 rgba(0,0,0,0.22);padding:12px;border-radius:6px;box-sizing:border-box}
+body.retro-magazine .summary h3{margin:0 0 6px 0;color:#000;font-size:1.05em;font-weight:700}
+body.retro-magazine .summary p{margin:0;color:#111;line-height:1.35}
+body.retro-magazine .time-header-fixed .now-line,body.retro-magazine .now-line{background:var(--now-line-panel-color);box-shadow:0 0 6px var(--now-line-glow);width:3px}
+body.retro-magazine { background:#ffffff; color:#000000; font-family:"Times New Roman","Georgia",serif; }
+body.retro-magazine .header { background:#ffffff; color:#000; border-bottom:2px solid #000; font-weight:bold; }
+body.retro-magazine .header .links > a,
+body.retro-magazine .header .links > .dropdown > .dropbtn,
+body.retro-magazine .header .links > span,
+body.retro-magazine .header .links > #clock { background:#ffffff !important; color:#000000 !important; }
+body.retro-magazine .header .links > a:hover,
+body.retro-magazine .header .links > .dropdown:hover > .dropbtn { background:#e0e0e0 !important; color:#000000 !important; }
+body.retro-magazine .dropdown-content { background:#ffffff !important; color:#000000 !important; border:1px solid #000 !important; box-shadow:none !important; }
+body.retro-magazine .dropdown-content a { background:#ffffff !important; color:#000000 !important; }
+body.retro-magazine .dropdown-content a:hover { background:#e0e0e0 !important; color:#000000 !important; }
+body.retro-magazine #video { background:#000; }
+body.retro-magazine .chan-col { background:#fff; border:1px solid #000; color:#000; font-weight:bold; }
+body.retro-magazine .grid-col { background:#fff; }
+body.retro-magazine .chan-header,
+body.retro-magazine .time-header-wrap { background:#fff; color:#fff; border:1px solid #000; font-weight:bold; }
+body.retro-magazine .time-cell { color:#000; border:1px solid #000; font-weight:bold; }
+body.retro-magazine .chan-name { color:#000; border:1px solid #000; font-weight:bold; }
+body.retro-magazine .program { background:#fff; border:1px solid #000; color:#000; font-size:14px; }
+body.retro-magazine .program.now { background:#e0e0e0; border:2px solid #000; color:#000; font-weight:bold; }
+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; }
+
+/* Additional theme-specific overrides kept (no truncation) */
+
+/* Ensure layering & z-index helpers */
+.header { position: relative; z-index: 2000; }
+.dropdown-content, .submenu-content { position: absolute; z-index: 2100; }
+.dropdown-content .submenu, .submenu-content .submenu { z-index: 2200; }
+.time-header-fixed { z-index: 1200; }
+.time-header-fixed .now-line { z-index: 1250; }
+
+/* Programs must be below the fixed timebar */
+.program { z-index: 1; position: absolute; }
+
+/* Ensure the guide and channels panes can scroll vertically */
+.guide, .channels {
+ overflow-y: auto; /* allow vertical scrolling */
+ scroll-behavior: auto; /* avoid CSS smooth interfering with per-frame moves */
+ -webkit-overflow-scrolling: touch; /* iOS momentum */
+}
+
+/* Important: the scroller needs a fixed height (or max-height) to enable scrolling. */
+.guide, .channels {
+ max-height: 60vh; /* or height: calc(100vh - 120px); */
+}
+
+/* transition helper */
+body { transition: background-color .3s ease,color .3s ease; }
+body.fade-switch { opacity:0; transition:opacity .25s ease; }
+
+/* End of base.css */
diff --git a/static/css/change_password.css b/static/css/change_password.css
new file mode 100644
index 0000000..f489616
--- /dev/null
+++ b/static/css/change_password.css
@@ -0,0 +1,89 @@
+/* Per-page styles for change_password (keeps the original compact centered form look) */
+
+/* Container centers the single panel */
+.container-change-password {
+ max-width: 360px;
+ margin: 60px auto 80px;
+ padding: 0 12px;
+ box-sizing: border-box;
+}
+
+/* Panel (card) */
+.container-change-password .box {
+ margin: 0;
+ padding: 18px 18px;
+ border-radius: 8px;
+ width: 100%;
+ box-sizing: border-box;
+ background: rgba(0,0,0,0.12);
+ border: 1px solid rgba(255,255,255,0.03);
+ box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset, 0 6px 12px rgba(0,0,0,0.12);
+}
+
+/* Heading */
+.container-change-password .box h2 {
+ margin: 0 0 12px;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: inherit;
+}
+
+/* Inputs */
+.container-change-password input[type="password"] {
+ width: 100%;
+ padding: 8px 10px;
+ margin: 6px 0;
+ border-radius: 4px;
+ border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03);
+ color: inherit;
+ box-sizing: border-box;
+ outline: none;
+}
+
+/* Light-theme overrides */
+body.light .container-change-password .box {
+ background: #f3f3f3;
+ border-color: rgba(0,0,0,0.06);
+}
+body.light .container-change-password input {
+ background: #fff;
+ color: #000;
+ border-color: #aaa;
+}
+
+/* Focus */
+.container-change-password input:focus {
+ border-color: rgba(30,211,206,0.95);
+ box-shadow: 0 0 0 4px rgba(30,211,206,0.06);
+}
+
+/* Button */
+.container-change-password button.primary {
+ width: 100%;
+ padding: 10px;
+ margin-top: 10px;
+ border-radius: 4px;
+ border: none;
+ background: #1ed3ce;
+ color: #002e2c;
+ font-weight: 700;
+ cursor: pointer;
+ box-shadow: 0 2px 0 rgba(0,0,0,0.12);
+}
+
+/* Button hover */
+.container-change-password button.primary:hover {
+ transform: translateY(-1px);
+ background: #16cfc9;
+}
+
+/* Flash messages */
+.flash-messages { margin-top: 12px; }
+.flash { color: #ffd; background: transparent; margin: 6px 0; }
+
+/* Accessibility / small screens */
+@media (max-width: 480px) {
+ .container-change-password { padding: 0 14px; }
+ .container-change-password .box { padding: 14px; }
+}
diff --git a/static/css/change_tuner.css b/static/css/change_tuner.css
new file mode 100644
index 0000000..15943ba
--- /dev/null
+++ b/static/css/change_tuner.css
@@ -0,0 +1,134 @@
+/* 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.
+*/
+
+/* Layout: center column and stack boxes */
+.container-change-tuner {
+ max-width: 520px;
+ margin: 28px auto 80px;
+ padding: 0 12px;
+ box-sizing: border-box;
+}
+
+/* Panel (card) used for each section */
+.box {
+ 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 */
+ 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);
+}
+
+/* Headings */
+.box h2 {
+ margin: 0 0 12px;
+ font-size: 1.35rem;
+ font-weight: 700;
+ color: #fff;
+ text-shadow: 0 1px 0 rgba(0,0,0,0.25);
+}
+
+/* Labels */
+.box label {
+ display: block;
+ font-size: 0.9rem;
+ color: rgba(255,255,255,0.9);
+ margin: 8px 0 6px;
+}
+
+/* Form rows - stack fields with gap */
+.form-row {
+ display: block;
+ margin-bottom: 10px;
+}
+
+/* Inputs & selects: full width, teal border, rounded */
+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;
+ 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); }
+
+/* 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;
+}
+
+/* Primary button โ full width teal pill */
+button.primary, input[type="submit"].primary {
+ display: block;
+ width: 100%;
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: #1ed3ce;
+ color: #002e2c;
+ font-weight: 700;
+ border: none;
+ cursor: pointer;
+ box-shadow: 0 2px 0 rgba(0,0,0,0.12);
+ transition: transform .06s ease, box-shadow .06s ease, background .08s ease;
+}
+
+/* Hover / active */
+button.primary:hover { transform: translateY(-1px); background: #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) */
+.btn-inline {
+ 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);
+ cursor: pointer;
+ font-weight: 600;
+ margin-left: 8px;
+}
+
+/* Tuning select & inputs layout in a row for small screens only: vertical stack on mobile */
+.row-inline {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+.row-inline .row-item {
+ flex: 1 1 auto;
+}
+
+/* Small screens: keep stacked */
+@media (max-width: 640px) {
+ .container-change-tuner { padding: 0 18px; max-width: 100%; }
+ .row-inline { flex-direction: column; align-items: stretch; }
+}
+
+/* Additional spacing for the delete / rename sections to match original spacing */
+.box .sub-heading {
+ margin-top: 12px;
+ margin-bottom: 8px;
+ 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; }
+
+/* small helper to hide an element if needed */
+.hidden { display: none !important; }
diff --git a/static/css/logs.css b/static/css/logs.css
new file mode 100644
index 0000000..ea8bb77
--- /dev/null
+++ b/static/css/logs.css
@@ -0,0 +1,104 @@
+/* Per-page styles for logs.html โ keeps the original log table look, scoped to this page */
+
+/* Outer container centers and sizes the panel */
+.container-logs {
+ max-width: 900px;
+ margin: 60px auto;
+ padding: 10px;
+ box-sizing: border-box;
+}
+
+/* Panel box */
+.container-logs .box {
+ padding: 18px;
+ border-radius: 8px;
+ background: rgba(0,0,0,0.06);
+ border: 1px solid rgba(255,255,255,0.03);
+ box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset;
+}
+
+/* Heading */
+.container-logs h2 {
+ margin: 0 0 12px;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: inherit;
+}
+
+/* Clear button */
+.clear-form { text-align: center; margin-bottom: 12px; }
+.btn-clear {
+ padding: 6px 14px;
+ border: none;
+ border-radius: 4px;
+ background: #c00;
+ color: #fff;
+ font-weight: 700;
+ cursor: pointer;
+}
+.btn-clear:hover { opacity: 0.95; transform: translateY(-1px); }
+
+/* Filter bar */
+.filter-bar { text-align:center; margin: 8px 0 12px; }
+.filter-btn {
+ padding: 6px 12px;
+ margin: 0 6px;
+ border: none;
+ border-radius: 4px;
+ background: rgba(0,0,0,0.45);
+ color: #fff;
+ font-weight: 700;
+ cursor: pointer;
+}
+.filter-btn:hover { background: #0af; color: #002; }
+
+/* Table wrapper: allow horizontal scroll on narrow screens */
+.table-wrap { overflow-x: auto; }
+
+/* Logs table */
+.logs-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 8px;
+ font-size: 0.95rem;
+}
+.logs-table thead th {
+ background: rgba(0,0,0,0.6);
+ color: #fff;
+ padding: 8px;
+ text-align: left;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+.logs-table tbody td {
+ padding: 8px;
+ border-bottom: 1px solid rgba(255,255,255,0.06);
+}
+
+/* Row striping */
+.logs-table tbody tr:nth-child(even) { background: rgba(0,0,0,0.08); }
+
+/* Security highlight (high contrast) */
+.logs-table tbody tr.security, .security {
+ background: rgba(200,30,30,0.12);
+ color: #ffbaba;
+ font-weight: 700;
+}
+
+/* Theme-aware overrides: dark and light inherit base styles, but adjust backgrounds */
+body.dark .container-logs .box { background: #222; border-color: rgba(255,255,255,0.03); }
+body.dark .logs-table thead th { background: #333; }
+body.dark .logs-table tbody tr:nth-child(even) { background: #222; color: #fff; }
+
+body.light .container-logs .box { background: #f3f3f3; border-color: rgba(0,0,0,0.06); color: #000; }
+body.light .logs-table thead th { background: #ddd; color: #000; }
+body.light .logs-table tbody tr:nth-child(even) { background: #fff; color: #000; }
+body.retro-magazine .logs-table thead th { background: #000; color: #fff; }
+
+/* Small screens */
+@media (max-width: 640px) {
+ .container-logs { padding: 8px; margin: 40px 12px; }
+ .filter-btn { padding: 8px 10px; margin: 4px; }
+ .logs-table thead th, .logs-table tbody td { padding: 8px 6px; font-size: 0.92rem; }
+}
diff --git a/static/css/manage_users.css b/static/css/manage_users.css
new file mode 100644
index 0000000..062b4af
--- /dev/null
+++ b/static/css/manage_users.css
@@ -0,0 +1,101 @@
+/* Per-page styles for Manage Users page โ scoped to this page */
+
+/* Outer container to center content */
+.container-manage-users {
+ max-width: 720px;
+ margin: 60px auto;
+ padding: 12px;
+ box-sizing: border-box;
+}
+
+/* Panel */
+.container-manage-users .box {
+ padding: 18px;
+ border-radius: 8px;
+ background: rgba(0,0,0,0.06);
+ border: 1px solid rgba(255,255,255,0.03);
+ box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset;
+}
+
+/* Headings */
+.container-manage-users h2 { margin: 0 0 12px; font-size:1.25rem; font-weight:700; color:inherit; }
+.container-manage-users h3 { margin: 10px 0; font-size:1rem; color:inherit; }
+
+/* Form rows */
+.form-row { margin-bottom: 8px; }
+
+/* Inputs / selects */
+.container-manage-users input[type="text"],
+.container-manage-users input[type="password"],
+.container-manage-users select {
+ width: 100%;
+ padding: 8px 10px;
+ border-radius: 4px;
+ border: 1px solid rgba(255,255,255,0.06);
+ background: rgba(255,255,255,0.03);
+ color: inherit;
+ box-sizing: border-box;
+ outline: none;
+}
+.container-manage-users input:focus {
+ border-color: rgba(30,211,206,0.95);
+ box-shadow: 0 0 0 4px rgba(30,211,206,0.06);
+}
+
+/* Buttons */
+.btn { padding: 8px 12px; border-radius:4px; border:none; cursor:pointer; font-weight:700; }
+.btn.add { background: #0a0; color: #fff; display: inline-block; }
+.btn.add:hover { background: #070; }
+.btn.danger { background: #c00; color: #fff; }
+.btn.danger:hover { background: #900; }
+.btn.warn { background: #d2691e; color: #fff; }
+.btn.warn:hover { background: #a0522d; }
+
+/* For the add user button make it full width in the form */
+.add-user-form .btn.add { width: 100%; }
+
+/* Table */
+.table-wrap { overflow-x: auto; margin-top: 12px; }
+.users-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
+.users-table thead th { background: rgba(0,0,0,0.6); color: #fff; padding: 8px; text-align: left; position: sticky; top: 0; z-index: 5; }
+.users-table tbody td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: middle; }
+
+/* Actions cell: inline forms */
+.actions { display:flex; gap:8px; }
+
+/* Inline form reset so buttons sit next to each other */
+.inline-form { display:inline; margin:0; padding:0; }
+
+/* Flash messages */
+.flash-messages { margin-top: 12px; }
+.flash { color: #ffd; background: transparent; margin: 6px 0; }
+
+/* Theme overrides */
+body.dark .container-manage-users .box { background: #222; border-color: rgba(255,255,255,0.03); }
+body.dark .users-table thead th { background: #333; color: #fff; }
+body.dark .users-table tbody tr:nth-child(even) { background: #222; color: #fff; }
+
+body.light .container-manage-users .box { background: #f3f3f3; border-color: rgba(0,0,0,0.06); color: #000; }
+body.light .users-table thead th { background: #ddd; color: #000; }
+body.light .users-table tbody tr:nth-child(even) { background: #fff; color: #000; }
+
+body.retro-aol .container-manage-users .box {
+ padding: 18px;
+ border-radius: 8px;
+ background: #004466;
+ border:2px solid #33cccc;
+ box-shadow: 0 2px 0 rgba(255,255,255,0.02) inset;
+}
+
+body.retro-magazine .container-manage-users .box { border:2px solid #000000; }
+
+body.directv .container-manage-users .box { background:linear-gradient(to bottom,#e7e4ff,#d2f3ff); border-bottom:2px solid #003090; }
+
+body.comcast .container-manage-users .box { background:linear-gradient(to bottom,#0055cc,#001b50); border-bottom:2px solid #003090; }
+
+/* Responsive */
+@media (max-width: 640px) {
+ .container-manage-users { margin: 40px 12px; padding: 8px; }
+ .actions { flex-direction: column; gap: 6px; }
+ .users-table thead th, .users-table tbody td { padding: 8px 6px; font-size: 0.92rem; }
+}
diff --git a/static/js/auto-scroll.js b/static/js/auto-scroll.js
new file mode 100644
index 0000000..3d7869d
--- /dev/null
+++ b/static/js/auto-scroll.js
@@ -0,0 +1,448 @@
+// auto-scroll v36.3 โ 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).
+// - Exposes status and cloneNow APIs.
+
+(function () {
+ const PREF_KEY = 'autoScrollEnabled';
+ function prefEnabled() { return localStorage.getItem(PREF_KEY) !== 'false'; }
+ function setPref(v) { localStorage.setItem(PREF_KEY, v ? 'true' : 'false'); }
+
+ const SELECTOR_PRIORITY = ['#guideOuter', '.guide-outer', '.grid-col'];
+ const scrollSpeed = 1.2; // px per frame (visual)
+ const idleDelay = 15000; // ms initial inactivity/start delay (15s)
+ const waitForContentMs = 5000; // wait up to 5s for rows to be populated before cloning
+ const contentSampleCount = 3; // sample when checking readiness
+
+ const PROGRAM_CELL_SELECTORS = [
+ '.programme', '.program', '.prog', '.prog-col', '.prog-cell', '.program-cell',
+ '.title', '.epg-item', '.time', '.programme-item', '.programs', '.epg'
+ ];
+
+ let scroller = null;
+ let rafId = null;
+ let isScrolling = false;
+ let lastActivity = Date.now();
+ let idleInterval = null;
+ let watchdogInterval = null;
+
+ // timestamps for watchdog: last time frameLoop actually ran
+ let lastFrameTime = 0;
+
+ let loopMode = true;
+ let endReached = false;
+ let endReachedAt = 0;
+ let autoRestart = false;
+ let autoRestartDelayMs = 30000;
+
+ function log(...args) {
+ if (window && window.console && console.debug) console.debug.apply(console, ['[auto-scroll v36.3]'].concat(args));
+ }
+
+ function findScroller() {
+ const nodes = SELECTOR_PRIORITY.map(s => Array.from(document.querySelectorAll(s))).flat();
+ if (!nodes.length) return null;
+ let best = null;
+ nodes.forEach(n => {
+ try {
+ const delta = Math.max(0, n.scrollHeight - n.clientHeight);
+ if (!best || delta > best.delta) best = { el: n, delta };
+ } catch (e) {}
+ });
+ return best ? best.el : null;
+ }
+
+ function ensureStyles(el) {
+ try {
+ const cs = getComputedStyle(el);
+ if (!/(auto|scroll)/.test(cs.overflowY)) el.style.overflowY = 'auto';
+ if (!el.style.maxHeight && cs.maxHeight === 'none') {
+ el.style.maxHeight = 'calc(100vh - 420px)';
+ }
+ el.style.scrollBehavior = 'auto';
+ } catch (e) {}
+ }
+
+ function supportsNativeSmoothScroll() {
+ try { return 'scrollBehavior' in document.documentElement.style; } catch (e) { return false; }
+ }
+
+ function smoothScrollTo(el, targetTop, duration = 650) {
+ if (!el) return Promise.resolve();
+ if (supportsNativeSmoothScroll()) {
+ try {
+ el.scrollTo({ top: targetTop, behavior: 'smooth' });
+ return new Promise(resolve => setTimeout(resolve, duration));
+ } catch (e) {}
+ }
+ return new Promise(resolve => {
+ const start = el.scrollTop;
+ const change = targetTop - start;
+ const startTime = performance.now();
+ const dur = Math.max(1, duration);
+ const ease = t => (t < 0.5) ? (2 * t * t) : (-1 + (4 - 2 * t) * t);
+ function step(now) {
+ const elapsed = now - startTime;
+ const t = Math.min(1, elapsed / dur);
+ try { el.scrollTop = start + change * ease(t); } catch (e) {}
+ if (t < 1) requestAnimationFrame(step);
+ else resolve();
+ }
+ requestAnimationFrame(step);
+ });
+ }
+
+ function markClone(node, srcId) {
+ try {
+ node.classList.add('__auto_scroll_clone');
+ node.dataset.autoScrollClone = '1';
+ if (srcId) node.dataset.autoScrollSrcid = srcId;
+ Array.from(node.querySelectorAll('[id]')).forEach(el => el.removeAttribute('id'));
+ } catch (e) {}
+ }
+
+ function getOriginalRows(sc) {
+ try {
+ const chanCols = Array.from(sc.querySelectorAll('.chan-col'));
+ const rowsSet = new Set();
+ if (chanCols.length) {
+ chanCols.forEach(c => {
+ const row = c.closest('.guide-row') || c.parentElement;
+ if (row && row.nodeType === 1) rowsSet.add(row);
+ });
+ } else {
+ Array.from(sc.children).forEach(ch => { if (ch && ch.nodeType === 1) rowsSet.add(ch); });
+ }
+ return Array.from(rowsSet).filter(r => !(r.dataset && (r.dataset.autoScrollClone === '1' || r.dataset.__autoScrollClone === '1')));
+ } catch (e) {
+ return [];
+ }
+ }
+
+ function rowHasContent(r) {
+ if (!r) return false;
+ const name = r.querySelector && r.querySelector('.chan-name');
+ if (name && name.textContent && name.textContent.trim().length) return true;
+ for (const sel of PROGRAM_CELL_SELECTORS) {
+ const el = r.querySelector(sel);
+ if (el && el.textContent && el.textContent.trim().length) return true;
+ }
+ const txt = r.textContent || '';
+ return txt.trim().length > 4;
+ }
+
+ function scrollerHasProgramInfo(sc) {
+ try {
+ for (const sel of PROGRAM_CELL_SELECTORS) {
+ const el = sc.querySelector(sel);
+ if (el && el.textContent && el.textContent.trim().length > 0) return true;
+ }
+ if ((sc.innerText || sc.textContent || '').trim().length > 60) return true;
+ } catch (e) {}
+ return false;
+ }
+
+ function waitForProgramInfo(sc, timeoutMs = 4000) {
+ return new Promise(resolve => {
+ const start = Date.now();
+ const check = () => {
+ try {
+ if (scrollerHasProgramInfo(sc)) return resolve(true);
+ if (Date.now() - start >= timeoutMs) return resolve(false);
+ } catch (e) { return resolve(false); }
+ setTimeout(check, 150);
+ };
+ check();
+ });
+ }
+
+ function waitForContent(sc, timeoutMs = waitForContentMs, sampleCount = contentSampleCount) {
+ return new Promise(resolve => {
+ const start = Date.now();
+ const check = () => {
+ const rows = getOriginalRows(sc);
+ let ok = false;
+ for (let i = 0; i < Math.min(sampleCount, rows.length); i++) {
+ if (rowHasContent(rows[i])) { ok = true; break; }
+ }
+ if (ok) return resolve(true);
+ if (Date.now() - start >= timeoutMs) return resolve(false);
+ setTimeout(check, 150);
+ };
+ check();
+ });
+ }
+
+ // Clone rows (full row elements). Returns Promise resolved once clones are added and prep offset stored.
+ function cloneOnce(sc) {
+ return new Promise(resolve => {
+ try {
+ if (!sc) return resolve(0);
+ if (sc.dataset.__autoScrollCloned === '1') return resolve(0);
+
+ const originals = getOriginalRows(sc);
+ if (!originals.length) { sc.dataset.__autoScrollCloned = '1'; return resolve(0); }
+
+ originals.forEach((orig, idx) => {
+ if (!orig.dataset.autoScrollSrcid) orig.dataset.autoScrollSrcid = 'asrc-' + idx + '-' + Date.now();
+ });
+
+ let rowHeight = originals[0].getBoundingClientRect().height || originals[0].offsetHeight || 40;
+ if (!isFinite(rowHeight) || rowHeight <= 0) rowHeight = 40;
+ const visibleRows = Math.max(1, Math.ceil(sc.clientHeight / rowHeight));
+ const clonesPerSide = visibleRows + 1;
+
+ const total = originals.length;
+ const left = Math.min(clonesPerSide, total);
+ const right = Math.min(clonesPerSide, total);
+
+ const leftClones = [];
+ for (let i = 0; i < left; i++) {
+ const srcRow = originals[total - 1 - i];
+ if (!srcRow) break;
+ const cloneRow = srcRow.cloneNode(true);
+ try { cloneRow.innerHTML = srcRow.innerHTML; } catch (e) {}
+ markClone(cloneRow, srcRow.dataset.autoScrollSrcid);
+ leftClones.push(cloneRow);
+ }
+
+ let prependedHeight = 0;
+ for (let i = leftClones.length - 1; i >= 0; i--) sc.insertBefore(leftClones[i], sc.firstChild);
+ for (let i = 0; i < leftClones.length; i++) {
+ const h = leftClones[i].getBoundingClientRect().height || leftClones[i].offsetHeight || 0;
+ prependedHeight += h;
+ }
+
+ for (let i = 0; i < right; i++) {
+ const srcRow = originals[i];
+ if (!srcRow) break;
+ const cloneRow = srcRow.cloneNode(true);
+ try { cloneRow.innerHTML = srcRow.innerHTML; } catch (e) {}
+ markClone(cloneRow, srcRow.dataset.autoScrollSrcid);
+ sc.appendChild(cloneRow);
+ }
+
+ if (prependedHeight > 0) {
+ try { sc.scrollTop = Number(prependedHeight) || 0; } catch (e) {}
+ }
+
+ // store and mark after waiting for programs (best-effort)
+ waitForProgramInfo(sc, 4000).then(found => {
+ try {
+ sc.dataset.__autoScrollPrependedHeight = String(prependedHeight || 0);
+ sc.dataset.__autoScrollCloned = '1';
+ } catch (e) {}
+ log('cloneOnce: prepended', leftClones.length, 'and appended', right, 'prependedHeight=' + prependedHeight, 'programsDetected=' + !!found);
+ resolve(leftClones.length + right);
+ }).catch(() => {
+ try {
+ sc.dataset.__autoScrollPrependedHeight = String(prependedHeight || 0);
+ sc.dataset.__autoScrollCloned = '1';
+ } catch (e) {}
+ log('cloneOnce: program wait failed; proceeding. prependedHeight=' + prependedHeight);
+ resolve(leftClones.length + right);
+ });
+
+ } catch (e) {
+ log('cloneOnce error', e);
+ resolve(0);
+ }
+ });
+ }
+
+ // Dispatch synthetic events to trigger program-updaters that listen for hover/focus.
+ function refreshProgramInfoForVisible(sc) {
+ try {
+ if (!sc) return;
+ const rows = getOriginalRows(sc);
+ if (!rows.length) return;
+ const scRect = sc.getBoundingClientRect();
+ let target = rows.find(r => {
+ const rect = r.getBoundingClientRect();
+ return (rect.top >= scRect.top - 2 && rect.top <= scRect.bottom + 2);
+ });
+ if (!target) target = rows[0];
+ if (!target) return;
+ ['mouseenter', 'mouseover', 'focus', 'mousemove'].forEach(type => {
+ try { target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); } catch (e) {}
+ });
+ try { target.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); } catch (e) {}
+ log('refreshProgramInfoForVisible: dispatched events on', target.dataset && target.dataset.autoScrollSrcid ? target.dataset.autoScrollSrcid : target);
+ } catch (e) { log('refreshProgramInfoForVisible error', e); }
+ }
+
+ // Start the watchdog interval that nudges scrollTop if RAF isn't advancing.
+ function startWatchdog() {
+ stopWatchdog();
+ watchdogInterval = setInterval(() => {
+ try {
+ if (!isScrolling || !scroller) return;
+ const now = performance.now();
+ // If RAF hasn't run in last 250ms, nudge scrollTop a tiny amount
+ if (now - lastFrameTime > 250) {
+ try { scroller.scrollTop = (scroller.scrollTop || 0) + scrollSpeed; } catch (e) {}
+ // update lastFrameTime so we don't double-nudge
+ lastFrameTime = now;
+ }
+ } catch (e) {}
+ }, 150);
+ }
+ function stopWatchdog() { try { if (watchdogInterval) { clearInterval(watchdogInterval); watchdogInterval = null; } } catch (e) {} }
+
+ // frameLoop: primary RAF animation; updates lastFrameTime on each run
+ function frameLoop() {
+ if (!isScrolling) return;
+ 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;
+ if (scroller.scrollTop >= maxScroll - 0.5) {
+ if (scroller.dataset.__autoScrollCloned === '1') {
+ // immediate deterministic wrap to prep (avoid race)
+ const prep = Number(scroller.dataset.__autoScrollPrependedHeight) || 0;
+ log('wrap detected. cur', scroller.scrollTop, 'max', maxScroll, 'prep', prep);
+ try { scroller.scrollTop = prep; } catch (e) {}
+ // small tick then refresh and resume
+ setTimeout(() => {
+ try { refreshProgramInfoForVisible(scroller); } catch (e) {}
+ }, 60);
+ // continue RAF animation after small delay
+ setTimeout(() => { if (prefEnabled()) { if (!isScrolling) isScrolling = true; rafId = requestAnimationFrame(frameLoop); } }, 120);
+ return;
+ } else {
+ if (loopMode) {
+ log('wrap reached but no clones present -> attempting cloneOnce() now');
+ cloneOnce(scroller).then(() => { setTimeout(() => { if (prefEnabled()) startDrift(); }, 80); }).catch(() => { scroller.scrollTop = maxScroll; stopDrift('end-reached'); });
+ return;
+ } else {
+ scroller.scrollTop = maxScroll;
+ stopDrift('end-reached');
+ return;
+ }
+ }
+ }
+ }
+ } catch (e) {}
+ rafId = requestAnimationFrame(frameLoop);
+ }
+
+ // Start RAF + watchdog
+ function startAnimation() {
+ // avoid double-start
+ if (isScrolling) return;
+ isScrolling = true;
+ lastFrameTime = performance.now();
+ if (rafId) cancelAnimationFrame(rafId);
+ rafId = requestAnimationFrame(frameLoop);
+ startWatchdog();
+ log('animation started (RAF + watchdog)');
+ }
+
+ function stopAnimation() {
+ try { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } catch (e) {}
+ stopWatchdog();
+ isScrolling = false;
+ log('animation stopped');
+ }
+
+ // start/resume auto-scroll drift (cloning step included)
+ function startDrift() {
+ if (!prefEnabled()) { log('pref disabled - not starting'); return; }
+ endReached = false;
+ endReachedAt = 0;
+
+ if (!scroller) {
+ scroller = findScroller();
+ if (!scroller) { log('no scroller found'); return; }
+ ensureStyles(scroller);
+ }
+
+ 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
+ setTimeout(() => startAnimation(), 60);
+ });
+ return;
+ }
+
+ // clones already present or no cloning needed
+ startAnimation();
+ }
+
+ function stopDrift(reason) {
+ if (!isScrolling) { log('stop called (no-op)', reason || ''); return; }
+ stopAnimation();
+ if (reason === 'end-reached') {
+ endReached = true;
+ endReachedAt = Date.now();
+ log('end reached: preventing auto-restart until cleared at', endReachedAt);
+ }
+ log('auto-scroll stopped', reason || '');
+ }
+
+ function onInsideActivity(e) {
+ try {
+ const tgt = e.target;
+ if (scroller && scroller.contains(tgt)) {
+ lastActivity = Date.now();
+ stopDrift('interaction-inside');
+ }
+ } catch (e) {}
+ }
+
+ function periodicIdle() {
+ if (endReached && autoRestart) {
+ const since = Date.now() - endReachedAt;
+ if (since >= autoRestartDelayMs) {
+ endReached = false; endReachedAt = 0;
+ if (!isScrolling && prefEnabled() && (Date.now() - lastActivity > idleDelay)) startDrift();
+ return;
+ }
+ return;
+ }
+ if (!isScrolling && !endReached && prefEnabled() && (Date.now() - lastActivity > idleDelay)) startDrift();
+ }
+
+ function attachHandlers() {
+ if (!scroller) return;
+ scroller.addEventListener('pointerdown', onInsideActivity, { passive: true });
+ scroller.addEventListener('touchstart', onInsideActivity, { passive: true });
+ scroller.addEventListener('focusin', onInsideActivity);
+ scroller.addEventListener('click', onInsideActivity, { passive: true });
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden && prefEnabled() && !isScrolling && (Date.now() - lastActivity > idleDelay)) startDrift();
+ });
+ }
+
+ function init() {
+ scroller = findScroller();
+ if (scroller) { ensureStyles(scroller); attachHandlers(); } else { log('no scroller found during init'); }
+ if (idleInterval) clearInterval(idleInterval);
+ idleInterval = setInterval(periodicIdle, 1000);
+ setTimeout(() => { if (prefEnabled() && (Date.now() - lastActivity > idleDelay)) startDrift(); }, idleDelay);
+
+ window.__autoScroll = window.__autoScroll || {};
+ window.__autoScroll.start = startDrift;
+ window.__autoScroll.stop = stopDrift;
+ window.__autoScroll.enable = function(){ setPref(true); lastActivity = Date.now(); endReached = false; startDrift(); };
+ window.__autoScroll.disable = function(){ setPref(false); stopDrift('disabled-via-api'); };
+ window.__autoScroll.setLoop = function(on){ loopMode = !!on; log('setLoop ->', loopMode); };
+ window.__autoScroll.getLoop = function(){ return !!loopMode; };
+ window.__autoScroll.setAutoRestart = function(enabled, delayMs){ autoRestart = !!enabled; if (typeof delayMs === 'number') autoRestartDelayMs = Number(delayMs); };
+ window.__autoScroll.clearEnd = function(){ endReached = false; endReachedAt = 0; };
+ window.__autoScroll.recompute = function(){ scroller = null; };
+ window.__autoScroll.cloneNow = function(){ if (!scroller) scroller = findScroller(); return cloneOnce(scroller); };
+ 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 }; };
+
+ log('auto-scroll (conservative v36.3) initialized');
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
+ else init();
+
+})();
\ No newline at end of file
diff --git a/static/js/tuner-settings.js b/static/js/tuner-settings.js
new file mode 100644
index 0000000..5cb0fd3
--- /dev/null
+++ b/static/js/tuner-settings.js
@@ -0,0 +1,370 @@
+// Tuner Settings + autoplay-from-playlist
+// - Loads a playlist (.m3u), parses channels
+// - Merges with EPG channels found on page
+// - Renders a small settings UI (select + autoplay checkbox)
+// - Persists active tuner + autoplay preference in localStorage
+// - Autoplays selected tuner on guide load when autoplay enabled
+//
+// Usage:
+// - Include this file in your page (defer).
+// - Optionally call window.__tuner.init({ playlistUrl: '/your.m3u', containerSelector: '#settings-container' });
+// - Or add to your settings markup and the module will auto-mount there.
+
+(function () {
+ const LS_KEY = 'activeTuner'; // stores { id, title, url }
+ const LS_AUTOPLAY = 'autoplayTuner'; // 'true'|'false'
+ const DEFAULT_PLAYLIST_URLS = ['/playlist.m3u', '/static/playlist.m3u', '/channels.m3u'];
+
+ // public API object
+ window.__tuner = window.__tuner || {};
+
+ function log(...args) { if (window && window.console) console.debug.apply(console, ['[tuner]'].concat(args)); }
+
+ // parse simple M3U with #EXTINF lines. Works with tvg-id="..." attributes or just " ,Name" style.
+ function parseM3U(text) {
+ const lines = text.split(/\r?\n/);
+ const out = [];
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) continue;
+ if (line.startsWith('#EXTINF')) {
+ const info = line;
+ // try to extract tvg-id or tvg-name
+ const idMatch = info.match(/tvg-id="([^"]+)"/i);
+ const nameMatch = info.match(/,(.*)$/);
+ const tvgNameMatch = info.match(/tvg-name="([^"]+)"/i);
+ const name = (nameMatch && nameMatch[1]) ? nameMatch[1].trim() : (tvgNameMatch ? tvgNameMatch[1] : null);
+ const id = idMatch ? idMatch[1] : (tvgNameMatch ? tvgNameMatch[1] : (name || null));
+ // next non-empty non-comment line is the URL
+ let j = i + 1;
+ let url = null;
+ while (j < lines.length) {
+ const candidate = lines[j].trim();
+ if (!candidate) { j++; continue; }
+ if (candidate.startsWith('#')) { j++; continue; }
+ url = candidate;
+ break;
+ }
+ if (url) {
+ out.push({ id: id || url, name: name || id || url, url: url, raw: info });
+ }
+ }
+ }
+ return out;
+ }
+
+ async function fetchPlaylist(playlistUrlCandidates) {
+ const candidates = Array.isArray(playlistUrlCandidates) ? playlistUrlCandidates : (playlistUrlCandidates ? [playlistUrlCandidates] : DEFAULT_PLAYLIST_URLS);
+ for (const u of candidates) {
+ try {
+ const res = await fetch(u + (u.includes('?') ? '&' : '?') + '_=' + Date.now(), { credentials: 'same-origin' });
+ if (!res.ok) { log('playlist fetch failed', u, res.status); continue; }
+ const txt = await res.text();
+ const parsed = parseM3U(txt);
+ if (parsed && parsed.length) {
+ log('loaded playlist', u, parsed.length, 'entries');
+ return parsed;
+ }
+ } catch (err) {
+ log('playlist fetch error', u, err);
+ }
+ }
+ log('no playlist loaded from candidates');
+ return [];
+ }
+
+ // Attempt to get EPG channels from a known global or from DOM
+ function getEpgChannels() {
+ // If an app-provided structure exists, use it
+ try {
+ if (Array.isArray(window.__epgChannels)) {
+ return window.__epgChannels.map(c => ({ id: String(c.id), title: c.title || c.name || String(c.id), epg: true }));
+ }
+ } catch (e) { /* ignore */ }
+
+ // Fallback: scan DOM for channel rows. Works with .chan-col or other reasonable selectors.
+ const nodes = Array.from(document.querySelectorAll('.chan-col, .channel-row, .channel, .grid-col .chan-col'));
+ const out = nodes.map(n => {
+ // try common places for channel id/number and name
+ let id = null;
+ if (n.dataset && n.dataset.chanId) id = n.dataset.chanId;
+ if (!id) {
+ const numEl = n.querySelector('.chan-number, .channel-number, .cnum');
+ if (numEl) id = numEl.textContent.trim();
+ }
+ if (!id) {
+ // fallback: use first 6 chars of text (not ideal but usable)
+ id = (n.getAttribute('data-id') || n.id || '').toString() || null;
+ }
+ const titleEl = n.querySelector('.chan-name, .channel-name, .cname');
+ const title = titleEl ? titleEl.textContent.trim() : (n.textContent || '').trim().slice(0, 40);
+ return id ? { id: String(id), title: title || String(id), epg: true } : null;
+ }).filter(Boolean);
+
+ // dedupe by id
+ const map = new Map();
+ out.forEach(c => map.set(String(c.id), c));
+ return Array.from(map.values());
+ }
+
+ // Merge playlist channels and EPG channels into an ordered list (EPG channels kept first)
+ function mergeChannels(epg, playlist) {
+ const map = new Map();
+ // add EPG first
+ (epg || []).forEach(e => map.set(String(e.id), { id: String(e.id), title: e.title || String(e.id), epg: true }));
+ // add playlist channels, attach url, add new ones
+ (playlist || []).forEach(p => {
+ const key = String(p.id || p.url || p.name || p);
+ if (map.has(key)) {
+ map.get(key).url = p.url; // attach url to EPG entry if available
+ } else {
+ map.set(key, { id: key, title: p.name || p.id || p.url, url: p.url, playlist: true });
+ }
+ });
+ // return array preserving EPG ordering first, then playlist-only entries
+ return Array.from(map.values());
+ }
+
+ // Simple UI creation: inject into container or auto-create a panel inside body if not found.
+ function renderSettingsUI(containerSelector, channels, persisted) {
+ let container = null;
+ if (containerSelector) container = document.querySelector(containerSelector);
+ if (!container) container = document.getElementById('tuner-settings') || document.querySelector('#settings-pane') || null;
+ // If still not found, create a small floating panel (non-intrusive)
+ let created = false;
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'tuner-settings';
+ container.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9999;background:#fff;border:1px solid #ddd;padding:8px;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,0.2);font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial;';
+ document.body.appendChild(container);
+ created = true;
+ }
+
+ container.innerHTML = ''; // clear
+ const title = document.createElement('div');
+ title.style.fontWeight = '600';
+ title.style.marginBottom = '6px';
+ title.textContent = 'Active Tuner (Autoplay)';
+ container.appendChild(title);
+
+ const select = document.createElement('select');
+ select.id = 'activeTunerSelect';
+ select.style.minWidth = '260px';
+ select.style.marginBottom = '6px';
+ // Add default option
+ const autoOpt = document.createElement('option');
+ autoOpt.value = '';
+ autoOpt.textContent = 'โ Use EPG default โ';
+ select.appendChild(autoOpt);
+
+ channels.forEach(ch => {
+ const opt = document.createElement('option');
+ opt.value = JSON.stringify({ id: ch.id, url: ch.url || '', title: ch.title || ch.id });
+ let label = ch.title || ch.id;
+ if (ch.playlist) label += ' (playlist)';
+ if (ch.epg && !ch.url) label += ' (EPG only)';
+ opt.textContent = label;
+ select.appendChild(opt);
+ });
+
+ // set persisted selection
+ if (persisted && persisted.id) {
+ const compare = JSON.stringify({ id: persisted.id, url: persisted.url || '', title: persisted.title || persisted.id });
+ const opt = Array.from(select.options).find(o => o.value === compare);
+ if (opt) select.value = compare;
+ }
+
+ container.appendChild(select);
+
+ const autop = document.createElement('label');
+ autop.style.display = 'flex';
+ autop.style.alignItems = 'center';
+ autop.style.gap = '8px';
+ autop.style.marginBottom = '8px';
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.id = 'autoplayTuner';
+ cb.checked = (localStorage.getItem(LS_AUTOPLAY) !== 'false'); // default true
+ autop.appendChild(cb);
+ const span = document.createElement('span');
+ span.textContent = 'Autoplay on guide load';
+ autop.appendChild(span);
+ container.appendChild(autop);
+
+ const btnRow = document.createElement('div');
+ btnRow.style.display = 'flex';
+ btnRow.style.gap = '8px';
+
+ const saveBtn = document.createElement('button');
+ saveBtn.textContent = 'Save';
+ saveBtn.style.padding = '6px 10px';
+ saveBtn.addEventListener('click', () => {
+ const val = select.value;
+ if (!val) {
+ localStorage.removeItem(LS_KEY);
+ } else {
+ try {
+ localStorage.setItem(LS_KEY, val);
+ } catch (e) { log('save error', e); }
+ }
+ localStorage.setItem(LS_AUTOPLAY, cb.checked ? 'true' : 'false');
+ log('settings saved', { active: localStorage.getItem(LS_KEY), autoplay: localStorage.getItem(LS_AUTOPLAY) });
+ // If autoplay enabled and a selection exists, play immediately
+ if (cb.checked && val) {
+ const parsed = JSON.parse(val);
+ playChannel(parsed.url, parsed);
+ }
+ });
+
+ const clearBtn = document.createElement('button');
+ clearBtn.textContent = 'Clear';
+ clearBtn.style.padding = '6px 10px';
+ clearBtn.addEventListener('click', () => {
+ select.value = '';
+ cb.checked = false;
+ localStorage.removeItem(LS_KEY);
+ localStorage.setItem(LS_AUTOPLAY, 'false');
+ log('settings cleared');
+ });
+
+ btnRow.appendChild(saveBtn);
+ btnRow.appendChild(clearBtn);
+ container.appendChild(btnRow);
+
+ if (created) {
+ const note = document.createElement('div');
+ note.style.marginTop = '8px';
+ note.style.fontSize = '12px';
+ note.style.color = '#666';
+ note.textContent = 'You can move this panel into your settings page and call window.__tuner.init({containerSelector:"#your-settings-slot"}).';
+ container.appendChild(note);
+ }
+ }
+
+ // Attempt to play url using common players or