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 -1
View File
@@ -80,9 +80,26 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
## [3.1] — 2026-09-22
**Android:** 1.15 (versionCode 18) → 1.21 (versionCode 24). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 bringt das Bearbeiten der Warteschlange.
**Android:** 1.15 (versionCode 18) → 1.22 (versionCode 25). 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.
### Added
- **Zwei neue Designs: Violet und Crimson (Web + Android)** — ein helles Lila auf tiefem
Violettschwarz und ein gedämpftes Rot auf Weinrot-Schwarz. Beide mit demselben Variablensatz wie
die vorhandenen, geprüft: kein Design vermisst eine Variable.
- Crimson trägt bewusst **kein reines Rot** als Akzent — das läge zu nah an der Warnfarbe, und
Löschknöpfe sähen dann aus wie gewöhnliche Akzente. Stattdessen ein gedämpftes Rosé-Rot, und
die Warnfarbe wurde dort heller gesetzt, damit sie weiterhin absticht.
- Beide Plattformen lesen ihre Designliste durch, die Auswahl in den Einstellungen füllt sich
also von selbst.
- **Wischen wechselt den Titel (Android)** — seitwärts wischen blätterte im Now-Playing zwischen
Player, Warteschlange und Text; den Titel wechselte nur ein Wisch **genau über dem Cover**. Das
ist verdreht: der Titelwechsel ist die Geste, die man dort ständig braucht. Jetzt nimmt die ganze
Player-Seite den Wisch entgegen und springt zum nächsten oder vorherigen Titel.
- **Umstellbar** in den Einstellungen unter *Wiedergabe* → „Wischen wechselt den Titel". Aus
heißt: wie bisher. Die Reiter oben schalten in beiden Fällen weiter, es geht also nie eine
Ansicht verloren.
- Auf Warteschlange und Text liegt die Geste **nicht** — dort würde sie mit dem Ziehen in der
Liste streiten.
- **Die Warteschlange lässt sich bearbeiten (Web + Android)** — bisher war sie eine reine Anzeige:
reinlegen ging, umsortieren oder wieder herausnehmen nicht. Jetzt trägt jede Zeile einen Griff
zum Ziehen und ein ✕ zum Entfernen, auf beiden Plattformen und in beiden Web-Listen (Seitenleiste
@@ -177,6 +194,16 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
erreichbar war) und zählt für die Seitenleiste jetzt zur Bibliothek statt zu Entdecken.
### Fixed
- **Das Mischen-Symbol zeigte nie an, dass es an ist (Android)** — und der Grund lag nicht in der
Anzeige. `toggleShuffle` baute den Rest der Warteschlange als Schleife aus `removeMediaItem` +
`addMediaItem` um; dazwischen hatte der Controller vorübergehend weniger Titel als unsere Liste.
Genau darauf reagiert `onTimelineChanged` mit „die Titel kamen von außen", baute die
Warteschlange neu auf — und setzte dabei `isManuallyShuffled` **zurück**. Gemischt wurde also,
aber der Zustand war eine Zehntelsekunde später wieder weg. Jetzt wird der Rest in **einem** Zug
ersetzt (`replaceMediaItems`), der Zwischenzustand entfällt.
- **Mischen und Wiederholen sind jetzt auch zu sehen (Android)** — ein eingefärbtes Symbol allein
reicht auf einem dunklen Cover nicht, das liest sich wie eine Schattierung. Ist der Schalter an,
liegt ein getönter Kreis darunter und ein Punkt darunter.
- **Der Rollbalken lief über die Player-Leiste (Web)** — sofort nach dem Verbreitern gemeldet. Der
Balken gehört zum Scrollbereich und läuft über dessen ganze Höhe, und `.main` reichte per
`padding-bottom` bis an den Fensterboden, also bis **unter** die Leiste. Bei 4 px fiel das nie
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 24
versionName = "1.21"
versionCode = 25
versionName = "1.22"
// 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.
@@ -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.
+57 -1
View File
@@ -124,8 +124,64 @@ export const THEMES = [
'--danger': '#e05555',
},
}
},
{
id: 'violet',
name: 'Violet',
accent: '#a78bfa',
bg: '#0d0916',
vars: {
'--bg': '#0d0916', // Tiefes Violettschwarz
'--bg2': '#140e20',
'--bg3': '#1a1229',
'--surface': '#221835',
'--surface2': '#2d2145',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.13)',
'--accent': '#a78bfa', // Helles Lila — auf dunklem Grund gut lesbar
'--accent2': '#c4b5fd',
'--accent-dark':'#7c5cf0',
'--accent-dim': 'rgba(167,139,250,0.15)',
'--accent-glow':'rgba(167,139,250,0.32)',
'--text': '#f2edff',
'--text2': '#9b8bbd',
'--text3': '#655780',
'--danger': '#e05555',
},
},
{
id: 'crimson',
name: 'Crimson',
accent: '#e05263',
bg: '#120708',
vars: {
'--bg': '#120708', // Dunkles Weinrot-Schwarz
'--bg2': '#1a0b0d',
'--bg3': '#210e11',
'--surface': '#2b1316',
'--surface2': '#3a1a1e',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.13)',
// Bewusst kein reines Rot: das laege zu nah an --danger, und Loeschknoepfe saehen
// dann aus wie gewoehnliche Akzente.
'--accent': '#e05263',
'--accent2': '#f2788a',
'--accent-dark':'#b03445',
'--accent-dim': 'rgba(224,82,99,0.16)',
'--accent-glow':'rgba(224,82,99,0.32)',
'--text': '#ffeef0',
'--text2': '#bf8d94',
'--text3': '#7d545c',
'--danger': '#ff7a6b', // heller als der Akzent, damit Warnungen abstechen
},
},
];