feat: Wischgeste wechselt Titel, Designs Violet und Crimson, Mischen-Anzeige gefixt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-22 21:04:37 +02:00
1 parent 549bec85ce
commit 899c98977a
9 files changed
+203 -23

No files matched your search

@@ -28,6 +28,7 @@ class PrefsStore(private val context: Context) {
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
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_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")
@@ -60,6 +61,9 @@ class PrefsStore(private val context: Context) {
/** Offline-Downloads nur ueber WLAN. Standard an: eine grosse Playlist sind schnell
* mehrere Gigabyte, und die Wiederholkette laeuft ohne Zutun weiter. */
val downloadWifiOnly: Boolean = true,
/** 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,
)
data class LastPlayedSong(
@@ -99,6 +103,7 @@ class PrefsStore(private val context: Context) {
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
wischenWechseltTitel = prefs[KEY_WISCH_TITEL] ?: true,
)
}
@@ -135,6 +140,7 @@ class PrefsStore(private val context: Context) {
prefs[KEY_ABS_API_KEY] = settings.absApiKey
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
prefs[KEY_WISCH_TITEL] = settings.wischenWechseltTitel
}
}
@@ -251,22 +251,22 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val ctrl = controller ?: return
val currentIdx = ctrl.currentMediaItemIndex
if (!isManuallyShuffled) {
// Der Rest hinter dem laufenden Titel wird in EINEM Zug ersetzt. Vorher lief das als
// Schleife aus removeMediaItem + addMediaItem, und dazwischen hatte der Controller
// voruebergehend weniger Titel als unsere Liste. Genau darauf reagiert onTimelineChanged
// mit "die Titel kamen von aussen" — es baute die Warteschlange neu auf und setzte dabei
// isManuallyShuffled ZURUECK. Das Mischen lief, aber das Symbol leuchtete nie.
val neuerRest = if (!isManuallyShuffled) {
isManuallyShuffled = true
val played = queue.take(currentIdx + 1)
val remaining = queue.drop(currentIdx + 1).shuffled()
queue = played + remaining
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
queue.drop(currentIdx + 1).shuffled()
} else {
isManuallyShuffled = false
val currentSong = queue.getOrNull(currentIdx)
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
val restOfOriginal = originalQueue.drop(origIdx + 1)
queue = originalQueue.take(origIdx + 1) + restOfOriginal
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
originalQueue.drop(origIdx + 1)
}
queue = queue.take(currentIdx + 1) + neuerRest
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
syncState()
}
@@ -83,9 +83,14 @@ fun NowPlayingScreen(
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a
// child pointerInput and consumes the drag before the pager sees it.
// Waagerechtes Wischen: standardmaessig wechselt es den TITEL — das ist die Geste, die man hier
// staendig braucht. Wer lieber zwischen Player, Warteschlange und Text blaettert, stellt es in
// den Einstellungen um; dann uebernimmt wieder der Pager. Die Reiter oben schalten in beiden
// Faellen weiter, es geht also nie eine Ansicht verloren.
val prefsCtx = LocalContext.current
val wischTitel by produceState(initialValue = true, prefsCtx) {
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings.collect { value = it.wischenWechseltTitel }
}
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
LaunchedEffect(tab) {
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
@@ -232,11 +237,31 @@ fun NowPlayingScreen(
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
HorizontalPager(
state = pagerState,
userScrollEnabled = !wischTitel,
modifier = Modifier.weight(1f).fillMaxWidth(),
) { page ->
// Die ganze Player-Seite nimmt die Wischgeste an, nicht nur das Cover: sonst muesste
// man genau das Bild treffen, und darunter liegt die halbe Seite. Auf Warteschlange
// und Text liegt sie NICHT — dort wuerde sie mit dem Ziehen in der Liste streiten.
var seitenDrag by remember { mutableStateOf(0f) }
val wischbar = wischTitel && NpTab.entries[page] == NpTab.PLAYER
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(
if (!wischbar) Modifier else Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (seitenDrag < -80) controller.skipNext()
else if (seitenDrag > 80) controller.skipPrev()
seitenDrag = 0f
},
onDragCancel = { seitenDrag = 0f },
onHorizontalDrag = { _, d -> seitenDrag += d },
)
}
),
) {
when (NpTab.entries[page]) {
// ── PLAYER TAB ─────────────────────────────────────────────────
@@ -391,7 +416,7 @@ fun NowPlayingScreen(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
}
@@ -417,7 +442,8 @@ fun NowPlayingScreen(
NpCircleBtn(onClick = { controller.skipNext() }) {
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
}
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
val (icon, tint) = when (state.repeatMode) {
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
@@ -768,11 +794,34 @@ private fun NpSeekBar(
}
@Composable
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
private fun NpCircleBtn(
onClick: () -> Unit,
aktiv: Boolean = false,
content: @Composable () -> Unit,
) {
// Ein eingefaerbtes Symbol allein ist als "an" schwer zu erkennen — auf dem dunklen Cover
// sieht Akzentfarbe wie eine Schattierung aus. Ist der Schalter an, liegt deshalb zusaetzlich
// ein getoenter Kreis darunter und ein Punkt darunter, wie in gaengigen Playern.
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
) { content() }
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(if (aktiv) NsAccent.copy(alpha = 0.22f) else Color.Transparent)
.clickable(onClick = onClick),
) {
content()
if (aktiv) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 5.dp)
.size(4.dp)
.clip(CircleShape)
.background(NsAccent)
)
}
}
}
private fun formatMs(ms: Long): String {
@@ -122,6 +122,30 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
)
}
// ── ▶ Wiedergabe ────────────────────────────────────────────────────
SectionHeader("▶ Wiedergabe")
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Wischen wechselt den Titel", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text(
if (state.wischenWechseltTitel)
"Im Now-Playing: seitwärts wischen springt zum nächsten oder vorherigen Titel"
else
"Im Now-Playing: seitwärts wischen blättert zwischen Player, Warteschlange und Text",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
Switch(
checked = state.wischenWechseltTitel,
onCheckedChange = { vm.onWischenWechseltTitel(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
// ── 📥 Downloads ─────────────────────────────────────────────────────
SectionHeader("📥 Offline-Downloads")
@@ -85,6 +85,20 @@ val NS_THEMES = listOf(
Color(0xFF162A21), Color(0xFF1E3A2D),
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
),
NsTheme(
"violet", "Violet",
Color(0xFFA78BFA), Color(0xFF7C5CF0),
Color(0xFF0D0916), Color(0xFF140E20),
Color(0xFF221835), Color(0xFF2D2145),
Color(0xFFF2EDFF), Color(0xFF9B8BBD), Color(0xFF655780),
),
NsTheme(
"crimson", "Crimson",
Color(0xFFE05263), Color(0xFFB03445),
Color(0xFF120708), Color(0xFF1A0B0D),
Color(0xFF2B1316), Color(0xFF3A1A1E),
Color(0xFFFFEEF0), Color(0xFFBF8D94), Color(0xFF7D545C),
),
)
fun applyNsTheme(themeId: String) {
@@ -37,6 +37,7 @@ data class SettingsUiState(
val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
val downloadWifiOnly: Boolean = true,
val wischenWechseltTitel: Boolean = true,
val isSaving: Boolean = false,
val savedOk: Boolean = false,
val error: String? = null,
@@ -71,6 +72,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absApiKey = s.absApiKey,
absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
wischenWechseltTitel = s.wischenWechseltTitel,
)
}
}
@@ -88,6 +90,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
fun onDownloadWifiOnly(v: Boolean) = update { it.copy(downloadWifiOnly = v, savedOk = false) }
fun onWischenWechseltTitel(v: Boolean) = update { it.copy(wischenWechseltTitel = v, savedOk = false) }
fun onTheme(id: String) {
update { it.copy(theme = id) }
@@ -182,6 +185,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absApiKey = s.absApiKey.trim(),
absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
wischenWechseltTitel = s.wischenWechseltTitel,
))
// Die Netzbedingung steckt in der bereits eingereihten Arbeit — ohne Neueinreihen
// wuerde der WLAN-Schalter erst beim naechsten App-Start greifen.