From 22fc2f00aefab183994789ae0d794160783396f5 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 13 Jun 2026 18:57:20 +0100 Subject: [PATCH] Add in-browser episode player with playback-position sync Adds an optional sticky audio player to the feed page. Clicking an episode streams its audio in the browser and reports playback position back to the existing /api/2/episodes/current.json endpoint as gPodder "play" actions, authenticated by the current web session cookie -- no new endpoint or auth. - Auto-resumes each episode from the last synced position (read from the action log already loaded by feed.php). - Reports on pause, seek, end, page unload (sendBeacon) and every 20s while playing, so progress round-trips with other gPodder clients (AntennaPod, Kasts, etc.). - Pure vanilla JS (server/player.js), no dependencies or build step. - Degrades gracefully: episodes over http:// (mixed content) or from hot-link-protected hosts show a short status message instead of failing silently. --- server/player.js | 155 ++++++++++++++++++++++++++++++++++++++ server/style.css | 82 ++++++++++++++++++++ server/templates/feed.tpl | 27 ++++++- 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 server/player.js diff --git a/server/player.js b/server/player.js new file mode 100644 index 0000000..3b28cb2 --- /dev/null +++ b/server/player.js @@ -0,0 +1,155 @@ +/** + * In-browser episode player for oPodSync. + * + * Plays an episode's audio directly from the podcast host and reports playback + * position back to the sync server as gPodder "play" episode actions, so other + * clients (AntennaPod, etc.) can resume where you left off and vice-versa. + * + * No new backend: it POSTs to the existing /api/2/episodes/current.json endpoint, + * authenticated by the same session cookie used to view this page. + */ +(function () { + 'use strict'; + + var bar = document.getElementById('player-bar'); + var audio = document.getElementById('pb-audio'); + var titleEl = document.getElementById('pb-title'); + var statusEl = document.getElementById('pb-status'); + var closeBtn = document.getElementById('pb-close'); + + if (!bar || !audio) { + return; + } + + // Minimum gap between periodic position reports while playing. Each report is + // one row in episodes_actions, so this trades sync granularity against table + // growth. Pause / seek / end / page-unload always report regardless. + var REPORT_INTERVAL = 20000; + + var current = null; // { media, podcast, title, total } + var startedAt = 0; // position (s) where the current play session began + var lastSent = 0; // epoch ms of the last report + + function setStatus(msg) { + statusEl.textContent = msg || ''; + } + + function buildAction() { + var position = Math.floor(audio.currentTime || 0); + var total = Math.floor(audio.duration || current.total || 0) || null; + + var action = { + podcast: current.podcast, + episode: current.media, + action: 'play', + // gPodder expects "YYYY-MM-DDTHH:MM:SSZ" + timestamp: new Date().toISOString().replace(/\.\d+Z$/, 'Z'), + started: Math.floor(startedAt), + position: position + }; + + if (total) { + action.total = total; + } + + return action; + } + + // mode: false = throttled (periodic), true = immediate, 'unload' = beacon + function report(mode) { + if (!current) { + return; + } + + var now = Date.now(); + if (mode === false && now - lastSent < REPORT_INTERVAL) { + return; + } + lastSent = now; + + var body = JSON.stringify([buildAction()]); + + if (mode === 'unload' && navigator.sendBeacon) { + navigator.sendBeacon( + './api/2/episodes/current.json', + new Blob([body], { type: 'application/json' }) + ); + return; + } + + fetch('./api/2/episodes/current.json', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: body + }).catch(function () { + /* transient network error: the next periodic report will retry */ + }); + } + + function play(btn) { + var media = btn.getAttribute('data-media'); + if (!media) { + return; + } + + // Flush the position of whatever was playing before we switch episodes. + report(true); + + current = { + media: media, + podcast: btn.getAttribute('data-podcast'), + title: btn.getAttribute('data-title') || media, + total: parseInt(btn.getAttribute('data-total'), 10) || 0 + }; + lastSent = 0; + + var resume = parseInt(btn.getAttribute('data-pos'), 10) || 0; + + titleEl.textContent = current.title; + setStatus('Loading…'); + bar.hidden = false; + + var onMeta = function () { + // Resume, unless we'd land within the last few seconds of the episode. + if (resume > 0 && (!audio.duration || resume < audio.duration - 5)) { + try { audio.currentTime = resume; } catch (e) { /* ignore */ } + } + startedAt = Math.floor(audio.currentTime || resume || 0); + setStatus(''); + audio.play().catch(function () { + setStatus('Could not play — the host may block playback or serve http:// (mixed content).'); + }); + }; + + audio.addEventListener('loadedmetadata', onMeta, { once: true }); + audio.src = media; + audio.load(); + } + + document.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.play-btn') : null; + if (btn) { + e.preventDefault(); + play(btn); + } + }); + + audio.addEventListener('timeupdate', function () { report(false); }); + audio.addEventListener('pause', function () { report(true); }); + audio.addEventListener('seeked', function () { report(true); }); + audio.addEventListener('ended', function () { report(true); }); + audio.addEventListener('error', function () { + setStatus('Could not load — the host may block playback or serve http:// (mixed content).'); + }); + + closeBtn.addEventListener('click', function () { + report(true); + audio.pause(); + bar.hidden = true; + current = null; + }); + + // Best-effort flush of the final position when leaving the page. + window.addEventListener('pagehide', function () { report('unload'); }); +})(); diff --git a/server/style.css b/server/style.css index ebcf3a8..e6545fa 100644 --- a/server/style.css +++ b/server/style.css @@ -236,6 +236,88 @@ input.url { box-shadow: 0 0 10px #e74c3c; } +/* In-browser episode player */ +.play-btn { + border: none; + cursor: pointer; + background: linear-gradient(to bottom, #2c3e50, #3498db); + color: #fff; + border-radius: 50%; + width: 1.9em; + height: 1.9em; + font-size: .9em; + line-height: 1.9em; + padding: 0; + margin-right: .5em; + vertical-align: middle; + flex: 0 0 auto; +} + +.play-btn:hover { + box-shadow: 0 0 8px orange; +} + +#player-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 50; + display: flex; + align-items: center; + gap: 1em; + padding: .6em 1em; + background: linear-gradient(to bottom, #2c3e50, #363795); + color: #fff; + box-shadow: 0 -2px 10px rgba(0, 0, 0, .5); +} + +#player-bar[hidden] { + display: none; +} + +#pb-meta { + flex: 1 1 auto; + min-width: 0; +} + +#pb-title { + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#pb-status { + color: #ffd; + font-size: .85em; + margin: 0; +} + +#pb-audio { + flex: 2 1 40%; + max-width: 55%; +} + +#pb-close { + flex: 0 0 auto; + background: none; + border: none; + color: #fff; + font-size: 1.4em; + line-height: 1; + cursor: pointer; +} + +#pb-close:hover { + color: #e74c3c; +} + +/* Keep the fixed bar from covering the last rows */ +main { + padding-bottom: 6em; +} + @media screen and (max-width: 900px) { body > h1 { font-size: 2rem; diff --git a/server/templates/feed.tpl b/server/templates/feed.tpl index c339e27..7632c2c 100644 --- a/server/templates/feed.tpl +++ b/server/templates/feed.tpl @@ -17,6 +17,20 @@ {/if} {if count($episodes)} + last known play position (seconds), from the action log. +// $actions is ordered by changed DESC, so the first 'play' seen per URL is newest. +$positions = []; +foreach ($actions as $__a) { + if (($__a->action ?? '') !== 'play' || isset($positions[$__a->url])) { + continue; + } + $__d = json_decode($__a->data ?? '', true) ?: []; + if (isset($__d['position'])) { + $positions[$__a->url] = (int) $__d['position']; + } +} +?>

Episodes ({$episodes|count})

@@ -33,9 +47,10 @@ $iso_date = $episode->pubdate ? date(DATE_ISO8601, strtotime($episode->pubdate)) : ''; $date = $episode->pubdate ? date('d/m/Y', strtotime($episode->pubdate)) : ''; $duration = $episode->duration ? sprintf('%d:%02d', floor($episode->duration / 60), $episode->duration % 60) : ''; + $pos = $positions[$episode->media_url] ?? 0; ?> - + @@ -73,4 +88,14 @@
{$title} {$title} {$duration}
+ + + {include file="_foot.tpl"}