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

- Version + Version GHCR @@ -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
+ +
--:-- --
+
diff --git a/templates/about.html b/templates/about.html index 763d323..f1547d7 100644 --- a/templates/about.html +++ b/templates/about.html @@ -1,289 +1,35 @@ - - - - About RetroIPTVGuide - - - -
- - - -
--:-- --
-
- -

About RetroIPTVGuide

-
+{% block content %} +
+
+

About RetroIPTVGuide

    -
  • Version: {{ info.version }}
  • -
  • Release date: {{ info.release_date }}
  • -
  • Python version: {{ info.python_version }}
  • -
  • OS: {{ info.os_info }}
  • -
  • Install path: {{ info.install_path }}
  • -
  • Database: {{ info.db_path }}
  • -
  • Logs: {{ info.log_path }}
  • -
  • Server uptime: {{ info.uptime }}
  • +
  • Version:{{ info.version }}
  • +
  • Release date:{{ info.release_date }}
  • +
  • Python version:{{ info.python_version }}
  • +
  • OS:{{ info.os_info }}
  • +
  • Install path:{{ info.install_path }}
  • +
  • Database:{{ info.db_path }}
  • +
  • Logs:{{ info.log_path }}
  • +
  • Server uptime:{{ info.uptime }}
+
+{% endblock %} +{% block scripts_extra %} - - - +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..86fcd39 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,276 @@ + + + + {% block title %}IPTV Guide{% endblock %} + + + + + + {% block page_styles %} + {# per-page CSS variables or small style blocks can be provided here #} + {% endblock %} + + + {% include '_header.html' %} + + {% block content %} + + {% endblock %} + + + + + + + {% block scripts_extra %} + {# per-page scripts can be placed here #} + {% endblock %} + + diff --git a/templates/change_password.html b/templates/change_password.html index 213701c..d7cded7 100644 --- a/templates/change_password.html +++ b/templates/change_password.html @@ -1,241 +1,13 @@ - - - - Change Password - - - -
- - -
--:-- --
-
- -
+{% block content %} +
+

Change Password

@@ -243,39 +15,28 @@

Change Password

+ {% with messages = get_flashed_messages() %} {% if messages %} - {% for msg in messages %} -

{{ msg }}

- {% endfor %} +
+ {% for msg in messages %} +

{{ msg }}

+ {% endfor %} +
{% endif %} {% endwith %} +
+{% endblock %} +{% block scripts_extra %} - - - +{% endblock %} diff --git a/templates/change_tuner.html b/templates/change_tuner.html index b0a12a6..b7e3737 100644 --- a/templates/change_tuner.html +++ b/templates/change_tuner.html @@ -1,228 +1,14 @@ - - - - Change Tuner - - - -
- -
--:-- --
-
- -
+{% block content %} +
+

Change Active Tuner

@@ -234,9 +20,9 @@

Change Active Tuner

-
+
-
+

Update Tuner URLs

@@ -246,28 +32,31 @@

Update Tuner URLs

{% endfor %} - - +
+ +
+
+ +
-
+
- -
+

Manage Tuners

- - - +
+
+
- + @@ -291,48 +80,43 @@

Rename Tuner

{% endfor %} - +
+ +
+
+{% endblock %} +{% block scripts_extra %} - - - +{% endblock %} diff --git a/templates/guide.html b/templates/guide.html index d90e8b6..fad5c51 100644 --- a/templates/guide.html +++ b/templates/guide.html @@ -2,271 +2,20 @@ Live TV Guide - @@ -276,8 +25,7 @@ HOME {% if current_user is defined and current_user.username == 'admin' %} - - MANAGE USERS + MANAGE USERS {% endif %}
- LOGOUT
-
--:-- --
- -
+

Program Info

Click a channel on the left to start playback.

- - + +
+ + + -
-
+
+ +
@@ -332,7 +84,7 @@

Program Info

{{ t.isoformat() }}
{% endfor %}
-
+
@@ -344,42 +96,43 @@

Program Info

+ data-name="{{ ch.name|e }}" + data-logo="{{ ch.logo }}"> {% if ch.logo %}{% endif %} {{ ch.name }}
- {% set cid = ch.tvg_id %} - {% set channel_epg = epg[cid] if cid in epg else [] %} - {% if channel_epg|length == 0 %} -
- No Guide Data Available -
- {% else %} - {% for prog in channel_epg %} - {% if prog.title == 'No Guide Data Available' %} -
- {{ prog.title }} -
- {% elif prog.start and prog.stop %} - {% set left = ((prog.start - grid_start).total_seconds()/60) * SCALE %} - {% set calc_width = (prog.stop - prog.start).total_seconds()/60 * SCALE %} - {% set width = 24 if calc_width < 24 else calc_width %} -
- {{ prog.title }} -
- {% endif %} - {% endfor %} - {% endif %} + {% set cid = ch.tvg_id %} + {% set channel_epg = epg[cid] if cid in epg else [] %} + {% if channel_epg|length == 0 %} +
+ No Guide Data Available +
+ {% else %} + {% for prog in channel_epg %} + {% if prog.title == 'No Guide Data Available' %} +
+ {{ prog.title }} +
+ {% elif prog.start and prog.stop %} + {% set left = ((prog.start - grid_start).total_seconds()/60) * SCALE %} + {% set calc_width = (prog.stop - prog.start).total_seconds()/60 * SCALE %} + {% set width = 24 if calc_width < 24 else calc_width %} +
+ {{ prog.title }} +
+ {% endif %} + {% endfor %} + {% endif %}
@@ -396,7 +149,6 @@

Program Info

currentChannelId = cid; updateSummary(cid, name); - // ๐Ÿ”ฅ Log playback to backend fetch("/play_channel", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -441,6 +193,108 @@

Program Info

} } +/* Fixed time header function (kept as your implementation) */ +function createOrUpdateFixedTimeBar(){ + const fixedBar = document.getElementById('fixedTimeBar'); + const gridTimeRow = document.getElementById('gridTimeRow'); + const guideOuter = document.getElementById('guideOuter'); + const playerRow = document.getElementById('playerRow'); + + if (!gridTimeRow || !fixedBar || !guideOuter) return; + + const headerWrap = gridTimeRow.querySelector('.time-header-wrap .grid-content'); + const serverTimeHeader = headerWrap ? headerWrap.querySelector('.time-header') : null; + if (!serverTimeHeader) return; + + fixedBar.innerHTML = ''; + + const clonedGridContent = document.createElement('div'); + clonedGridContent.className = 'grid-content'; + clonedGridContent.style.display = 'flex'; + clonedGridContent.style.alignItems = 'stretch'; + clonedGridContent.style.position = 'relative'; + clonedGridContent.style.height = '100%'; + + const spacer = document.createElement('div'); + spacer.className = 'left-spacer'; + + const guideRect = guideOuter.getBoundingClientRect(); + const headerGridRect = headerWrap.getBoundingClientRect(); + + let spacerWidth = Math.round(headerGridRect.left - guideRect.left); + + if (!spacerWidth || spacerWidth <= 0) { + const firstChanCol = document.querySelector('.guide-row .chan-col'); + spacerWidth = firstChanCol ? Math.round(firstChanCol.getBoundingClientRect().width) : 200; + } + + spacer.style.width = spacer.style.minWidth = spacer.style.maxWidth = spacerWidth + 'px'; + spacer.style.flex = '0 0 ' + spacerWidth + 'px'; + spacer.style.height = '100%'; + spacer.style.pointerEvents = 'none'; + + const clonedTimeHeader = serverTimeHeader.cloneNode(true); + clonedTimeHeader.classList.add('time-header'); + clonedTimeHeader.style.display = 'flex'; + clonedTimeHeader.style.height = '100%'; + + clonedGridContent.appendChild(spacer); + clonedGridContent.appendChild(clonedTimeHeader); + + const nowLine = document.createElement('div'); + nowLine.id = 'nowLineFixed'; + nowLine.className = 'now-line'; + nowLine.style.position = 'absolute'; + nowLine.style.top = '0'; + nowLine.style.bottom = '0'; + nowLine.style.width = '2px'; + nowLine.style.left = '0px'; + nowLine.style.pointerEvents = 'none'; + clonedGridContent.appendChild(nowLine); + + fixedBar.appendChild(clonedGridContent); + + fixedBar.style.left = '0px'; + fixedBar.style.width = window.innerWidth + 'px'; + + if (playerRow) { + const rect = playerRow.getBoundingClientRect(); + fixedBar.style.top = rect.bottom + 'px'; + } else { + const header = document.querySelector('.header'); + const headerRect = header ? header.getBoundingClientRect() : { bottom: 40 }; + fixedBar.style.top = headerRect.bottom + 'px'; + } + + requestAnimationFrame(() => { + const fbHeight = fixedBar.getBoundingClientRect().height || 34; + const currentPaddingTop = parseFloat(window.getComputedStyle(guideOuter).paddingTop) || 0; + if (currentPaddingTop < fbHeight) { + guideOuter.style.paddingTop = fbHeight + 'px'; + } + }); + + const origNow = document.getElementById('nowLineOriginal'); + if (origNow) origNow.style.display = 'none'; +} + +function updateNowLine(){ + const gridStart = new Date("{{ grid_start.isoformat() }}"); + const scale = {{ SCALE }}; + const now = new Date(); + const minutesFromStart = (now - gridStart) / 60000; + const leftPx = (minutesFromStart * scale); + + const firstChanCol = document.querySelector('.guide-row .chan-col'); + const chanColWidth = firstChanCol ? firstChanCol.getBoundingClientRect().width : 0; + + const nlFixed = document.getElementById('nowLineFixed'); + if (nlFixed) nlFixed.style.left = (leftPx + chanColWidth) + 'px'; + + const nlOrig = document.getElementById('nowLineOriginal'); + if (nlOrig) nlOrig.style.left = leftPx + 'px'; +} + document.addEventListener("DOMContentLoaded", () => { const savedTheme = localStorage.getItem("theme") || "dark"; document.body.classList.add(savedTheme); @@ -462,26 +316,69 @@

Program Info

prog.title = `${prog.dataset.title}\n${start.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})} - ${stop.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`; }); + createOrUpdateFixedTimeBar(); updateClock(); - setInterval(updateClock, 1000); updateNowLine(); + + setInterval(updateClock, 1000); setInterval(updateNowLine, 60000); }); -function setTheme(theme) { - const body = document.body; - // Remove all possible theme classes - body.classList.remove("light", "dark", "retro-tvguide", "retro-aol", "retro-webtv", "retro-tvguide2000", "retro-magazine", "directv", "comcast"); - // Add the new one - body.classList.add(theme); - // Save it for next load - localStorage.setItem("theme", theme); +window.addEventListener('resize', () => { + createOrUpdateFixedTimeBar(); + updateNowLine(); +}); + +/* --- Theme & UI helpers --- */ +function setTheme(theme){ + const b=document.body; + b.classList.add("fade-switch"); + setTimeout(()=>{ + const wasTVG=b.classList.contains("tvguide1990"); + b.classList.remove("light","dark","retro-tvguide","retro-aol","retro-webtv", + "retro-tvguide2000","retro-magazine","directv","comcast","tvguide1990"); + b.classList.add(theme); + localStorage.setItem("theme",theme); + + if(wasTVG && theme!=="tvguide1990"){ + document.querySelectorAll(".chan-col .chan-name").forEach(el=>{ + const name=el.dataset.name||"Channel"; + const logo=el.dataset.logo; + el.innerHTML=logo?`${name}`:`${name}`; + }); + document.querySelectorAll(".grid-row,.chan-col").forEach(r=>r.style.height=""); + } + + if(theme==="tvguide1990") requestAnimationFrame(applyTvGuide1990Capsules); + + setTimeout(()=>b.classList.remove("fade-switch"),100); + },150); } -// On load, apply saved theme or default -document.addEventListener("DOMContentLoaded", () => { - const savedTheme = localStorage.getItem("theme") || "dark"; - setTheme(savedTheme); +// applyTvGuide1990Capsules: only operate on original .chan-col elements (exclude auto-scroll clones) +function applyTvGuide1990Capsules(){ + const originalCols = Array.from(document.querySelectorAll('.chan-col')).filter(c => !c.closest('.__auto_scroll_clone')); + originalCols.forEach((col,i)=>{ + const box = col.querySelector('.chan-name'); + if(!box) return; + if(box.querySelector('.channel-number')) return; + const cap = document.createElement('span'); + cap.className='channel-number'; + cap.textContent = String(i+1); + box.innerHTML = ''; + box.appendChild(cap); + col.classList.add('tvguide1990-applied'); + }); + + if (window.__autoScroll && typeof window.__autoScroll.recomputeLoops === 'function') { + window.__autoScroll.recomputeLoops(); + } +} + +document.addEventListener("DOMContentLoaded",()=>{ + const t=localStorage.getItem("theme")||"dark"; + setTheme(t); + if(t==="tvguide1990") requestAnimationFrame(applyTvGuide1990Capsules); }); function updateClock() { @@ -494,39 +391,97 @@

Program Info

}); } } + -function updateNowLine() { - const gridStart = new Date("{{ grid_start.isoformat() }}"); - const scale = {{ SCALE }}; - const now = new Date(); - const minutesFromStart = (now - gridStart) / 60000; - const nl = document.getElementById('nowLine'); - if (nl) nl.style.left = (minutesFromStart * scale) + "px"; -} + - diff --git a/templates/logs.html b/templates/logs.html index f0479dd..3ae3355 100644 --- a/templates/logs.html +++ b/templates/logs.html @@ -1,144 +1,58 @@ - - - - Activity Log - - - -
- -
--:-- --
-
- -
+{% block content %} +
+

Activity Log ({{ log_size }} bytes)

- -
- -
+ +
+ +
- - - + + +
- - - - - - {% for user, action, ts, log_type in entries %} - - - - - - {% endfor %} - -
UserActionTimestamp
{{ user }}{{ action }}{{ ts }}
+
+ + + + + + {% for user, action, ts, log_type in entries %} + + + + + + {% endfor %} + +
UserActionTimestamp
{{ user }}{{ action }}{{ ts }}
+
+
+{% endblock %} +{% block scripts_extra %} - - - +{% endblock %} diff --git a/templates/manage_users.html b/templates/manage_users.html index bedd200..4db5521 100644 --- a/templates/manage_users.html +++ b/templates/manage_users.html @@ -1,165 +1,71 @@ - - - - Manage Users - - - -
- -
--:-- --
-
- -
+{% block content %} +
+

Manage Users

-
-

Add New User

- - - - + + +

Add New User

+ +
+
+

Existing Users

- - - {% for user in users %} - - - + + {% endfor %} + +
UsernameActions
{{ user }} -
- - - +
+ + + + + + {% for user in users %} + + + - - {% endfor %} -
UsernameActions
{{ user }} + + + + -
- - - + + + +
-
+
+
{% with messages = get_flashed_messages() %} {% if messages %} - {% for msg in messages %} -

{{ msg }}

- {% endfor %} +
+ {% for msg in messages %} +

{{ msg }}

+ {% endfor %} +
{% endif %} {% endwith %} +
+{% endblock %} +{% block scripts_extra %} - - +{% endblock %}