DeadAir plays an HLS playlist of the original, with a pause of your own (#181, #182)

DeadAir made a shortened copy of each episode with ffmpeg: as much disk
again as the original, one pause length for everyone, and a minutes-long
wait after the download. Seeking past silences instead was tried on an
iPhone and left about 1.3 s of nothing at every cut (#178).

Now it finds a file's silences once (silencedetect, plus ffprobe for
each frame's byte position) and stores a plan for each pause a person
can choose, 0.2 to 0.5 s: the cuts, and the frames kept as byte ranges
of the original. /media/{id}/deadair.m3u8 hands this person's plan out
as an HLS playlist of ranges of /media/{id}/deadair.mp3, the original.
Tested on iOS Safari first (#180): no gap at the cuts, plays locked,
seeks exactly, and needs no ID3 timestamp in front of each piece.

Other browsers get hls.js (1.6.19, the light build), loaded only when
needed. Not where canPlayType says it can play HLS: Chromium answers
"maybe" and then fails on MP3 ranges with "stream parsing failed", so
only Apple's browsers play the playlist themselves.

The pause is per person and per show, on deadair_prefs.pause_ms, 0.3 s
unless chosen; 0.2 s, what the copies left, made speech sound rushed.
AAC in MP4 has no ranges a playlist can use, so only MP3 and ADTS AAC
are shortened (the library is 803 MP3s and no .m4a). The copies, their
table and #179's expiry go: the first start drops the table and deletes
the files it names, only those named name.deadair.*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 19:46:01 +00:00
parent f29d493a0e
commit 089202f72b
15 changed files with 564 additions and 440 deletions

View File

@@ -30,6 +30,11 @@ const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
@@ -51,7 +56,8 @@ export function buildStyle({ minify = true } = {}) {
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_URL);
const js = swc.transformSync(src, {
filename: name + '.ts',
jsc: {
@@ -88,6 +94,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);

View File

@@ -530,7 +530,11 @@ function settingsModal(f, newUrl?: string){
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
are part of the telling.</p></div>
are part of the telling.</p>
<span class="flabel" id="sdpausel">Pause it leaves</span>
<div class="tabs seg" role="radiogroup" aria-labelledby="sdpausel">${[200,300,400,500].map(ms=>
`<label><input type="radio" name="sdpause" value="${ms}"${(f.deadair_pause||300)===ms?' checked':''}>${ms/1000} s</label>`).join('')}</div>
<p class="cap">A longer pause sounds more relaxed and saves less time.</p></div>
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
@@ -614,7 +618,8 @@ function settingsModal(f, newUrl?: string){
const patch: Record<string, unknown>={
keywords:chipWords($('#skw')),
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked};
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
deadair_pause:Number($('[name=sdpause]:checked').value)};
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
// API refuses it from anyone else. The folder and category are not this dialog's at all.

View File

@@ -3,9 +3,34 @@ const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
/// Where a file plays from: DeadAir's copy when the server says this person plays it (#171),
/// whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair`:`/media/${enc.id}`;
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
/// on the playlist's MP3 ranges with "stream parsing failed", and so plays nothing.
let hls: any=null;
function dropHls(){ if(hls){ hls.destroy(); hls=null; } }
/// Puts a file in the player: null when it is there at once, else a promise to wait on before
/// playing, while hls.js loads. Safari always takes the first way, where play() has to run within
/// the tap itself or it is refused.
function setSrc(url: string): Promise<void>|null{
dropHls();
const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl');
if(!url.endsWith('.m3u8')||native){ audio.src=url; return null; }
const w=window as any;
const ready=w.Hls?Promise.resolve():new Promise<void>((ok,fail)=>{
const s=document.createElement('script');
s.src='__HLS_JS__'; s.onload=()=>ok(); s.onerror=()=>fail(new Error('the DeadAir player did not load'));
document.head.append(s);
});
return ready.then(()=>{
hls=new w.Hls();
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal) toast('Playback failed: '+d.details,true); });
hls.loadSource(url); hls.attachMedia(audio);
});
}
// Marked read when an item has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the item vanish from the Unread list the instant
@@ -29,10 +54,11 @@ function play(e,enc=e.enclosures.find(isPlayable)){
const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc);
// Every play button is a pause button for what is playing, as the player bar's is.
if(resuming && !audio.paused){ audio.pause(); return; }
let wait: Promise<void>|null=null;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=player.src=mediaSrc(enc);
wait=setSrc(player.src=mediaSrc(enc));
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
@@ -45,7 +71,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
player.marked=false;
paintDead();
}
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
renderEntries();
}
function mediaSession(e,f){
@@ -153,12 +180,12 @@ async function switchVersion(){
if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused;
player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position;
audio.src=player.src;
const wait=setSrc(player.src);
audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
if(was) audio.play().catch(()=>{});
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
return true;
}
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};
(function restore(){