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
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user