Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/).

---

## v4.2.1 - 2025-11-10

### Added
- Added horizontal scroll/refresh as time moves forward
- Added API dynamic guide timing refresh

---

## [Unreleased]

- Planned: add `.m3u8` tuner support.
- Planned: move logs to SQLite DB.
- Planned: log filtering and pagination.

---

## v4.2.0 - 2025-11-06
This version introduces mobile responsiveness, a new theme, refinements to auto-scroll, and backend API structures.

Expand All @@ -24,15 +40,6 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Resolved layout inconsistencies across themes and display sizes.
- General code cleanup and alignment for CI/CD consistency.

---

## [Unreleased]

- Planned: add `.m3u8` tuner support.
- Planned: move logs to SQLite DB.
- Planned: log filtering and pagination.

---

## v4.1.0 - 2025-10-25
### New Features
Expand Down Expand Up @@ -64,7 +71,6 @@ This version introduces mobile responsiveness, a new theme, refinements to auto-
- Improved guide performance and browser compatibility with the new auto-scroll implementation.
- Minor visual and layout corrections across settings and guide pages.

---
## v4.0.0 — 2025-10-19
**Status:** Public Release (Feature Complete)

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 📺 RetroIPTVGuide v4.2.0
# 📺 RetroIPTVGuide v4.2.1

<p align="center">
<a href="https://github.com/thehack904/RetroIPTVGuide">
<img src="https://img.shields.io/badge/version-v4.2.0-blue?style=for-the-badge" alt="Version">
<img src="https://img.shields.io/badge/version-v4.2.1-blue?style=for-the-badge" alt="Version">
</a>
<a href="https://github.com/thehack904/RetroIPTVGuide/pkgs/container/retroiptvguide">
<img src="https://img.shields.io/badge/GHCR-ghcr.io/thehack904/retroiptvguide-green?style=for-the-badge&logo=docker" alt="GHCR">
Expand Down
60 changes: 30 additions & 30 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# app.py — merged version (features from both sources)
APP_VERSION = "v4.2.0"
APP_RELEASE_DATE = "2025-11-06"
APP_VERSION = "v4.2.1"
APP_RELEASE_DATE = "2025-11-10"

from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
Expand Down Expand Up @@ -1284,37 +1284,36 @@ def ensure_default_tuner():
def api_guide_snapshot():
"""
Public unified guide data for framebuffer/RetroIPTV OS clients.
Combines channels + EPG info + active tuner in a single JSON response.
Emits precise start/stop ISO and duration (minutes) per program.
Supports ?hours=N (0.5–8) to control window size.
"""
try:
now = datetime.now(timezone.utc)
start = now.replace(minute=(0 if now.minute < 30 else 30), second=0, microsecond=0)

# Allow variable window length via ?hours= parameter
try:
hours = float(request.args.get("hours", 2))
except (TypeError, ValueError):
hours = 2

# Clamp to a sane maximum
if hours < 0.5:
hours = 0.5
elif hours > 8:
hours = 8
# Read optional hours parameter
hours_param = request.args.get("hours", type=float)
if hours_param is None:
hours = 2.0
else:
hours = max(0.5, min(hours_param, 8.0)) # clamp 0.5–8h

start = now.replace(
minute=(0 if now.minute < 30 else 30),
second=0,
microsecond=0
)
end = start + timedelta(hours=hours)

# Timeline labels (30-minute increments)
slots = [start + timedelta(minutes=i*30) for i in range(0, 5)]
# 30-minute timeline labels across the window
slot_count = int((hours * 60) / 30) + 1
slots = [start + timedelta(minutes=30 * i) for i in range(slot_count)]
timeline = [s.strftime("%I:%M %p").lstrip("0") for s in slots]

tuner_name = get_current_tuner()
tuners = get_tuners()
tuner_info = tuners.get(tuner_name, {})

channels_out = []
for ch in cached_channels[:50]: # limit for perf
for ch in cached_channels[:50]: # perf cap
tvg_id = ch.get('tvg_id')
progs = cached_epg.get(tvg_id, [])
visible_programs = []
Expand All @@ -1324,32 +1323,33 @@ def api_guide_snapshot():
if not st or not sp:
continue

# include any program overlapping our 2h window
# include any program overlapping [start, end)
if st < end and sp > start:
# clamp to window for a stable box inside the 2h view
clipped_start = max(st, start)
clipped_stop = min(sp, end)
dur_min = max(1, int((clipped_stop - clipped_start).total_seconds() // 60))
clipped_stop = min(sp, end)
dur_min = max(
1,
int((clipped_stop - clipped_start).total_seconds() // 60)
)

visible_programs.append({
"title": p.get('title') or "No Data",
"desc": p.get('desc') or "",
"start": st.isoformat(), # full original ISO (tz-aware)
"start": st.isoformat(),
"stop": sp.isoformat(),
"clipped_start": clipped_start.isoformat(),
"clipped_stop": clipped_stop.isoformat(),
"duration": dur_min # minutes within the 2h window
"duration": dur_min
})

if not visible_programs:
# 30-min placeholder, pinned to window start for display sanity
visible_programs = [{
"title": "No Data",
"desc": "",
"start": None,
"stop": None,
"clipped_start": start.isoformat(),
"clipped_stop": (start + timedelta(minutes=30)).isoformat(),
"clipped_stop": (start + timedelta(minutes=30)).isoformat(),
"duration": 30
}]

Expand All @@ -1367,13 +1367,13 @@ def api_guide_snapshot():
"tuner_xml": tuner_info.get('xml'),
"tuner_m3u": tuner_info.get('m3u'),
"theme": "default_crt_blue",
"version": APP_VERSION
"version": APP_VERSION,
},
"timeline": timeline,
"window": {
"start_iso": start.isoformat(),
"end_iso": end.isoformat(),
"minutes": 120
"end_iso": end.isoformat(),
"minutes": int(hours * 60),
},
"channels": channels_out
}
Expand Down
74 changes: 74 additions & 0 deletions static/js/guide-now-sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
(() => {
const ENDPOINT = '/api/guide_snapshot?hours=6';
const REFRESH_INTERVAL_MIN = 30; // re-sync with server window (Cairo-style)
const TICK_MS = 60000; // move line every 1 min
const SCALE = 5; // must match SCALE in app.py

const timeRow = document.getElementById('gridTimeRow');
if (!timeRow) return;

const nowLine = document.getElementById('nowLineOriginal');
if (!nowLine) return;

// Element that owns the CSS variable; your layout already wraps now-line in .grid-content
const gridContent = timeRow.querySelector('.time-header-wrap .grid-content') || timeRow;
if (!gridContent) return;

let windowStart = null; // Date from API
let windowMinutes = null; // total minutes from API

function setNowOffset(px) {
gridContent.style.setProperty('--now-offset', px + 'px');
}

async function syncWindowFromAPI() {
try {
const res = await fetch(ENDPOINT, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!data.window || !data.window.start_iso || !data.window.minutes) return;

windowStart = new Date(data.window.start_iso);
windowMinutes = data.window.minutes;

// Make sure the line is visible once we have a valid window
nowLine.style.display = 'block';

updateNowLine(); // snap immediately after sync
console.log('[guide-now-sync] window synced');
} catch (e) {
console.error('[guide-now-sync] failed to sync window', e);
}
}

function minutesSinceStart() {
if (!windowStart) return 0;
return (Date.now() - windowStart.getTime()) / 60000;
}

function updateNowLine() {
if (!windowStart || !windowMinutes) return;

const elapsed = minutesSinceStart();

if (elapsed < 0 || elapsed > windowMinutes) {
// Outside current window: hide line so it doesn't lie
nowLine.style.display = 'none';
return;
}

nowLine.style.display = 'block';
const offsetPx = elapsed * SCALE;
setNowOffset(offsetPx);
}

// Kick things off
syncWindowFromAPI();

// Re-sync to server window (so page follows the same rolling window as Cairo)
setInterval(syncWindowFromAPI, REFRESH_INTERVAL_MIN * 60 * 1000);

// Smoothly advance between syncs
setInterval(updateNowLine, TICK_MS);
})();

25 changes: 25 additions & 0 deletions static/js/guide-refresh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
(() => {
// Adjust the hours parameter if you prefer 8-hour browser view
const ENDPOINT = '/api/guide_snapshot?hours=6';
const REFRESH_INTERVAL_MIN = 30; // same cadence as Cairo

async function refreshGuide() {
try {
const res = await fetch(ENDPOINT, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);

// You can either rebuild just the EPG grid, or simply reload the page
// depending on how your current guide is generated.
// For now, safest approach is a full reload:
window.location.reload();

console.log(`[guide-refresh] Guide refreshed at ${new Date().toLocaleTimeString()}`);
} catch (err) {
console.error('[guide-refresh] Failed to refresh guide:', err);
}
}

// Run once every X minutes so the grid rolls forward with real time
setInterval(refreshGuide, REFRESH_INTERVAL_MIN * 60 * 1000);
})();

5 changes: 5 additions & 0 deletions templates/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,11 @@ <h3>Program Info</h3>
<!-- Mobile player adapt script: allow scrolling -->
<script src="{{ url_for('static', filename='js/mobile-scroll-fix.js') }}" defer></script>

<!-- Align all versions that pull from the API -->
<script src="{{ url_for('static', filename='js/guide-refresh.js') }}"></script>
<script src="{{ url_for('static', filename='js/guide-now-sync.js') }}"></script>


<!-- Auto Scroll (manager and bindings) -->
<script src="/static/js/auto-scroll.js" defer></script>

Expand Down