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:
1 parent
92c3d96936
commit
65fa950043
8 files changed
+217
-2
No files matched your search
+21
-1
@@ -37,6 +37,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
| Playlist cover upload | ✅ | ✅ |
|
| Playlist cover upload | ✅ | ✅ |
|
||||||
| Spotify / YouTube importer | ✅ | ✅ |
|
| Spotify / YouTube importer | ✅ | ✅ |
|
||||||
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
||||||
|
| Wiedergabe fortsetzen | ✅ | ✅ |
|
||||||
| Teilen per Link | ✅ | ✅ |
|
| Teilen per Link | ✅ | ✅ |
|
||||||
| Playlist aus Import anlegen | ✅ | ✅ |
|
| Playlist aus Import anlegen | ✅ | ✅ |
|
||||||
| Song von Festplatte löschen | ✅ | ✅ |
|
| Song von Festplatte löschen | ✅ | ✅ |
|
||||||
@@ -68,6 +69,26 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.4] — 2026-09-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Wiedergabe fortsetzen, wo du aufgehört hast (Web + Android)** — bisher überlebte im Web nur der
|
||||||
|
zuletzt gespielte Titel und die Warteschlange, aber **nicht die Position im Titel**; Android
|
||||||
|
sicherte allein den letzten Song, ohne Warteschlange. Jetzt merken sich beide Warteschlange,
|
||||||
|
Listenposition und Sekunde im Titel. Web schreibt gedrosselt beim Abspielen sowie sicher beim
|
||||||
|
Pausieren, Wegwischen und Wechseln des Tabs (`pagehide`/`visibilitychange` statt `beforeunload`,
|
||||||
|
das am Handy unzuverlässig ist). Android sichert bei jedem Titelwechsel und beim Pausieren und
|
||||||
|
stellt die Warteschlange nach dem Verbinden wieder her — **vorbereitet, aber nicht abgespielt**:
|
||||||
|
ein Druck auf Play macht weiter. Lag die Position im letzten Rest des Titels, beginnt er von vorn,
|
||||||
|
statt zwei Sekunden Abspann zu spielen. Gespeichert werden nur Song-Ids; verschwundene Titel
|
||||||
|
werden beim Wiederherstellen übersprungen.
|
||||||
|
- **Streitpunkte im Musik-Duell (Web + Android)** — gemeinsame Künstler, sortiert nach dem größten
|
||||||
|
**Ungleichgewicht** statt nach der Summe: wo einer 120 Plays hat und der andere 3. Ein Balken
|
||||||
|
zeigt das Kräfteverhältnis. Mindestens vier gemeinsame Plays und 50 % Unwucht, damit ein einzelner
|
||||||
|
Play gegen null nicht als Streit gilt. Beide Plattformen nutzen dieselben Schwellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.3] — 2026-09-04
|
## [2.3] — 2026-09-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -739,7 +760,6 @@ Polish release: Android navigation and offline resilience, download accuracy for
|
|||||||
- **Android** — Romaji / Japanese sort support
|
- **Android** — Romaji / Japanese sort support
|
||||||
- **Web + Android** — Backend settings UI rework (move, reorganize)
|
- **Web + Android** — Backend settings UI rework (move, reorganize)
|
||||||
- **Web + Android** — yt-dlp backend update / maintenance
|
- **Web + Android** — yt-dlp backend update / maintenance
|
||||||
- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded)
|
|
||||||
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
|
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
|
||||||
|
|
||||||
### Known Bugs
|
### Known Bugs
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ class MusicRepository(private val context: Context) {
|
|||||||
|
|
||||||
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
||||||
|
|
||||||
|
suspend fun saveQueueState(ids: List<String>, index: Int, positionMs: Long) =
|
||||||
|
prefs.saveQueueState(ids, index, positionMs)
|
||||||
|
suspend fun readQueueState() = prefs.readQueueState()
|
||||||
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
|
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
|
||||||
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
|
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package com.novasonic.app.data
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
||||||
@@ -29,6 +32,10 @@ class PrefsStore(private val context: Context) {
|
|||||||
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
||||||
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
||||||
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
|
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
|
||||||
|
// Warteschlange fortsetzen: Song-Ids, Position in der Liste und Position im Titel.
|
||||||
|
val KEY_QUEUE_IDS = stringPreferencesKey("queue_ids")
|
||||||
|
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index")
|
||||||
|
val KEY_QUEUE_POSITION = longPreferencesKey("queue_position_ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
||||||
@@ -137,6 +144,31 @@ class PrefsStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichert die Warteschlange als Id-Liste samt Position. Nur Ids, keine vollstaendigen Songs:
|
||||||
|
* Titel und Cover koennen sich aendern (Metadaten-Bearbeitung), die Id ist der stabile Bezug.
|
||||||
|
*/
|
||||||
|
suspend fun saveQueueState(ids: List<String>, index: Int, positionMs: Long) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_QUEUE_IDS] = ids.joinToString(",")
|
||||||
|
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
|
||||||
|
prefs[KEY_QUEUE_POSITION] = positionMs.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class QueueState(val ids: List<String>, val index: Int, val positionMs: Long)
|
||||||
|
|
||||||
|
suspend fun readQueueState(): QueueState? {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
val ids = prefs[KEY_QUEUE_IDS]?.split(',')?.filter { it.isNotBlank() }.orEmpty()
|
||||||
|
if (ids.isEmpty()) return null
|
||||||
|
return QueueState(
|
||||||
|
ids = ids,
|
||||||
|
index = (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, ids.lastIndex),
|
||||||
|
positionMs = prefs[KEY_QUEUE_POSITION] ?: 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
context.dataStore.edit { it.clear() }
|
context.dataStore.edit { it.clear() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,45 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
controller = runCatching { future.get() }.getOrNull()
|
controller = runCatching { future.get() }.getOrNull()
|
||||||
controller?.addListener(PlayerListener())
|
controller?.addListener(PlayerListener())
|
||||||
syncState()
|
syncState()
|
||||||
|
restoreQueue()
|
||||||
}, context.mainExecutor)
|
}, context.mainExecutor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haelt Warteschlange, Listenposition und Position im Titel fest. ABS-Hoerbuecher bleiben aussen
|
||||||
|
* vor — die haben ihren eigenen Fortschritt auf dem Server.
|
||||||
|
*/
|
||||||
|
private fun persistQueueState() {
|
||||||
|
val ctrl = controller ?: return
|
||||||
|
val ids = queue.map { it.id }
|
||||||
|
if (ids.isEmpty() || ids.any { it.startsWith("abs:") }) return
|
||||||
|
val index = ctrl.currentMediaItemIndex
|
||||||
|
val pos = ctrl.currentPosition
|
||||||
|
scope.launch { runCatching { repo.saveQueueState(ids, index, pos) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt.
|
||||||
|
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter,
|
||||||
|
* wo es aufgehoert hat. Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
|
||||||
|
*/
|
||||||
|
fun restoreQueue() {
|
||||||
|
val ctrl = controller ?: return
|
||||||
|
if (ctrl.mediaItemCount > 0) return
|
||||||
|
scope.launch {
|
||||||
|
val zustand = runCatching { repo.readQueueState() }.getOrNull() ?: return@launch
|
||||||
|
// Songs einzeln nachladen; verschwundene (geloescht, umbenannt) werden uebersprungen.
|
||||||
|
val songs = zustand.ids.mapNotNull { runCatching { repo.getSong(it) }.getOrNull() }
|
||||||
|
if (songs.isEmpty()) return@launch
|
||||||
|
val index = zustand.index.coerceIn(0, songs.lastIndex)
|
||||||
|
queue = songs
|
||||||
|
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, zustand.positionMs)
|
||||||
|
ctrl.prepare()
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Playback commands ─────────────────────────────────────────────────────
|
// ── Playback commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||||
@@ -317,6 +352,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
if (isPlaying) startTicker() else stopTicker()
|
if (isPlaying) startTicker() else stopTicker()
|
||||||
syncState()
|
syncState()
|
||||||
|
// Beim Pausieren die Position festhalten: von da soll es beim naechsten Start weitergehen.
|
||||||
|
if (!isPlaying) persistQueueState()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(state: Int) = syncState()
|
override fun onPlaybackStateChanged(state: Int) = syncState()
|
||||||
@@ -336,6 +373,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
|
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
|
||||||
scheduleScrobble(song, dur)
|
scheduleScrobble(song, dur)
|
||||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||||
|
persistQueueState()
|
||||||
prefetchLyrics(song)
|
prefetchLyrics(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ private fun CompareContent(
|
|||||||
Spacer(Modifier.height(22.dp))
|
Spacer(Modifier.height(22.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Streitpunkte(d, onArtistClick)
|
||||||
|
|
||||||
SectionHeader("Nur bei…")
|
SectionHeader("Nur bei…")
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
|
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
|
||||||
@@ -181,6 +183,54 @@ private fun CompareContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) {
|
||||||
|
// Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame
|
||||||
|
// Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht
|
||||||
|
// als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen.
|
||||||
|
val items = d.sharedArtists
|
||||||
|
.map { a ->
|
||||||
|
val summe = a.playsA + a.playsB
|
||||||
|
Triple(a, summe, if (summe > 0) kotlin.math.abs(a.playsA - a.playsB) / summe.toFloat() else 0f)
|
||||||
|
}
|
||||||
|
.filter { (_, summe, unwucht) -> summe >= 4 && unwucht >= 0.5f }
|
||||||
|
.sortedWith(compareByDescending<Triple<SharedArtist, Int, Float>> { it.third }.thenByDescending { it.second })
|
||||||
|
.take(8)
|
||||||
|
|
||||||
|
SectionHeader("Streitpunkte")
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Text("Ihr hört gemeinsame Künstler erstaunlich ausgewogen.",
|
||||||
|
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.forEach { (a, summe, _) ->
|
||||||
|
val anteilA = a.playsA / summe.toFloat()
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.clickable { a.artistId?.let(onArtistClick) }
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
fontWeight = FontWeight.Bold)
|
||||||
|
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 8.dp))
|
||||||
|
Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) {
|
||||||
|
Box(Modifier.weight(anteilA.coerceAtLeast(0.001f)).fillMaxHeight().background(NsAccent))
|
||||||
|
Box(Modifier.weight((1f - anteilA).coerceAtLeast(0.001f)).fillMaxHeight()
|
||||||
|
.background(NsAccent.copy(alpha = .35f)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Scoreboard(d: CompareResult) {
|
private fun Scoreboard(d: CompareResult) {
|
||||||
val b = d.battle
|
val b = d.battle
|
||||||
|
|||||||
+6
-1
@@ -13,7 +13,7 @@ import {
|
|||||||
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
|
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
|
||||||
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
|
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
|
||||||
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
|
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
|
||||||
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
|
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, persistPosition, setPlayIcon,
|
||||||
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
|
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
|
||||||
} from './player/player.js';
|
} from './player/player.js';
|
||||||
import {
|
import {
|
||||||
@@ -158,6 +158,9 @@ window.addEventListener('load', () => {
|
|||||||
let _errRetries = 0, _errTimer = null;
|
let _errRetries = 0, _errTimer = null;
|
||||||
function bindAudioEl(el) {
|
function bindAudioEl(el) {
|
||||||
el.addEventListener('timeupdate', updateProgress);
|
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('ended', onEnded);
|
||||||
el.addEventListener('play', () => {
|
el.addEventListener('play', () => {
|
||||||
S._trackChanging = false;
|
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));
|
window.addEventListener('popstate', (e) => _navOnPopState(e.state));
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if(e.key !== 'Backspace') return;
|
if(e.key !== 'Backspace') return;
|
||||||
|
|||||||
@@ -1364,6 +1364,25 @@ export function reconcileNativeState() {
|
|||||||
} catch {}
|
} 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() {
|
export function _restoreLastPlayed() {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
|
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.
|
// Web path: prime the audio element so play button works immediately.
|
||||||
S.audio.src = streamUrl(s.id);
|
S.audio.src = streamUrl(s.id);
|
||||||
S.audio.load();
|
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 {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -135,6 +135,7 @@ function _renderCompare(body, d) {
|
|||||||
body.innerHTML = `<div class="cmp-body">
|
body.innerHTML = `<div class="cmp-body">
|
||||||
<div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
|
<div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
|
||||||
<div class="cmp-wide">${_totals(d)}</div>
|
<div class="cmp-wide">${_totals(d)}</div>
|
||||||
|
<div>${_streitpunkte(d)}</div>
|
||||||
<div>${_sharedArtists(d.sharedArtists)}</div>
|
<div>${_sharedArtists(d.sharedArtists)}</div>
|
||||||
<div>${_sharedTracks(d.sharedTracks)}</div>
|
<div>${_sharedTracks(d.sharedTracks)}</div>
|
||||||
<div class="cmp-wide">${_uniqueCols(d)}</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
|
// 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.
|
// 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) {
|
function _sharedArtists(items) {
|
||||||
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
|
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);
|
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
|
||||||
|
|||||||
Reference in new issue
Block a user