feat(web+android): Lyrics-Mitlaufen umschaltbar, echtes Mischen, Wiederholung am Queue-Ende
Lyrics: Schalter "Mitlaufen" im Lyrics-Reiter, Vorgabe AUS. Hervorgehoben wird die laufende Zeile immer, gescrollt nur bei eingeschaltetem Mitlaufen. Nur bei synchronen Texten sichtbar. Android speichert den Schluessel einzeln, damit das Speichern im Einstellungsbildschirm ihn nicht auf die Vorgabe zuruecksetzt. Mischen (Web): sort(() => Math.random() - 0.5) ist keine Mischung. Gemessen blieb der erste Titel drei- bis fuenfmal zu oft vorn (18,8 % statt 10 % bei n=10, 0,23 % statt 0,05 % bei n=2000). Ersetzt durch Fisher-Yates in queueMath.mischeListe. Queue-Ende (Web): ein beendetes <audio> meldet sich als pausiert, die Wiederaufnahme nach unfreiwilligem Hintergrund-Stop rief play() und spielte den letzten Titel erneut. Jetzt Abbruch bei audio.ended. Shuffle (Android): das Mischen macht jetzt der Player (playSongs(liste, -1)), damit die ungemischte Liste erhalten bleibt — vorher leuchtete das Symbol nicht und "Shuffle aus" konnte die Albumreihenfolge nicht wiederherstellen. Radio-Modus (beide): der laufende Titel ist aus der letzten Rueckfallebene ausgenommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae357b0eb9
commit
b6e98463ee
21 files changed
+227
-29
No files matched your search
+44
-1
@@ -23,7 +23,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
||||
| Dominant color background | ✅ | ✅ |
|
||||
| Cover art scale animation | ❌ | ✅ |
|
||||
| Synced lyrics (LRCLIB) | ✅ | ✅ |
|
||||
| Lyrics autoscroll | ✅ | ✅ |
|
||||
| Lyrics-Mitlaufen (umschaltbar) | ✅ | ✅ |
|
||||
| Star / favorite song | ✅ | ✅ |
|
||||
| Multi-artist navigation | ✅ | ✅ |
|
||||
| Album detail page | ✅ | ✅ |
|
||||
@@ -79,6 +79,49 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
||||
|
||||
---
|
||||
|
||||
## [3.2] — 2026-10-01
|
||||
|
||||
**Android:** 1.25 (versionCode 28) → 1.26 (versionCode 29).
|
||||
|
||||
### Added
|
||||
- **Lyrics: Mitlaufen ein- und ausschalten (Web + Android)** — im Lyrics-Reiter sitzt jetzt ein
|
||||
kleiner Schalter *Mitlaufen / Folgt*. Aus heißt: der Text bleibt stehen, man scrollt selbst.
|
||||
An heißt: der Text springt mit und hält die laufende Zeile bei 40 % Höhe.
|
||||
- **Vorgabe ist AUS.** Wer mitliest, blättert auch mal zurück oder vor — und genau dann sprang
|
||||
der Text bisher bei jeder neuen Zeile unter dem Finger weg.
|
||||
- **Nur bei synchronen Texten sichtbar.** Ohne Zeitstempel gibt es nichts mitzuführen; bei
|
||||
reinem Text erscheint der Schalter nicht.
|
||||
- Hervorgehoben wird die laufende Zeile **immer**, auch bei ausgeschaltetem Mitlaufen — man will
|
||||
ja sehen, wo man ist. Einschalten richtet sofort aus, statt bis zur nächsten Zeile zu warten.
|
||||
- Gespeichert wird pro Gerät (`ns_lyrics_folgen` im Web, `lyrics_autoscroll` in den Android-
|
||||
Einstellungen). Der Android-Schlüssel wird **einzeln** geschrieben: würde ihn das Speichern im
|
||||
Einstellungsbildschirm mitschreiben, setzte es ihn jedes Mal auf die Vorgabe zurück, weil
|
||||
dieser Bildschirm das Feld gar nicht führt.
|
||||
|
||||
### Fixed
|
||||
- **Mischen war nicht zufällig (Web)** — `liste.sort(() => Math.random() - 0.5)` ist keine
|
||||
Mischung: ein Vergleicher, der bei derselben Paarung mal so und mal anders antwortet, ist für
|
||||
eine Sortierung ungültig, und das Ergebnis ist messbar verzerrt. Gemessen (V8, 3000 Durchläufe)
|
||||
blieb der erste Titel drei- bis fünfmal zu oft vorn — bei 10 Titeln in 18,8 % der Fälle statt
|
||||
10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in 0,23 % statt 0,05 %. Die Grobordnung sah dabei
|
||||
gemischt aus, weshalb es lange nicht auffiel; bevorzugt wurden trotzdem immer dieselben Titel.
|
||||
Ersetzt durch Fisher-Yates in `src/player/queueMath.js` (`mischeListe`), verwendet an allen vier
|
||||
Stellen: Warteschlange starten, Mischen umschalten und die beiden Genre-Knöpfe.
|
||||
- **Der letzte Titel lief mehrfach wieder los (Web/PWA)** — am Ende der Warteschlange ohne Repeat
|
||||
steht nichts mehr an, trotzdem spielte der letzte Titel bei jedem Zurückholen der Seite erneut.
|
||||
Ursache: ein beendetes `<audio>` meldet sich als *pausiert*, und die Wiederaufnahme nach einem
|
||||
unfreiwilligen Hintergrund-Stop rief `play()` — das spult dann auf Anfang zurück. Mit Mischen
|
||||
fiel es besonders auf, weil der letzte Titel dort ein beliebiger ist. Jetzt bricht die
|
||||
Wiederaufnahme bei `audio.ended` ab.
|
||||
- **Shuffle-Knopf und tatsächliche Reihenfolge gingen auseinander (Android)** — jeder
|
||||
Shuffle-Knopf mischte selbst (`liste.shuffled()`) und übergab die fertige Reihenfolge. Damit war
|
||||
die „ursprüngliche" Liste schon die gemischte: das Shuffle-Symbol leuchtete nicht, obwohl
|
||||
gemischt gespielt wurde, und *Shuffle aus* konnte die Albumreihenfolge nie wiederherstellen.
|
||||
Das Mischen macht jetzt der Player (`playSongs(liste, -1)`), der die ungemischte Liste behält.
|
||||
- **Radio-Modus konnte den laufenden Titel direkt hinter sich selbst hängen (Web + Android)** — in
|
||||
der letzten Rückfallebene sind Wiederholungen erlaubt, damit Musik weiterläuft; der gerade
|
||||
laufende Titel war davon nicht ausgenommen und spielte dann zweimal in Folge. Jetzt ist er es.
|
||||
|
||||
## [3.1] — 2026-09-22
|
||||
|
||||
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte, 1.25 sie ohne Zeitfenster.
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 28
|
||||
versionName = "1.25"
|
||||
versionCode = 29
|
||||
versionName = "1.26"
|
||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||
|
||||
@@ -29,6 +29,7 @@ class PrefsStore(private val context: Context) {
|
||||
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
|
||||
val KEY_DL_WIFI_ONLY = booleanPreferencesKey("download_wifi_only")
|
||||
val KEY_WISCH_TITEL = booleanPreferencesKey("swipe_changes_track")
|
||||
val KEY_LYRICS_FOLGEN = booleanPreferencesKey("lyrics_autoscroll")
|
||||
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
|
||||
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
||||
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
||||
@@ -64,6 +65,10 @@ class PrefsStore(private val context: Context) {
|
||||
/** Waagerechtes Wischen im Now-Playing wechselt den Titel statt der Ansicht.
|
||||
* Standard an — Titelwechsel ist die Geste, die man dort staendig braucht. */
|
||||
val wischenWechseltTitel: Boolean = true,
|
||||
/** Im Lyrics-Tab automatisch zur laufenden Zeile scrollen. Standard AUS: wer selbst
|
||||
* blaettert, will nicht, dass der Text unter dem Finger wegspringt. Umschalten direkt
|
||||
* im Lyrics-Tab, wirkt nur bei synchronen Texten. */
|
||||
val lyricsFolgenAutomatisch: Boolean = false,
|
||||
)
|
||||
|
||||
data class LastPlayedSong(
|
||||
@@ -104,6 +109,7 @@ class PrefsStore(private val context: Context) {
|
||||
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
|
||||
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
|
||||
wischenWechseltTitel = prefs[KEY_WISCH_TITEL] ?: true,
|
||||
lyricsFolgenAutomatisch = prefs[KEY_LYRICS_FOLGEN] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,9 +147,19 @@ class PrefsStore(private val context: Context) {
|
||||
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
|
||||
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
|
||||
prefs[KEY_WISCH_TITEL] = settings.wischenWechseltTitel
|
||||
// KEY_LYRICS_FOLGEN bewusst NICHT: der Schalter sitzt im Lyrics-Tab und wird mit
|
||||
// saveLyricsFolgen() einzeln geschrieben. Wuerde er hier mitgeschrieben, setzte jedes
|
||||
// Speichern in den Einstellungen ihn auf die Vorgabe zurueck — der Bildschirm fuehrt
|
||||
// dieses Feld gar nicht mit.
|
||||
}
|
||||
}
|
||||
|
||||
/** Einzeln speicherbar: der Schalter sitzt im Lyrics-Tab, nicht in den Einstellungen, und
|
||||
* darf dort nicht den ganzen Einstellungssatz ueberschreiben. */
|
||||
suspend fun saveLyricsFolgen(an: Boolean) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_FOLGEN] = an }
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
||||
}
|
||||
|
||||
@@ -124,18 +124,29 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Startet eine Liste. Ein `startIndex` KLEINER 0 bedeutet "gemischt starten".
|
||||
*
|
||||
* Warum das Mischen hier passiert und nicht beim Aufrufer: vorher mischte jeder
|
||||
* Shuffle-Knopf selbst (`liste.shuffled()`) und uebergab die fertige Reihenfolge. Damit war
|
||||
* `originalQueue` schon die gemischte Liste, `isManuallyShuffled` blieb false — das
|
||||
* Shuffle-Symbol leuchtete nicht, obwohl gemischt gespielt wurde, und ein Druck auf
|
||||
* "Shuffle aus" stellte die Albumreihenfolge nie wieder her, weil es keine mehr gab.
|
||||
*/
|
||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||
// If ABS is active, close it — user explicitly switched to music
|
||||
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
|
||||
if (abs.isActive) abs.close()
|
||||
}
|
||||
val ctrl = controller ?: return
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
isManuallyShuffled = false
|
||||
val gemischt = startIndex < 0
|
||||
val reihenfolge = if (gemischt) songs.shuffled() else songs
|
||||
queue = reihenfolge
|
||||
originalQueue = songs // immer die UNGEMISCHTE Liste — Rueckweg fuer "Shuffle aus"
|
||||
isManuallyShuffled = gemischt
|
||||
ctrl.shuffleModeEnabled = false
|
||||
val items = songs.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, startIndex, 0L)
|
||||
val items = reihenfolge.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, if (gemischt) 0 else startIndex.coerceIn(0, maxOf(0, songs.lastIndex)), 0L)
|
||||
ctrl.prepare()
|
||||
ctrl.play()
|
||||
syncState()
|
||||
@@ -303,7 +314,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
// Letzte Rueckfallebene: Wiederholungen sind erlaubt, damit Musik weiterlaeuft — aber
|
||||
// nicht der gerade laufende Titel. Der haengte sich sonst direkt hinter sich selbst.
|
||||
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
|
||||
.filter { it.id != currentSong.id }
|
||||
}
|
||||
|
||||
val ctrl = controller ?: return
|
||||
|
||||
@@ -376,7 +376,7 @@ fun AppNavigation() {
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onShowAllGenres = { navController.navigate("genres") },
|
||||
@@ -547,7 +547,7 @@ fun AppNavigation() {
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
|
||||
@@ -181,7 +181,7 @@ fun AlbumScreen(
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(album.song.shuffled(), 0) },
|
||||
onClick = { onPlay(album.song, -1) }, // -1 = gemischt, der Player mischt
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
@@ -85,7 +85,7 @@ fun ArtistDetailScreen(
|
||||
|
||||
LaunchedEffect(state.pendingShufflePlay) {
|
||||
state.pendingShufflePlay?.let { songs ->
|
||||
onPlay(songs, 0)
|
||||
onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
|
||||
vm.consumeShufflePlay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ fun ItunesArtistScreen(
|
||||
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
|
||||
onClick = { onSongPlay(artistState.topSongs, -1) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
||||
|
||||
@@ -461,6 +461,13 @@ fun NowPlayingScreen(
|
||||
val lyricsListState = rememberLazyListState()
|
||||
val density = LocalDensity.current
|
||||
var lyricsViewH by remember { mutableIntStateOf(0) }
|
||||
// Mitlaufen: Standard AUS. Der Schalter sitzt hier im Reiter, nicht in den
|
||||
// Einstellungen — man entscheidet beim Lesen, nicht vorher.
|
||||
val folgtMit by produceState(initialValue = false, prefsCtx) {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings
|
||||
.collect { value = it.lyricsFolgenAutomatisch }
|
||||
}
|
||||
val folgenScope = rememberCoroutineScope()
|
||||
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
|
||||
val anchorFrac = 0.40f
|
||||
|
||||
@@ -471,7 +478,10 @@ fun NowPlayingScreen(
|
||||
|
||||
// Center the active line using its *measured* position — no cumulative drift, so
|
||||
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
|
||||
LaunchedEffect(activeIdx, lyricsViewH) {
|
||||
LaunchedEffect(activeIdx, lyricsViewH, folgtMit) {
|
||||
// Ohne Mitlaufen wird nur hervorgehoben, nicht gescrollt — sonst springt der
|
||||
// Text unter dem Finger weg, sobald die naechste Zeile beginnt.
|
||||
if (!folgtMit) return@LaunchedEffect
|
||||
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||
val info = lyricsListState.layoutInfo
|
||||
val anchor = info.viewportStartOffset +
|
||||
@@ -489,6 +499,41 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (lyricsState.lines.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (folgtMit) NsAccent.copy(0.18f) else Color.White.copy(0.08f)
|
||||
)
|
||||
.clickable {
|
||||
folgenScope.launch {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx)
|
||||
.saveLyricsFolgen(!folgtMit)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 11.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown, null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(
|
||||
if (folgtMit) "Folgt" else "Mitlaufen",
|
||||
fontFamily = Outfit, fontSize = 11.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
lyricsState.isLoading -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
|
||||
@@ -152,7 +152,7 @@ fun OfflineDetailScreen(
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar.shuffled(), 0) },
|
||||
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, -1) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
@@ -132,7 +132,7 @@ fun PartialAlbumScreen(
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable { onPlay(downloadedSongs.shuffled(), 0) }
|
||||
.clickable { onPlay(downloadedSongs, -1) }
|
||||
.padding(vertical = 11.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
|
||||
|
||||
@@ -224,7 +224,7 @@ fun PlaylistDetailScreen(
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, -1) }, // -1 = gemischt, der Player mischt
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
@@ -290,7 +290,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
isShuffling = false,
|
||||
pendingShufflePlay = allSongs.shuffled().ifEmpty { null },
|
||||
pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1178,6 +1178,12 @@
|
||||
<div id="np-lyrics-view">
|
||||
<div id="np-lyrics-content"></div>
|
||||
</div>
|
||||
<!-- Mitlaufen an/aus. Nur sichtbar, wenn es synchrone Zeilen gibt — bei reinem Text
|
||||
gibt es nichts mitzuführen. -->
|
||||
<button class="np-follow" id="np-follow-btn" onclick="toggleLyricsFolgen()" title="Automatisch mitlaufen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="12 5 12 19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
<span id="np-follow-text">Mitlaufen</span>
|
||||
</button>
|
||||
|
||||
<div id="np-queue-view">
|
||||
<div id="np-queue-list"></div>
|
||||
|
||||
+10
-2
@@ -37,7 +37,7 @@ import {
|
||||
} from './views/abs.js';
|
||||
import {
|
||||
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
|
||||
openNpMenu, closeNpMenu,
|
||||
openNpMenu, closeNpMenu, toggleLyricsFolgen,
|
||||
} from './player/nowplaying.js';
|
||||
import {
|
||||
openSettings, closeSettings, saveSettings, confirmDisconnect,
|
||||
@@ -126,6 +126,8 @@ async function startApp() {
|
||||
const sr = localStorage.getItem('ns_repeat');
|
||||
if(sr === 'all' || sr === 'one') S.repeat = sr;
|
||||
if(localStorage.getItem('ns_shuffle') === '1') S.shuffle = true;
|
||||
// Mitlaufen der Lyrics: Vorgabe AUS, nur ein ausdruecklich gesetztes '1' schaltet es ein.
|
||||
S.lyricsFolgen = localStorage.getItem('ns_lyrics_folgen') === '1';
|
||||
_applyShuffleRepeat();
|
||||
}
|
||||
|
||||
@@ -216,6 +218,12 @@ window.addEventListener('load', () => {
|
||||
// backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly
|
||||
// auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time.
|
||||
if(!S.audio.paused) return;
|
||||
// Am ENDE der Warteschlange nicht fortsetzen. Ein beendetes <audio> ist zwar "paused", aber
|
||||
// play() spult dann auf Anfang zurueck und spielt denselben Titel NOCH EINMAL. Bei
|
||||
// ausgeschaltetem Repeat steht nach dem letzten Titel nichts mehr an, trotzdem lief er bei
|
||||
// jedem Zurueckholen der Seite wieder los — genau das sah wie "der letzte Titel wiederholt
|
||||
// sich mehrfach" aus, besonders bei Shuffle, wo der Letzte ein beliebiger ist.
|
||||
if(S.audio.ended) return;
|
||||
if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) S.audio.play().catch(() => { S._trackChanging = false; });
|
||||
}
|
||||
|
||||
@@ -360,7 +368,7 @@ Object.assign(window, {
|
||||
toggleStar, dlSongLocal, toggleRadioMode, stopPreview, npToggleStar,
|
||||
// Now Playing
|
||||
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
|
||||
openNpMenu, closeNpMenu,
|
||||
openNpMenu, closeNpMenu, toggleLyricsFolgen,
|
||||
npSeek: () => {},
|
||||
// UI / Modals
|
||||
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
|
||||
|
||||
@@ -123,9 +123,32 @@ function _renderSyncedLines(content, lines) {
|
||||
const bot = document.createElement('div');
|
||||
bot.style.cssText = 'height:40vh;flex-shrink:0';
|
||||
content.appendChild(bot);
|
||||
_zeigeFolgenSchalter(true);
|
||||
updateLyricsHighlight(S.audio.currentTime * 1000);
|
||||
}
|
||||
|
||||
// ─── MITLAUFEN ───
|
||||
// Der Schalter erscheint nur bei synchronen Zeilen: ohne Zeitstempel gibt es nichts mitzufuehren.
|
||||
function _zeigeFolgenSchalter(hatSync) {
|
||||
const btn = document.getElementById('np-follow-btn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('hat-sync', !!hatSync);
|
||||
btn.classList.toggle('active', !!S.lyricsFolgen);
|
||||
const txt = document.getElementById('np-follow-text');
|
||||
if (txt) txt.textContent = S.lyricsFolgen ? 'Folgt' : 'Mitlaufen';
|
||||
btn.title = S.lyricsFolgen
|
||||
? 'Mitlaufen aus — Text von Hand scrollen'
|
||||
: 'Mitlaufen an — Text springt zur laufenden Zeile';
|
||||
}
|
||||
|
||||
export function toggleLyricsFolgen() {
|
||||
S.lyricsFolgen = !S.lyricsFolgen;
|
||||
localStorage.setItem('ns_lyrics_folgen', S.lyricsFolgen ? '1' : '0');
|
||||
_zeigeFolgenSchalter(_lyricsLines.length > 0);
|
||||
// Beim Einschalten sofort zur laufenden Zeile — sonst wartet man bis zur naechsten.
|
||||
if (S.lyricsFolgen) updateLyricsHighlight(S.audio.currentTime * 1000);
|
||||
}
|
||||
|
||||
function _parseLrc(text) {
|
||||
let offsetMs = 0;
|
||||
const lines = [];
|
||||
@@ -143,7 +166,7 @@ async function _loadNpLyrics(songId) {
|
||||
const content = document.getElementById('np-lyrics-content');
|
||||
if(!content) return;
|
||||
if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) {
|
||||
if(_lyricsLines.length) updateLyricsHighlight(S.audio.currentTime * 1000);
|
||||
if(_lyricsLines.length) { _zeigeFolgenSchalter(true); updateLyricsHighlight(S.audio.currentTime * 1000); }
|
||||
return;
|
||||
}
|
||||
_lyricsSongId = songId;
|
||||
@@ -253,6 +276,7 @@ function _showLyricsResult(content, result) {
|
||||
const parsed = _parseLrc(result.syncedLyrics);
|
||||
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
|
||||
}
|
||||
_zeigeFolgenSchalter(false);
|
||||
if(result?.plainLyrics) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lyrics-plain';
|
||||
@@ -279,7 +303,8 @@ export function updateLyricsHighlight(currentMs) {
|
||||
line.el.classList.toggle('active', i === activeIdx);
|
||||
line.el.classList.toggle('past', i < activeIdx);
|
||||
});
|
||||
if(activeIdx >= 0) {
|
||||
// Hervorheben passiert immer, Scrollen nur wenn Mitlaufen eingeschaltet ist.
|
||||
if(activeIdx >= 0 && S.lyricsFolgen) {
|
||||
const activeEl = _lyricsLines[activeIdx].el;
|
||||
const container = document.getElementById('np-lyrics-view');
|
||||
if(container) {
|
||||
@@ -294,6 +319,7 @@ export function onNpSongChange(songId) {
|
||||
_lyricsLines = [];
|
||||
const content = document.getElementById('np-lyrics-content');
|
||||
if(content) content.innerHTML = '';
|
||||
_zeigeFolgenSchalter(false);
|
||||
// Warm the cache shortly AFTER the track starts — deferred so the lyrics requests
|
||||
// don't compete with the audio stream and delay playback start.
|
||||
clearTimeout(_prefetchTimer);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { loadRecommendations } from '../views/views.js';
|
||||
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying, renderNpQueue } from './nowplaying.js';
|
||||
import { logPlay } from './scrobbleLog.js';
|
||||
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
|
||||
import { queueNachEntfernen, queueNachVerschieben } from './queueMath.js';
|
||||
import { queueNachEntfernen, queueNachVerschieben, mischeListe } from './queueMath.js';
|
||||
|
||||
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
|
||||
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
|
||||
@@ -366,7 +366,7 @@ export function playQueue(songs, startIdx, opts = {}) {
|
||||
// opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page).
|
||||
if (S.shuffle && songs.length > 1 && !opts.noShuffle) {
|
||||
S._origQueue = songs.map(s => ({...s}));
|
||||
S.queue = [...songs].sort(() => Math.random() - 0.5).map(s => ({...s}));
|
||||
S.queue = mischeListe(songs).map(s => ({...s}));
|
||||
S.qIdx = 0;
|
||||
} else {
|
||||
S.queue = songs.map(s => ({...s}));
|
||||
@@ -793,7 +793,7 @@ export function toggleShuffle() {
|
||||
if (S.shuffle) {
|
||||
const cur = S.queue[S.qIdx];
|
||||
S._origQueue = [...S.queue];
|
||||
const rest = S.queue.filter((_, i) => i !== S.qIdx).sort(() => Math.random() - 0.5);
|
||||
const rest = mischeListe(S.queue.filter((_, i) => i !== S.qIdx));
|
||||
S.queue = [cur, ...rest];
|
||||
S.qIdx = 0;
|
||||
} else if (S._origQueue) {
|
||||
@@ -871,8 +871,12 @@ export async function fillRadioQueue() {
|
||||
if (gen !== _queueGen) return;
|
||||
const rr = await api('getRandomSongs', '&size=20');
|
||||
const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id));
|
||||
// If random is also exhausted, allow repeats to keep music playing
|
||||
newSongs = randomFresh.length > 0 ? randomFresh : newSongs.slice(0, 5);
|
||||
// Ist auch der Zufall erschoepft, sind Wiederholungen erlaubt, damit Musik laeuft — aber
|
||||
// NIE der gerade laufende Titel: der landete sonst direkt hinter sich selbst und spielte
|
||||
// zweimal in Folge.
|
||||
newSongs = randomFresh.length > 0
|
||||
? randomFresh
|
||||
: newSongs.filter(s => s.id !== seed?.id).slice(0, 5);
|
||||
} else {
|
||||
newSongs = deduped;
|
||||
}
|
||||
|
||||
@@ -35,3 +35,24 @@ export function queueNachVerschieben(ids, qIdx, von, nach) {
|
||||
const gefunden = neu.indexOf(laufende);
|
||||
return { ids: neu, qIdx: gefunden >= 0 ? gefunden : qIdx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Echte Mischung (Fisher-Yates). Liefert eine NEUE Liste, die Eingabe bleibt unberuehrt.
|
||||
*
|
||||
* Warum nicht `liste.sort(() => Math.random() - 0.5)`: ein Vergleicher, der bei derselben Paarung
|
||||
* mal so und mal anders antwortet, ist fuer eine Sortierung ungueltig, und das Ergebnis ist nicht
|
||||
* gleichverteilt. Gemessen (V8, 3000 Durchlaeufe je Laenge) bleibt der ERSTE Titel viel zu oft
|
||||
* vorn: bei 10 Titeln in 18,8 % der Faelle statt 10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in
|
||||
* 0,23 % statt 0,05 % — also durchweg das Drei- bis Fuenffache. Die Grobordnung sieht dabei
|
||||
* gemischt aus, weshalb es lange nicht auffaellt; systematisch bevorzugt werden trotzdem immer
|
||||
* dieselben Titel. Fisher-Yates zieht fuer jede Position gleichverteilt aus dem Rest und traf in
|
||||
* derselben Messung 9,7 % / 0,87 % / 0,03 %.
|
||||
*/
|
||||
export function mischeListe(liste, rnd = Math.random) {
|
||||
const a = liste.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rnd() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const S = {
|
||||
npOpen: false,
|
||||
npQueueMode: false,
|
||||
npLyricsMode: false,
|
||||
// Lyrics: automatisch mitlaufen oder von Hand scrollen. Standard AUS — wer liest, will nicht,
|
||||
// dass der Text unter dem Finger wegspringt; einschalten geht im Lyrics-Tab mit einem Druck.
|
||||
lyricsFolgen: false,
|
||||
|
||||
// Navigation history
|
||||
_navStack: [],
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@ import { esc } from '../utils.js';
|
||||
import { api, apiCached, apiIsCached, invalidateApiCache, coverUrl, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames } from '../api.js';
|
||||
import { makeCard, makeAlbumCard, makeSongItem } from '../ui/components.js';
|
||||
import { playQueue } from '../player/player.js';
|
||||
import { mischeListe } from '../player/queueMath.js';
|
||||
// Circular — safe (called inside event handlers)
|
||||
import { navigate, _navGoBack } from './navigation.js';
|
||||
|
||||
@@ -161,7 +162,7 @@ async function playGenre(genre) {
|
||||
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
|
||||
const songs = r.songsByGenre?.song || [];
|
||||
if (!songs.length) return;
|
||||
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
|
||||
playQueue(mischeListe(songs), 0);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -187,7 +188,7 @@ export async function loadGenreDetail(g) {
|
||||
|
||||
document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`;
|
||||
document.getElementById('btn-play-genre').onclick = () => {
|
||||
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
|
||||
playQueue(mischeListe(songs), 0);
|
||||
};
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
+11
@@ -816,6 +816,17 @@ button:focus{outline:none}
|
||||
.lyrics-line{font-size:18px;font-weight:600;line-height:1.85;color:rgba(255,255,255,.22);transition:color .3s,transform .3s,opacity .3s;padding:5px 0;cursor:pointer;text-align:center;transform:scale(1);transform-origin:center center;will-change:transform}
|
||||
.lyrics-line.past{color:rgba(255,255,255,.45)}
|
||||
.lyrics-line.active{color:#fff;transform:scale(1.04)}
|
||||
/* Mitlaufen-Schalter: schwebt unten rechts ueber dem Text, nur im Lyrics-Tab und nur bei
|
||||
synchronen Zeilen (Klasse .hat-sync setzt das Skript). Aus = gedaempft, an = Akzentfarbe. */
|
||||
.np-follow{display:none;position:absolute;right:22px;bottom:22px;z-index:3;align-items:center;gap:6px;
|
||||
background:rgba(0,0,0,.42);border:1px solid rgba(255,255,255,.14);color:rgba(255,255,255,.6);
|
||||
font-family:var(--font);font-size:11px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;
|
||||
padding:7px 12px;border-radius:18px;cursor:pointer;backdrop-filter:blur(10px);
|
||||
transition:color .2s,border-color .2s,background .2s}
|
||||
.np-overlay.lyrics-mode .np-follow.hat-sync{display:flex}
|
||||
.np-follow:hover{background:rgba(0,0,0,.6);color:rgba(255,255,255,.85)}
|
||||
.np-follow.active{color:var(--blue);border-color:color-mix(in srgb,var(--blue) 55%,transparent)}
|
||||
.np-follow svg{pointer-events:none;flex-shrink:0}
|
||||
.lyrics-plain{font-size:15px;line-height:1.9;color:rgba(255,255,255,.65);white-space:pre-wrap;text-align:center;padding-top:24px}
|
||||
.section-count{font-size:11px;font-weight:500;color:var(--text3);background:var(--surface2);padding:2px 8px;border-radius:10px;flex-shrink:0}
|
||||
.album-partial-badge{position:absolute;bottom:5px;left:5px;background:rgba(0,0,0,.72);color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;pointer-events:none;z-index:2}
|
||||
|
||||
Reference in new issue
Block a user