From a0d3c7a5926b9166d3614da32ed32974d0750719 Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 15 Jun 2026 23:49:36 +0200 Subject: [PATCH] feat: ABS integration, last played song, settings reorder + conn dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/com/novasonic/app/api/AbsClient.kt | 88 +++++++ .../com/novasonic/app/data/MusicRepository.kt | 11 + .../com/novasonic/app/data/PrefsStore.kt | 46 +++- .../novasonic/app/player/PlayerController.kt | 1 + .../com/novasonic/app/ui/AppNavigation.kt | 206 +++++++-------- .../novasonic/app/ui/component/MiniPlayer.kt | 120 +++++---- .../com/novasonic/app/ui/screen/AbsScreen.kt | 209 +++++++++++++++ .../novasonic/app/ui/screen/SettingsScreen.kt | 239 ++++++++++-------- .../app/viewmodel/SettingsViewModel.kt | 69 ++++- 9 files changed, 728 insertions(+), 261 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt new file mode 100644 index 0000000..833e140 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/AbsClient.kt @@ -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 = 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 = 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 = runCatching { + get("/api/libraries").body().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() + }.getOrDefault(AbsItemsResponse()) + + fun coverUrl(itemId: String): String = + "$absUrl/api/items/$itemId/cover?token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}" + + fun close() = http.close() +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 861304d..295d418 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -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 || 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 d089c94..0d70bdf 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 @@ -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 = 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 = 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,7 +113,16 @@ 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() } } -} \ No newline at end of file +} 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 fab5adc..ff2ac78 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 @@ -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() diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index f8e690f..58ace70 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -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(null) } - var playlistPickerSong by remember { mutableStateOf(null) } - var infoSheetSong by remember { mutableStateOf(null) } - var showNotifications by remember { mutableStateOf(false) } + var contextMenuSong by remember { mutableStateOf(null) } + var playlistPickerSong by remember { mutableStateOf(null) } + var infoSheetSong by remember { mutableStateOf(null) } + var showNotifications by remember { mutableStateOf(false) } + var selectedAbsLibrary by remember { mutableStateOf(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 ?: "") @@ -419,4 +425,4 @@ private fun downloadSong(context: Context, song: Song, streamUrl: String) { Toast.makeText(context, "Download gestartet: ${song.title}", Toast.LENGTH_SHORT).show() } -private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8") \ No newline at end of file +private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8") diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/MiniPlayer.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/MiniPlayer.kt index ad2d4df..50e4b8c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/component/MiniPlayer.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/MiniPlayer.kt @@ -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)) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt new file mode 100644 index 0000000..4a72892 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt @@ -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>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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), + ) + } + } + } +} 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 67f43a1..6f578e6 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 @@ -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, -) \ No newline at end of file + focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent, + unfocusedBorderColor = NsOnSurface3, unfocusedLabelColor = NsOnSurface2, + focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface, +) 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 1966f50..1ace4be 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 @@ -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) { @@ -93,4 +156,4 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { private inline fun update(block: (SettingsUiState) -> SettingsUiState) { _state.value = block(_state.value) } -} \ No newline at end of file +}