feat: ABS integration, last played song, settings reorder + conn dots

Settings:
- Reordered to match web: Navidrome → Bibliothek → Theme → Audiobookshelf → Discover APIs
- Save button now appears at top AND bottom for easy access
- Audiobookshelf section: Server URL + API Key (masked) + green/red status dot with Test button
- NovaSonic Backend section: same green/red conn dot + Test button
- Connection status uses ConnStatus sealed class (Idle/Checking/Ok/Err)

Audiobookshelf:
- New AbsClient.kt: Ktor HTTP client, proxies through NovaSonic backend or direct with Bearer token
- New AbsScreen.kt: library list → book list with covers, author, duration
- Hörbücher tab appears in bottom nav only when ABS URL + API key are both set
- absUrl + absApiKey persisted in DataStore

Last played song:
- PlayerController saves song to DataStore on every media item transition
- MiniPlayer shows last song (dimmed, single play button) when no active queue
- Tapping play fetches the song from Navidrome and resumes
- LastPlayedSong stored in DataStore (id, title, artist, coverArt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 23:49:36 +02:00
1 parent f7012dab49
commit a0d3c7a592
9 files changed
+724 -257

No files matched your search

@@ -0,0 +1,88 @@
package com.novasonic.app.api
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.HttpResponse
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class AbsLibrary(
val id: String = "",
val name: String = "",
val mediaType: String = "book",
)
@Serializable
data class AbsLibrariesResponse(val libraries: List<AbsLibrary> = emptyList())
@Serializable
data class AbsMediaMetadata(
val title: String? = null,
val authorName: String? = null,
val description: String? = null,
)
@Serializable
data class AbsMedia(
val metadata: AbsMediaMetadata = AbsMediaMetadata(),
val duration: Double = 0.0,
val numFiles: Int = 0,
)
@Serializable
data class AbsLibraryItem(
val id: String = "",
val media: AbsMedia = AbsMedia(),
)
@Serializable
data class AbsItemsResponse(
val results: List<AbsLibraryItem> = emptyList(),
val total: Int = 0,
)
class AbsClient(
val absUrl: String,
val apiKey: String,
private val backendUrl: String = "",
) {
private val http = HttpClient(Android) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) }
install(HttpTimeout) { requestTimeoutMillis = 12_000; connectTimeoutMillis = 8_000 }
}
private suspend fun get(path: String): HttpResponse =
if (backendUrl.isNotBlank()) {
http.get("$backendUrl/api/abs-proxy$path") {
header("X-ABS-URL", absUrl)
header("X-ABS-Token", apiKey)
}
} else {
http.get("$absUrl$path") {
header("Authorization", "Bearer $apiKey")
}
}
suspend fun ping(): Boolean = runCatching { get("/api/me").status == HttpStatusCode.OK }.getOrDefault(false)
suspend fun getLibraries(): List<AbsLibrary> = runCatching {
get("/api/libraries").body<AbsLibrariesResponse>().libraries
}.getOrDefault(emptyList())
suspend fun getLibraryItems(libId: String, page: Int = 0): AbsItemsResponse = runCatching {
get("/api/libraries/$libId/items?expanded=1&limit=50&page=$page&sort=media.metadata.title&asc=1")
.body<AbsItemsResponse>()
}.getOrDefault(AbsItemsResponse())
fun coverUrl(itemId: String): String =
"$absUrl/api/items/$itemId/cover?token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
fun close() = http.close()
}
@@ -100,6 +100,17 @@ class MusicRepository(private val context: Context) {
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
fun lastPlayedSongFlow() = prefs.lastPlayedSong
suspend fun absClient(): com.novasonic.app.api.AbsClient? {
val s = prefs.appSettings.first()
if (s.absUrl.isBlank() || s.absApiKey.isBlank()) return null
return com.novasonic.app.api.AbsClient(s.absUrl.trimEnd('/'), s.absApiKey, s.backendUrl.trimEnd('/'))
}
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
settings.username != lastCredentials?.username ||
@@ -1,4 +1,4 @@
package com.novasonic.app.data
package com.novasonic.app.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -18,9 +18,15 @@ class PrefsStore(private val context: Context) {
val KEY_PASSWORD = stringPreferencesKey("password")
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") // 15s|30s|third|two_thirds|end
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
val KEY_THEME = stringPreferencesKey("theme")
val KEY_ABS_URL = stringPreferencesKey("abs_url")
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
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")
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
}
data class Credentials(val serverUrl: String, val username: String, val password: String) {
@@ -35,7 +41,16 @@ class PrefsStore(private val context: Context) {
val backendUrl: String = "",
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
val theme: String = "dark-ocean",
val absUrl: String = "",
val absApiKey: String = "",
)
data class LastPlayedSong(
val id: String,
val title: String,
val artist: String,
val coverArtId: String?,
)
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
@@ -56,6 +71,18 @@ class PrefsStore(private val context: Context) {
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
theme = prefs[KEY_THEME] ?: "dark-ocean",
absUrl = prefs[KEY_ABS_URL] ?: "",
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
)
}
val lastPlayedSong: Flow<LastPlayedSong?> = context.dataStore.data.map { prefs ->
val id = prefs[KEY_LAST_SONG_ID]?.takeIf { it.isNotBlank() } ?: return@map null
LastPlayedSong(
id = id,
title = prefs[KEY_LAST_SONG_TITLE] ?: "",
artist = prefs[KEY_LAST_SONG_ARTIST] ?: "",
coverArtId = prefs[KEY_LAST_SONG_COVER]?.takeIf { it.isNotBlank() },
)
}
@@ -77,6 +104,8 @@ class PrefsStore(private val context: Context) {
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
prefs[KEY_THEME] = settings.theme
prefs[KEY_ABS_URL] = settings.absUrl
prefs[KEY_ABS_API_KEY] = settings.absApiKey
}
}
@@ -84,6 +113,15 @@ class PrefsStore(private val context: Context) {
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
}
suspend fun saveLastSong(id: String, title: String, artist: String, coverArt: String?) {
context.dataStore.edit { prefs ->
prefs[KEY_LAST_SONG_ID] = id
prefs[KEY_LAST_SONG_TITLE] = title
prefs[KEY_LAST_SONG_ARTIST] = artist
prefs[KEY_LAST_SONG_COVER] = coverArt ?: ""
}
}
suspend fun clear() {
context.dataStore.edit { it.clear() }
}
@@ -255,6 +255,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
val dur = ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur)
scope.launch { runCatching { repo.saveLastSong(song) } }
}
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
@@ -1,4 +1,4 @@
package com.novasonic.app.ui
package com.novasonic.app.ui
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
@@ -29,8 +29,11 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.*
import androidx.navigation.compose.*
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibrary
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.NotificationSheet
import com.novasonic.app.ui.component.PlaylistPickerSheet
@@ -41,11 +44,12 @@ import com.novasonic.app.ui.theme.*
import kotlinx.coroutines.launch
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
HOME("home", Icons.Default.Home, "Home"),
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
HOME("home", Icons.Default.Home, "Home"),
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
ABS("abs", Icons.Default.Book, "Hörbücher"),
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
}
private val NavBg = Color(0xF7080814)
@@ -62,18 +66,23 @@ fun AppNavigation() {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
// Apply saved theme whenever settings change
LaunchedEffect(Unit) {
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
}
val playerState by player.state.collectAsStateWithLifecycle()
val playerState by player.state.collectAsStateWithLifecycle()
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
else null
}
val navController = rememberNavController()
var showNowPlaying by remember { mutableStateOf(false) }
// Re-register a fresh callback each time NowPlaying opens so it is always
// the LAST entry in the dispatcher queue (LIFO = highest priority), beating
// the BackHandler that NavHost registers internally for its own back stack.
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
DisposableEffect(showNowPlaying, backDispatcher) {
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
@@ -84,10 +93,11 @@ fun AppNavigation() {
onDispose { cb.remove() }
}
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) }
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) }
var selectedAbsLibrary by remember { mutableStateOf<AbsLibrary?>(null) }
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
@@ -109,6 +119,11 @@ fun AppNavigation() {
center = Offset.Unspecified, radius = 900f,
)
// Nav items — ABS tab only shown when configured
val navItems = remember(absConfigured) {
TopLevel.entries.filter { it != TopLevel.ABS || absConfigured }
}
Box(Modifier.fillMaxSize()) {
Scaffold(
containerColor = NsBackground,
@@ -116,22 +131,37 @@ fun AppNavigation() {
bottomBar = {
if (isTopLevel) {
Column {
// MiniPlayer: show active or last played song
val showMiniPlayer = playerState.currentSong != null ||
(lastPlayedSong != null && !showNowPlaying)
AnimatedVisibility(
visible = playerState.currentSong != null,
visible = showMiniPlayer,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
MiniPlayer(
state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTap = { showNowPlaying = true },
onTogglePlay = { player.togglePlay() },
onSkipPrev = { player.skipPrev() },
onSkipNext = { player.skipNext() },
state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTap = { showNowPlaying = true },
onTogglePlay = { player.togglePlay() },
onSkipPrev = { player.skipPrev() },
onSkipNext = { player.skipNext() },
lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
onPlayLast = {
lastPlayedSong?.let { ls ->
scope.launch {
runCatching {
val song = repo.getSong(ls.id)
player.playSongs(listOf(song), 0)
}
}
}
},
)
}
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
TopLevel.entries.forEach { dest ->
navItems.forEach { dest ->
NavigationBarItem(
selected = currentRoute == dest.route,
onClick = {
@@ -145,10 +175,7 @@ fun AppNavigation() {
}
},
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = {
Text(dest.label, style = TextStyle(
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp))
},
label = { Text(dest.label, style = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp)) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = NsAccent,
selectedTextColor = NsAccent,
@@ -167,22 +194,10 @@ fun AppNavigation() {
navController = navController,
startDestination = TopLevel.HOME.route,
modifier = Modifier.padding(innerPadding),
enterTransition = {
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeIn(tween(200))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeOut(tween(180))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeIn(tween(200))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeOut(tween(180))
},
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) },
) {
composable(TopLevel.HOME.route) {
HomeScreen(
@@ -229,13 +244,24 @@ fun AppNavigation() {
}
composable(TopLevel.STARRED.route) {
StarredScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
)
}
composable(TopLevel.ABS.route) {
if (absClient != null && selectedAbsLibrary == null) {
AbsScreen(absClient = absClient, onLibraryClick = { selectedAbsLibrary = it })
} else if (absClient != null && selectedAbsLibrary != null) {
AbsLibraryScreen(
absClient = absClient,
library = selectedAbsLibrary!!,
onBack = { selectedAbsLibrary = null },
)
}
}
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
composable(
@@ -243,13 +269,13 @@ fun AppNavigation() {
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
) {
AlbumScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() },
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() },
)
}
composable(
@@ -257,14 +283,14 @@ fun AppNavigation() {
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
) {
ArtistDetailScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() },
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() },
)
}
composable(
@@ -272,12 +298,12 @@ fun AppNavigation() {
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
) {
PlaylistDetailScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onBack = { navController.popBackStack() },
onBack = { navController.popBackStack() },
)
}
composable(
@@ -286,13 +312,13 @@ fun AppNavigation() {
) { backEntry ->
val genreName = backEntry.arguments?.getString("genreName") ?: ""
GenreSongsScreen(
genreName = genreName,
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
genreName = genreName,
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onBack = { navController.popBackStack() },
onBack = { navController.popBackStack() },
)
}
composable("genres") {
@@ -310,7 +336,7 @@ fun AppNavigation() {
}
}
// NowPlaying overlay — outside Scaffold so WindowInsets are not double-consumed
// NowPlaying overlay
AnimatedVisibility(
visible = showNowPlaying,
enter = slideInVertically { it } + fadeIn(tween(220)),
@@ -322,12 +348,8 @@ fun AppNavigation() {
queueCoverUrl = { id -> coverUrl(id) },
controller = player,
onDismiss = { showNowPlaying = false },
onStarToggle = {
playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) }
},
onShowOptions = {
playerState.currentSong?.let { s -> contextMenuSong = s }
},
onStarToggle = { playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) } },
onShowOptions = { playerState.currentSong?.let { s -> contextMenuSong = s } },
onArtistClick = { artistId ->
showNowPlaying = false
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
@@ -336,7 +358,6 @@ fun AppNavigation() {
)
}
// Song context menu bottom sheet
contextMenuSong?.let { song ->
SongContextSheet(
song = song,
@@ -346,25 +367,13 @@ fun AppNavigation() {
onInsertNext = { player.insertNext(song) },
onAddToQueue = { player.addToQueueEnd(song) },
onStarToggle = { starToggle(song.id, song.starred != null) },
onAddToPlaylist = {
playlistPickerSong = song
contextMenuSong = null
},
onGoToAlbum = song.albumId?.let { albumId ->
{ navController.navigate("album/$albumId") }
},
onShowInfo = {
infoSheetSong = song
contextMenuSong = null
},
onDownload = {
downloadSong(context, song, repo.streamUrl(song.id) ?: "")
contextMenuSong = null
},
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
onDownload = { downloadSong(context, song, repo.streamUrl(song.id) ?: ""); contextMenuSong = null },
)
}
// Playlist picker sheet
playlistPickerSong?.let { song ->
PlaylistPickerSheet(
song = song,
@@ -389,12 +398,10 @@ fun AppNavigation() {
)
}
// Song info sheet
infoSheetSong?.let { song ->
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
}
// Notification sheet
if (showNotifications) {
NotificationSheet(onDismiss = { showNotifications = false })
}
@@ -406,8 +413,7 @@ private fun downloadSong(context: Context, song: Song, streamUrl: String) {
Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show()
return
}
val safe = (song.title + "." + (song.suffix ?: "mp3"))
.replace(Regex("[/\\\\:*?\"<>|]"), "_")
val safe = (song.title + "." + (song.suffix ?: "mp3")).replace(Regex("[/\\\\:*?\"<>|]"), "_")
val request = DownloadManager.Request(Uri.parse(streamUrl))
.setTitle(song.title)
.setDescription(song.artist ?: "")
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
@@ -34,9 +35,25 @@ fun MiniPlayer(
onTogglePlay: () -> Unit,
onSkipPrev: () -> Unit,
onSkipNext: () -> Unit,
lastSong: PrefsStore.LastPlayedSong? = null,
lastSongCoverUrl: String? = null,
onPlayLast: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val song = state.currentSong ?: return
val song = state.currentSong
// If no active song but last song exists, show it in "resume" mode
if (song == null) {
val ls = lastSong ?: return
LastSongMiniPlayer(
lastSong = ls,
coverUrl = lastSongCoverUrl,
onTap = onTap,
onPlay = onPlayLast ?: {},
modifier = modifier,
)
return
}
Column(
modifier = modifier
@@ -55,78 +72,93 @@ fun MiniPlayer(
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AsyncImage(
model = coverUrl,
contentDescription = null,
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = song.title,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = song.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (!song.artist.isNullOrBlank()) {
Text(
text = song.artist!!,
fontFamily = Outfit,
fontSize = 12.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = song.artist!!, fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.SkipPrevious,
contentDescription = null,
tint = NsOnSurface2,
modifier = Modifier.size(24.dp),
)
Icon(Icons.Default.SkipPrevious, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
}
// Accent play/pause circle button
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(NsAccent)
.clickable(onClick = onTogglePlay),
Modifier.size(40.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onTogglePlay),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.SkipNext,
contentDescription = null,
tint = NsOnSurface2,
modifier = Modifier.size(24.dp),
)
Icon(Icons.Default.SkipNext, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
}
}
// Progress bar anchored to the bottom of the card
if (state.durationMs > 0) {
LinearProgressIndicator(
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(3.dp),
color = NsAccent,
trackColor = NsAccent.copy(alpha = 0.15f),
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.15f),
)
}
}
}
@Composable
private fun LastSongMiniPlayer(
lastSong: PrefsStore.LastPlayedSong,
coverUrl: String?,
onTap: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 4.dp)
.shadow(12.dp, RoundedCornerShape(18.dp))
.clip(RoundedCornerShape(18.dp))
.background(NsSurface)
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AsyncImage(
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = lastSong.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (lastSong.artist.isNotBlank()) {
Text(
text = lastSong.artist, fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
Box(
Modifier.size(40.dp).clip(CircleShape).background(NsAccent.copy(alpha = 0.6f)).clickable(onClick = onPlay),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
}
}
}
@@ -0,0 +1,209 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Book
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibrary
import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.ui.theme.*
import kotlinx.coroutines.launch
@Composable
fun AbsScreen(
absClient: AbsClient,
onLibraryClick: (AbsLibrary) -> Unit,
) {
val scope = rememberCoroutineScope()
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
scope.launch {
isLoading = true
val result = runCatching { absClient.getLibraries() }
if (result.isSuccess) {
libraries = result.getOrDefault(emptyList())
} else {
error = result.exceptionOrNull()?.message ?: "Fehler"
}
isLoading = false
}
}
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(
"Audiobookshelf",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
modifier = Modifier.padding(bottom = 16.dp),
)
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
libraries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Bibliotheken gefunden", fontFamily = Outfit, color = NsOnSurface2)
}
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(libraries) { lib ->
LibraryRow(library = lib, onClick = { onLibraryClick(lib) })
}
}
}
}
}
@Composable
private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 14.dp),
) {
Box(
Modifier.size(44.dp).clip(RoundedCornerShape(10.dp)).background(NsAccent.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Book, contentDescription = null, tint = NsAccent, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = library.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
text = library.mediaType.replaceFirstChar { it.uppercase() },
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
}
}
@Composable
fun AbsLibraryScreen(
absClient: AbsClient,
library: AbsLibrary,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(library.id) {
isLoading = true
val result = runCatching { absClient.getLibraryItems(library.id).results }
if (result.isSuccess) {
items = result.getOrDefault(emptyList())
} else {
error = result.exceptionOrNull()?.message ?: "Fehler"
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
library.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 20.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Bücher gefunden", fontFamily = Outfit, color = NsOnSurface2)
}
else -> LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items) { item ->
AbsItemRow(item = item, absClient = absClient)
}
}
}
}
}
@Composable
private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient) {
val meta = item.media.metadata
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.padding(10.dp),
) {
AsyncImage(
model = absClient.coverUrl(item.id),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = meta.title ?: item.id,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!meta.authorName.isNullOrBlank()) {
Text(
text = meta.authorName, fontFamily = Outfit,
fontSize = 12.sp, color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
val durationMin = (item.media.duration / 60).toInt()
if (durationMin > 0) {
Text(
text = "${durationMin / 60}h ${durationMin % 60}m",
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.7f),
)
}
}
}
}
@@ -1,4 +1,4 @@
package com.novasonic.app.ui.screen
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -18,6 +18,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -27,6 +28,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ConnStatus
import com.novasonic.app.viewmodel.SettingsViewModel
private val SCROBBLE_OPTIONS = listOf(
@@ -44,6 +46,7 @@ fun SettingsScreen() {
val state by vm.state.collectAsStateWithLifecycle()
var showPassword by remember { mutableStateOf(false) }
var showAbsKey by remember { mutableStateOf(false) }
var showLogoutDialog by remember { mutableStateOf(false) }
var scrobbleExpanded by remember { mutableStateOf(false) }
@@ -58,9 +61,7 @@ fun SettingsScreen() {
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("Abbrechen", fontFamily = Outfit)
}
TextButton(onClick = { showLogoutDialog = false }) { Text("Abbrechen", fontFamily = Outfit) }
},
containerColor = NsSurface,
)
@@ -79,24 +80,23 @@ fun SettingsScreen() {
color = NsOnSurface,
)
// ── Save button (TOP) ────────────────────────────────────────────────
SaveButton(vm, state.isSaving, state.savedOk, state.error)
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
SectionHeader("🌐 Navidrome Server")
NsTextField(
value = state.serverUrl,
onValueChange = vm::onServerUrl,
label = "Server URL",
placeholder = "https://musik.beispiel.de",
value = state.serverUrl, onValueChange = vm::onServerUrl,
label = "Server URL", placeholder = "https://musik.beispiel.de",
keyboardType = KeyboardType.Uri,
)
NsTextField(
value = state.username,
onValueChange = vm::onUsername,
value = state.username, onValueChange = vm::onUsername,
label = "Benutzername",
)
NsTextField(
value = state.password,
onValueChange = vm::onPassword,
value = state.password, onValueChange = vm::onPassword,
label = "Passwort",
hint = "(unverändert lassen wenn kein Reset gewünscht)",
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
@@ -116,55 +116,35 @@ fun SettingsScreen() {
// ── 📊 Bibliothek ────────────────────────────────────────────────────
SectionHeader("📊 Bibliothek")
// Scrobble-Mode dropdown
ExposedDropdownMenuBox(
expanded = scrobbleExpanded,
onExpandedChange = { scrobbleExpanded = it },
) {
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
OutlinedTextField(
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second
?: "30 Sekunden",
onValueChange = {},
readOnly = true,
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
onValueChange = {}, readOnly = true,
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
colors = fieldColors(),
)
ExposedDropdownMenu(
expanded = scrobbleExpanded,
onDismissRequest = { scrobbleExpanded = false },
containerColor = NsSurface2,
) {
ExposedDropdownMenu(expanded = scrobbleExpanded, onDismissRequest = { scrobbleExpanded = false }, containerColor = NsSurface2) {
SCROBBLE_OPTIONS.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
colors = MenuDefaults.itemColors(
textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface
),
colors = MenuDefaults.itemColors(textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface),
)
}
}
}
// Show play count toggle
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Zeigt Wiedergabezähler in Song-Reihen an",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Zeigt Wiedergabezähler in Song-Reihen an", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
checked = state.showPlayCount,
onCheckedChange = vm::onShowPlayCount,
checked = state.showPlayCount, onCheckedChange = vm::onShowPlayCount,
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground,
checkedTrackColor = NsAccent,
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
@@ -174,60 +154,57 @@ fun SettingsScreen() {
// ── 🎨 Theme ─────────────────────────────────────────────────────────
SectionHeader("🎨 Theme")
ThemePicker(
currentThemeId = state.theme,
onSelect = { id ->
vm.onTheme(id)
applyNsTheme(id)
},
)
ThemePicker(currentThemeId = state.theme, onSelect = { id -> vm.onTheme(id); applyNsTheme(id) })
Spacer(Modifier.height(4.dp))
// ── 🔑 APIs ──────────────────────────────────────────────────────────
SectionHeader("🔑 APIs")
// ── 📚 Audiobookshelf ────────────────────────────────────────────────
SectionHeader("📚 Audiobookshelf")
NsTextField(
value = state.lastFmKey,
onValueChange = vm::onLastFmKey,
value = state.absUrl, onValueChange = vm::onAbsUrl,
label = "Server URL", placeholder = "https://audiobooks.beispiel.de",
keyboardType = KeyboardType.Uri,
)
NsTextField(
value = state.absApiKey, onValueChange = vm::onAbsApiKey,
label = "API Key",
hint = "Einstellungen → Profil → API Token in Audiobookshelf",
visualTransformation = if (showAbsKey) VisualTransformation.None else PasswordVisualTransformation(),
keyboardType = KeyboardType.Password,
trailingIcon = {
IconButton(onClick = { showAbsKey = !showAbsKey }) {
Icon(
if (showAbsKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null, tint = NsOnSurface2,
)
}
},
)
ConnStatusRow(status = state.absStatus, label = "Nicht konfiguriert", onTest = vm::testAbs)
Spacer(Modifier.height(4.dp))
// ── 🔑 Discover APIs ─────────────────────────────────────────────────
SectionHeader("🔑 Discover APIs")
NsTextField(
value = state.lastFmKey, onValueChange = vm::onLastFmKey,
label = "Last.fm API Key",
hint = "Kostenlos unter last.fm/api/account/create",
)
NsTextField(
value = state.backendUrl,
onValueChange = vm::onBackendUrl,
value = state.backendUrl, onValueChange = vm::onBackendUrl,
label = "NovaSonic Backend URL",
hint = "Für Download-to-Server via yt-dlp",
keyboardType = KeyboardType.Uri,
)
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
Spacer(Modifier.height(8.dp))
// Status + Save
if (state.error != null) {
Text(state.error!!, color = MaterialTheme.colorScheme.error,
fontFamily = Outfit, fontSize = 13.sp)
}
if (state.savedOk) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
}
}
Button(
onClick = vm::save,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
) {
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
} else {
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
// ── Save button (BOTTOM) ─────────────────────────────────────────────
SaveButton(vm, state.isSaving, state.savedOk, state.error)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
@@ -238,14 +215,75 @@ fun SettingsScreen() {
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
),
) {
Text("Abmelden", color = MaterialTheme.colorScheme.error,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun SaveButton(vm: SettingsViewModel, isSaving: Boolean, savedOk: Boolean, error: String?) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (error != null) {
Text(error, color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontSize = 13.sp)
}
if (savedOk) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
}
}
Button(
onClick = vm::save,
enabled = !isSaving,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
) {
if (isSaving) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
} else {
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
}
@Composable
private fun ConnStatusRow(status: ConnStatus, label: String, onTest: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
when (status) {
ConnStatus.Idle -> Spacer(Modifier.size(10.dp))
ConnStatus.Checking -> CircularProgressIndicator(modifier = Modifier.size(10.dp), strokeWidth = 1.5.dp, color = NsAccent)
is ConnStatus.Ok -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF4CAF50)))
is ConnStatus.Err -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFE53935)))
}
Spacer(Modifier.width(8.dp))
Text(
text = when (status) {
ConnStatus.Idle -> label
ConnStatus.Checking -> "Verbinde…"
is ConnStatus.Ok -> status.text
is ConnStatus.Err -> status.text
},
fontFamily = Outfit, fontSize = 12.sp,
color = when (status) {
is ConnStatus.Ok -> Color(0xFF4CAF50)
is ConnStatus.Err -> Color(0xFFE53935)
else -> NsOnSurface2
},
modifier = Modifier.weight(1f),
)
TextButton(onClick = onTest, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
Text("Testen", fontFamily = Outfit, fontSize = 12.sp, color = NsAccent)
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
@@ -271,8 +309,7 @@ private fun NsTextField(
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
value = value, onValueChange = onValueChange,
label = { Text(label, fontFamily = Outfit) },
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
singleLine = true,
@@ -308,29 +345,15 @@ private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
.clickable { onSelect(theme.id) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
// Accent dot
Box(
Modifier
.size(22.dp)
.clip(CircleShape)
.background(theme.accent),
)
Box(Modifier.size(22.dp).clip(CircleShape).background(theme.accent))
Spacer(Modifier.width(12.dp))
Text(
text = theme.name,
fontFamily = Outfit,
text = theme.name, fontFamily = Outfit,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
fontSize = 14.sp,
color = if (isActive) theme.onSurface else NsOnSurface2,
fontSize = 14.sp, color = if (isActive) theme.onSurface else NsOnSurface2,
modifier = Modifier.weight(1f),
)
if (isActive) {
Icon(
Icons.Default.Check, null,
tint = theme.accent,
modifier = Modifier.size(18.dp),
)
}
if (isActive) Icon(Icons.Default.Check, null, tint = theme.accent, modifier = Modifier.size(18.dp))
}
}
}
@@ -338,11 +361,7 @@ private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
@Composable
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
focusedLabelColor = NsAccent,
cursorColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
unfocusedLabelColor = NsOnSurface2,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
unfocusedBorderColor = NsOnSurface3, unfocusedLabelColor = NsOnSurface2,
focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
)
@@ -1,14 +1,27 @@
package com.novasonic.app.viewmodel
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.AbsClient
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
sealed class ConnStatus {
object Idle : ConnStatus()
object Checking : ConnStatus()
data class Ok(val text: String) : ConnStatus()
data class Err(val text: String) : ConnStatus()
}
data class SettingsUiState(
val serverUrl: String = "",
val username: String = "",
@@ -18,9 +31,13 @@ data class SettingsUiState(
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
val absUrl: String = "",
val absApiKey: String = "",
val isSaving: Boolean = false,
val savedOk: Boolean = false,
val error: String? = null,
val absStatus: ConnStatus = ConnStatus.Idle,
val backendStatus: ConnStatus = ConnStatus.Idle,
)
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
@@ -42,6 +59,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
absUrl = s.absUrl,
absApiKey = s.absApiKey,
)
}
}
@@ -51,15 +70,57 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onUsername(v: String) = update { it.copy(username = v, savedOk = false) }
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false) }
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onTheme(id: String) {
update { it.copy(theme = id) }
viewModelScope.launch { repo.saveTheme(id) }
}
fun testAbs() {
val url = _state.value.absUrl.trim().trimEnd('/')
val key = _state.value.absApiKey.trim()
if (url.isBlank() || key.isBlank()) {
update { it.copy(absStatus = ConnStatus.Err("URL und API Key eingeben")) }
return
}
update { it.copy(absStatus = ConnStatus.Checking) }
viewModelScope.launch {
val backend = _state.value.backendUrl.trim().trimEnd('/')
val ok = runCatching { AbsClient(url, key, backend).ping() }.getOrDefault(false)
update { it.copy(absStatus = if (ok) ConnStatus.Ok("Verbunden · $url") else ConnStatus.Err("Nicht erreichbar")) }
}
}
fun testBackend() {
val url = _state.value.backendUrl.trim().trimEnd('/')
if (url.isBlank()) {
update { it.copy(backendStatus = ConnStatus.Err("Backend URL eingeben")) }
return
}
update { it.copy(backendStatus = ConnStatus.Checking) }
viewModelScope.launch {
try {
val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
}
val resp = http.get("$url/api/health")
http.close()
if (resp.status == HttpStatusCode.OK) {
update { it.copy(backendStatus = ConnStatus.Ok("Verbunden · $url")) }
} else {
update { it.copy(backendStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
}
} catch (e: Exception) {
update { it.copy(backendStatus = ConnStatus.Err("Nicht erreichbar")) }
}
}
}
fun save() {
val s = _state.value
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
@@ -78,6 +139,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
absUrl = s.absUrl.trimEnd('/'),
absApiKey = s.absApiKey.trim(),
))
_state.value = _state.value.copy(isSaving = false, savedOk = true)
} catch (e: Exception) {