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:
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) {
|
||||
|
||||
Reference in new issue
Block a user