Skip to content
Open
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
155 changes: 155 additions & 0 deletions server/player.js
Original file line number Diff line number Diff line change
@@ -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'); });
})();
82 changes: 82 additions & 0 deletions server/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 26 additions & 1 deletion server/templates/feed.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@
{/if}

{if count($episodes)}
<?php
// Map media_url => 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'];
}
}
?>
<h2>Episodes ({$episodes|count})</h2>
<table>
<thead>
Expand All @@ -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;
?>
<tr>
<th scope="row"><a href="{$episode.media_url}">{$title}</a></th>
<th scope="row"><button type="button" class="play-btn" title="Play in browser" data-media="{$episode.media_url}" data-podcast="{$feed.feed_url}" data-title="{$title}" data-pos="{$pos}" data-total="{$episode.duration}">&#9654;</button> <a href="{$episode.media_url}">{$title}</a></th>
<td><time datetime="{$iso_date}">{$date}</time></td>
<td>{$duration}</td>
</tr>
Expand Down Expand Up @@ -73,4 +88,14 @@
</tbody>
</table>

<div id="player-bar" hidden>
<div id="pb-meta">
<div id="pb-title"></div>
<div id="pb-status" class="help"></div>
</div>
<audio id="pb-audio" controls preload="metadata"></audio>
<button type="button" id="pb-close" title="Close player">&#10005;</button>
</div>
<script src="player.js"></script>

{include file="_foot.tpl"}