feat(web+android): Wiedergabe fortsetzen und Streitpunkte im Duell (2.4)

Wiedergabe fortsetzen:
Im Web ueberlebten Warteschlange und letzter Titel bereits, die POSITION im
Titel aber nicht — beim Neuladen begann der Song von vorn. Android sicherte nur
den letzten Song ohne Warteschlange. Beide merken sich jetzt Warteschlange,
Listenposition und Sekunde.

Web schreibt gedrosselt (alle 5 s) waehrend der Wiedergabe und sicher beim
Pausieren, Wegwischen und Tabwechsel — pagehide/visibilitychange statt
beforeunload, das am Handy unzuverlaessig feuert. Android sichert bei jedem
Titelwechsel und beim Pausieren und stellt nach dem Verbinden wieder her:
vorbereitet, aber NICHT abgespielt, wer die App oeffnet will nicht ueberfallen
werden. Laeuft schon etwas (Android Auto), passiert nichts.

Zwei Feinheiten: lag die Position im letzten Rest des Titels, faengt er von vorn
an, statt zwei Sekunden Abspann zu spielen; und gespeichert werden nur Song-Ids,
weil Titel und Cover sich durch Metadaten-Bearbeitung aendern koennen —
verschwundene Titel werden beim Wiederherstellen uebersprungen.

Streitpunkte im Duell:
Gemeinsame Kuenstler nach groesstem Ungleichgewicht statt nach Summe, mit
Kraefteverhaeltnis-Balken. Mindestens vier gemeinsame Plays und 50 % Unwucht,
sonst gilt ein einzelner Play gegen null schon als Streit. Web und Android mit
identischen Schwellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-06 23:20:40 +02:00
1 parent 92c3d96936
commit 65fa950043
8 files changed
+217 -2

No files matched your search

+6 -1
View File
@@ -13,7 +13,7 @@ import {
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, persistPosition, setPlayIcon,
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
} from './player/player.js';
import {
@@ -158,6 +158,9 @@ window.addEventListener('load', () => {
let _errRetries = 0, _errTimer = null;
function bindAudioEl(el) {
el.addEventListener('timeupdate', updateProgress);
// Position mitschreiben (gedrosselt), damit ein Neuladen dort weitermacht, wo du warst.
el.addEventListener('timeupdate', () => persistPosition());
el.addEventListener('pause', () => persistPosition(true));
el.addEventListener('ended', onEnded);
el.addEventListener('play', () => {
S._trackChanging = false;
@@ -315,6 +318,8 @@ window.addEventListener('load', () => {
}
});
window.addEventListener('pagehide', () => persistPosition(true));
document.addEventListener('visibilitychange', () => { if (document.hidden) persistPosition(true); });
window.addEventListener('popstate', (e) => _navOnPopState(e.state));
document.addEventListener('keydown', e => {
if(e.key !== 'Backspace') return;
+34
View File
@@ -1364,6 +1364,25 @@ export function reconcileNativeState() {
} catch {}
}
// Abspielposition mitschreiben. Der Eintrag entsteht beim Titelwechsel; hier wird nur die Position
// nachgetragen, damit ein Neuladen dort weitermacht, wo du warst — nicht am Anfang des Titels.
// Gedrosselt, weil timeupdate mehrmals pro Sekunde feuert und localStorage synchron schreibt.
let _posLastWrite = 0;
export function persistPosition(force = false) {
if (HAS_BRIDGE) return; // nativ verwaltet Media3 den Zustand
const jetzt = Date.now();
if (!force && jetzt - _posLastWrite < 5000) return;
_posLastWrite = jetzt;
try {
const roh = localStorage.getItem('ns_last_played');
if (!roh) return;
const daten = JSON.parse(roh);
daten.position = S.audio?.currentTime || 0;
daten.savedAt = Math.floor(jetzt / 1000);
localStorage.setItem('ns_last_played', JSON.stringify(daten));
} catch {}
}
export function _restoreLastPlayed() {
try {
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
@@ -1388,5 +1407,20 @@ export function _restoreLastPlayed() {
// Web path: prime the audio element so play button works immediately.
S.audio.src = streamUrl(s.id);
S.audio.load();
// Gespeicherte Position wiederherstellen, sobald die Dauer bekannt ist. Lag sie im letzten
// Rest des Titels, faengt der Titel lieber von vorn an — sonst spielt man beim Fortsetzen
// zwei Sekunden Abspann und springt weiter.
const pos = Number(saved.position) || 0;
if (pos > 1) {
const setzen = () => {
const rest = (S.audio.duration || 0) - pos;
if (S.audio.duration && rest > 5) {
try { S.audio.currentTime = pos; } catch {}
updateProgress();
}
S.audio.removeEventListener('loadedmetadata', setzen);
};
S.audio.addEventListener('loadedmetadata', setzen);
}
} catch {}
}
+33
View File
@@ -135,6 +135,7 @@ function _renderCompare(body, d) {
body.innerHTML = `<div class="cmp-body">
<div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
<div class="cmp-wide">${_totals(d)}</div>
<div>${_streitpunkte(d)}</div>
<div>${_sharedArtists(d.sharedArtists)}</div>
<div>${_sharedTracks(d.sharedTracks)}</div>
<div class="cmp-wide">${_uniqueCols(d)}</div>
@@ -177,6 +178,38 @@ function _h(t) {
// Shared artist row: a centered name with each side's play count on its flank + a
// mirrored bar so you can see who plays them more.
// Streitpunkte: gemeinsame Künstler, bei denen die Zählerstände am weitesten auseinanderliegen.
// Umgekehrt zu "Gemeinsame Künstler", das nach Summe sortiert und damit zeigt, worin ihr euch einig
// seid. Sortiert wird nach relativem Ungleichgewicht, sonst stünden nur die meistgehörten oben.
function _streitpunkte(d) {
const items = (d.sharedArtists || [])
.map(a => {
const summe = a.playsA + a.playsB;
return { ...a, unwucht: summe ? Math.abs(a.playsA - a.playsB) / summe : 0, summe };
})
// Ein einzelner Play gegen null ist kein Streit, sondern Zufall — Mindestmasse verlangen.
.filter(a => a.summe >= 4 && a.unwucht >= 0.5)
.sort((x, y) => (y.unwucht - x.unwucht) || (y.summe - x.summe))
.slice(0, 8);
if (!items.length) return `${_h('Streitpunkte')}${_info('Ihr hört gemeinsame Künstler erstaunlich ausgewogen.')}`;
const rows = items.map(a => {
const anteilA = Math.round((a.playsA / a.summe) * 100);
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="padding:7px 0">
<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px;margin-bottom:4px">
<span class="cmp-num">${a.playsA}</span>
<span style="font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.name)}</span>
<span class="cmp-num">${a.playsB}</span>
</div>
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--surface2)">
<div style="width:${anteilA}%;background:var(--accent)"></div>
<div style="width:${100 - anteilA}%;background:var(--accent);opacity:.35"></div>
</div>
</div>`;
}).join('');
return `${_h('Streitpunkte')}<div>${rows}</div>`;
}
function _sharedArtists(items) {
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);