diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9a1f4..8452e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b8190d3..7fee65a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt index eea0552..a67ca91 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -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 } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index 22d6ee8..9091774 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -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() } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index be03e55..2c893a4 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -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 { diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt index 2f529c7..4a5cdd7 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -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") diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Color.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Color.kt index 8c5c624..5aeca80 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Color.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Color.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt index c8d9faa..68497c8 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt @@ -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. diff --git a/src/themes.js b/src/themes.js index bad534b..c81a49b 100644 --- a/src/themes.js +++ b/src/themes.js @@ -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 + }, + }, ];