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 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) {
|
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
|
||||||
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
|
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
|
||||||
settings.username != lastCredentials?.username ||
|
settings.username != lastCredentials?.username ||
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.novasonic.app.data
|
package com.novasonic.app.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
@@ -18,9 +18,15 @@ class PrefsStore(private val context: Context) {
|
|||||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
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_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||||
val KEY_THEME = stringPreferencesKey("theme")
|
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) {
|
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 backendUrl: String = "",
|
||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
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 ->
|
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",
|
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
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_SCROBBLE_MODE] = settings.scrobbleMode
|
||||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||||
prefs[KEY_THEME] = settings.theme
|
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 }
|
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() {
|
suspend fun clear() {
|
||||||
context.dataStore.edit { it.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 song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||||
val dur = ctrl.duration.coerceAtLeast(0)
|
val dur = ctrl.duration.coerceAtLeast(0)
|
||||||
scheduleScrobble(song, dur)
|
scheduleScrobble(song, dur)
|
||||||
|
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
|
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.OnBackPressedCallback
|
||||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||||
@@ -29,8 +29,11 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.*
|
import androidx.navigation.*
|
||||||
import androidx.navigation.compose.*
|
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.api.model.Song
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
|
import com.novasonic.app.data.PrefsStore
|
||||||
import com.novasonic.app.ui.component.MiniPlayer
|
import com.novasonic.app.ui.component.MiniPlayer
|
||||||
import com.novasonic.app.ui.component.NotificationSheet
|
import com.novasonic.app.ui.component.NotificationSheet
|
||||||
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
||||||
@@ -41,11 +44,12 @@ import com.novasonic.app.ui.theme.*
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
|
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
|
||||||
HOME("home", Icons.Default.Home, "Home"),
|
HOME("home", Icons.Default.Home, "Home"),
|
||||||
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
|
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
|
||||||
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
|
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
|
||||||
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
|
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
|
||||||
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
|
ABS("abs", Icons.Default.Book, "Hörbücher"),
|
||||||
|
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
|
||||||
}
|
}
|
||||||
|
|
||||||
private val NavBg = Color(0xF7080814)
|
private val NavBg = Color(0xF7080814)
|
||||||
@@ -62,18 +66,23 @@ fun AppNavigation() {
|
|||||||
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply saved theme whenever settings change
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
|
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()
|
val navController = rememberNavController()
|
||||||
var showNowPlaying by remember { mutableStateOf(false) }
|
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
|
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||||
DisposableEffect(showNowPlaying, backDispatcher) {
|
DisposableEffect(showNowPlaying, backDispatcher) {
|
||||||
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
@@ -84,10 +93,11 @@ fun AppNavigation() {
|
|||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
|
|
||||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||||
var showNotifications by remember { mutableStateOf(false) }
|
var showNotifications by remember { mutableStateOf(false) }
|
||||||
|
var selectedAbsLibrary by remember { mutableStateOf<AbsLibrary?>(null) }
|
||||||
|
|
||||||
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
||||||
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
||||||
@@ -109,6 +119,11 @@ fun AppNavigation() {
|
|||||||
center = Offset.Unspecified, radius = 900f,
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = NsBackground,
|
containerColor = NsBackground,
|
||||||
@@ -116,22 +131,37 @@ fun AppNavigation() {
|
|||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (isTopLevel) {
|
if (isTopLevel) {
|
||||||
Column {
|
Column {
|
||||||
|
// MiniPlayer: show active or last played song
|
||||||
|
val showMiniPlayer = playerState.currentSong != null ||
|
||||||
|
(lastPlayedSong != null && !showNowPlaying)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = playerState.currentSong != null,
|
visible = showMiniPlayer,
|
||||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||||
) {
|
) {
|
||||||
MiniPlayer(
|
MiniPlayer(
|
||||||
state = playerState,
|
state = playerState,
|
||||||
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
||||||
onTap = { showNowPlaying = true },
|
onTap = { showNowPlaying = true },
|
||||||
onTogglePlay = { player.togglePlay() },
|
onTogglePlay = { player.togglePlay() },
|
||||||
onSkipPrev = { player.skipPrev() },
|
onSkipPrev = { player.skipPrev() },
|
||||||
onSkipNext = { player.skipNext() },
|
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) {
|
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
|
||||||
TopLevel.entries.forEach { dest ->
|
navItems.forEach { dest ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = currentRoute == dest.route,
|
selected = currentRoute == dest.route,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -145,10 +175,7 @@ fun AppNavigation() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
||||||
label = {
|
label = { Text(dest.label, style = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp)) },
|
||||||
Text(dest.label, style = TextStyle(
|
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp))
|
|
||||||
},
|
|
||||||
colors = NavigationBarItemDefaults.colors(
|
colors = NavigationBarItemDefaults.colors(
|
||||||
selectedIconColor = NsAccent,
|
selectedIconColor = NsAccent,
|
||||||
selectedTextColor = NsAccent,
|
selectedTextColor = NsAccent,
|
||||||
@@ -167,22 +194,10 @@ fun AppNavigation() {
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = TopLevel.HOME.route,
|
startDestination = TopLevel.HOME.route,
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
enterTransition = {
|
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) },
|
||||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) },
|
||||||
fadeIn(tween(200))
|
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)) },
|
||||||
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) {
|
composable(TopLevel.HOME.route) {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
@@ -229,13 +244,24 @@ fun AppNavigation() {
|
|||||||
}
|
}
|
||||||
composable(TopLevel.STARRED.route) {
|
composable(TopLevel.STARRED.route) {
|
||||||
StarredScreen(
|
StarredScreen(
|
||||||
coverUrl = { id -> coverUrl(id) },
|
coverUrl = { id -> coverUrl(id) },
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||||
onShowContextMenu = ::showContextMenu,
|
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(TopLevel.SETTINGS.route) { SettingsScreen() }
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
@@ -243,13 +269,13 @@ fun AppNavigation() {
|
|||||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||||
) {
|
) {
|
||||||
AlbumScreen(
|
AlbumScreen(
|
||||||
coverUrl = { id -> coverUrl(id) },
|
coverUrl = { id -> coverUrl(id) },
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||||
onShowContextMenu = ::showContextMenu,
|
onShowContextMenu = ::showContextMenu,
|
||||||
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@@ -257,14 +283,14 @@ fun AppNavigation() {
|
|||||||
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
|
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
|
||||||
) {
|
) {
|
||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
coverUrl = { id -> coverUrl(id) },
|
coverUrl = { id -> coverUrl(id) },
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||||
onShowContextMenu = ::showContextMenu,
|
onShowContextMenu = ::showContextMenu,
|
||||||
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@@ -272,12 +298,12 @@ fun AppNavigation() {
|
|||||||
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
||||||
) {
|
) {
|
||||||
PlaylistDetailScreen(
|
PlaylistDetailScreen(
|
||||||
coverUrl = { id -> coverUrl(id) },
|
coverUrl = { id -> coverUrl(id) },
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||||
onShowContextMenu = ::showContextMenu,
|
onShowContextMenu = ::showContextMenu,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@@ -286,13 +312,13 @@ fun AppNavigation() {
|
|||||||
) { backEntry ->
|
) { backEntry ->
|
||||||
val genreName = backEntry.arguments?.getString("genreName") ?: ""
|
val genreName = backEntry.arguments?.getString("genreName") ?: ""
|
||||||
GenreSongsScreen(
|
GenreSongsScreen(
|
||||||
genreName = genreName,
|
genreName = genreName,
|
||||||
coverUrl = { id -> coverUrl(id) },
|
coverUrl = { id -> coverUrl(id) },
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||||
onShowContextMenu = ::showContextMenu,
|
onShowContextMenu = ::showContextMenu,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("genres") {
|
composable("genres") {
|
||||||
@@ -310,7 +336,7 @@ fun AppNavigation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NowPlaying overlay — outside Scaffold so WindowInsets are not double-consumed
|
// NowPlaying overlay
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showNowPlaying,
|
visible = showNowPlaying,
|
||||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||||
@@ -322,12 +348,8 @@ fun AppNavigation() {
|
|||||||
queueCoverUrl = { id -> coverUrl(id) },
|
queueCoverUrl = { id -> coverUrl(id) },
|
||||||
controller = player,
|
controller = player,
|
||||||
onDismiss = { showNowPlaying = false },
|
onDismiss = { showNowPlaying = false },
|
||||||
onStarToggle = {
|
onStarToggle = { playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) } },
|
||||||
playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) }
|
onShowOptions = { playerState.currentSong?.let { s -> contextMenuSong = s } },
|
||||||
},
|
|
||||||
onShowOptions = {
|
|
||||||
playerState.currentSong?.let { s -> contextMenuSong = s }
|
|
||||||
},
|
|
||||||
onArtistClick = { artistId ->
|
onArtistClick = { artistId ->
|
||||||
showNowPlaying = false
|
showNowPlaying = false
|
||||||
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
|
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
|
||||||
@@ -336,7 +358,6 @@ fun AppNavigation() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Song context menu bottom sheet
|
|
||||||
contextMenuSong?.let { song ->
|
contextMenuSong?.let { song ->
|
||||||
SongContextSheet(
|
SongContextSheet(
|
||||||
song = song,
|
song = song,
|
||||||
@@ -346,25 +367,13 @@ fun AppNavigation() {
|
|||||||
onInsertNext = { player.insertNext(song) },
|
onInsertNext = { player.insertNext(song) },
|
||||||
onAddToQueue = { player.addToQueueEnd(song) },
|
onAddToQueue = { player.addToQueueEnd(song) },
|
||||||
onStarToggle = { starToggle(song.id, song.starred != null) },
|
onStarToggle = { starToggle(song.id, song.starred != null) },
|
||||||
onAddToPlaylist = {
|
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
||||||
playlistPickerSong = song
|
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
||||||
contextMenuSong = null
|
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
|
||||||
},
|
onDownload = { downloadSong(context, song, repo.streamUrl(song.id) ?: ""); 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 ->
|
playlistPickerSong?.let { song ->
|
||||||
PlaylistPickerSheet(
|
PlaylistPickerSheet(
|
||||||
song = song,
|
song = song,
|
||||||
@@ -389,12 +398,10 @@ fun AppNavigation() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Song info sheet
|
|
||||||
infoSheetSong?.let { song ->
|
infoSheetSong?.let { song ->
|
||||||
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
|
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification sheet
|
|
||||||
if (showNotifications) {
|
if (showNotifications) {
|
||||||
NotificationSheet(onDismiss = { showNotifications = false })
|
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()
|
Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val safe = (song.title + "." + (song.suffix ?: "mp3"))
|
val safe = (song.title + "." + (song.suffix ?: "mp3")).replace(Regex("[/\\\\:*?\"<>|]"), "_")
|
||||||
.replace(Regex("[/\\\\:*?\"<>|]"), "_")
|
|
||||||
val request = DownloadManager.Request(Uri.parse(streamUrl))
|
val request = DownloadManager.Request(Uri.parse(streamUrl))
|
||||||
.setTitle(song.title)
|
.setTitle(song.title)
|
||||||
.setDescription(song.artist ?: "")
|
.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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.novasonic.app.data.PrefsStore
|
||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
|
|
||||||
@@ -34,9 +35,25 @@ fun MiniPlayer(
|
|||||||
onTogglePlay: () -> Unit,
|
onTogglePlay: () -> Unit,
|
||||||
onSkipPrev: () -> Unit,
|
onSkipPrev: () -> Unit,
|
||||||
onSkipNext: () -> Unit,
|
onSkipNext: () -> Unit,
|
||||||
|
lastSong: PrefsStore.LastPlayedSong? = null,
|
||||||
|
lastSongCoverUrl: String? = null,
|
||||||
|
onPlayLast: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -55,78 +72,93 @@ fun MiniPlayer(
|
|||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = coverUrl,
|
model = coverUrl, contentDescription = null,
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||||
.size(44.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(NsSurface2),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = song.title,
|
text = song.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
fontFamily = Outfit,
|
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = NsOnSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
if (!song.artist.isNullOrBlank()) {
|
if (!song.artist.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = song.artist!!,
|
text = song.artist!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||||
fontFamily = Outfit,
|
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
fontSize = 12.sp,
|
|
||||||
color = NsOnSurface2,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
|
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(Icons.Default.SkipPrevious, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
|
||||||
Icons.Default.SkipPrevious,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = NsOnSurface2,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Accent play/pause circle button
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier.size(40.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onTogglePlay),
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(NsAccent)
|
|
||||||
.clickable(onClick = onTogglePlay),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
contentDescription = null,
|
contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp),
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
|
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(Icons.Default.SkipNext, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
|
||||||
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) {
|
if (state.durationMs > 0) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
|
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
|
||||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||||
color = NsAccent,
|
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.15f),
|
||||||
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -18,6 +18,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import com.novasonic.app.viewmodel.ConnStatus
|
||||||
import com.novasonic.app.viewmodel.SettingsViewModel
|
import com.novasonic.app.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
private val SCROBBLE_OPTIONS = listOf(
|
private val SCROBBLE_OPTIONS = listOf(
|
||||||
@@ -44,6 +46,7 @@ fun SettingsScreen() {
|
|||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var showPassword by remember { mutableStateOf(false) }
|
var showPassword by remember { mutableStateOf(false) }
|
||||||
|
var showAbsKey by remember { mutableStateOf(false) }
|
||||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||||
var scrobbleExpanded by remember { mutableStateOf(false) }
|
var scrobbleExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -58,9 +61,7 @@ fun SettingsScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showLogoutDialog = false }) {
|
TextButton(onClick = { showLogoutDialog = false }) { Text("Abbrechen", fontFamily = Outfit) }
|
||||||
Text("Abbrechen", fontFamily = Outfit)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
containerColor = NsSurface,
|
containerColor = NsSurface,
|
||||||
)
|
)
|
||||||
@@ -79,24 +80,23 @@ fun SettingsScreen() {
|
|||||||
color = NsOnSurface,
|
color = NsOnSurface,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||||
|
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||||
|
|
||||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||||
SectionHeader("🌐 Navidrome Server")
|
SectionHeader("🌐 Navidrome Server")
|
||||||
|
|
||||||
NsTextField(
|
NsTextField(
|
||||||
value = state.serverUrl,
|
value = state.serverUrl, onValueChange = vm::onServerUrl,
|
||||||
onValueChange = vm::onServerUrl,
|
label = "Server URL", placeholder = "https://musik.beispiel.de",
|
||||||
label = "Server URL",
|
|
||||||
placeholder = "https://musik.beispiel.de",
|
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
)
|
)
|
||||||
NsTextField(
|
NsTextField(
|
||||||
value = state.username,
|
value = state.username, onValueChange = vm::onUsername,
|
||||||
onValueChange = vm::onUsername,
|
|
||||||
label = "Benutzername",
|
label = "Benutzername",
|
||||||
)
|
)
|
||||||
NsTextField(
|
NsTextField(
|
||||||
value = state.password,
|
value = state.password, onValueChange = vm::onPassword,
|
||||||
onValueChange = vm::onPassword,
|
|
||||||
label = "Passwort",
|
label = "Passwort",
|
||||||
hint = "(unverändert lassen wenn kein Reset gewünscht)",
|
hint = "(unverändert lassen wenn kein Reset gewünscht)",
|
||||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
@@ -116,55 +116,35 @@ fun SettingsScreen() {
|
|||||||
// ── 📊 Bibliothek ────────────────────────────────────────────────────
|
// ── 📊 Bibliothek ────────────────────────────────────────────────────
|
||||||
SectionHeader("📊 Bibliothek")
|
SectionHeader("📊 Bibliothek")
|
||||||
|
|
||||||
// Scrobble-Mode dropdown
|
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = scrobbleExpanded,
|
|
||||||
onExpandedChange = { scrobbleExpanded = it },
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second
|
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
|
||||||
?: "30 Sekunden",
|
onValueChange = {}, readOnly = true,
|
||||||
onValueChange = {},
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
|
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
|
||||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
|
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
|
||||||
colors = fieldColors(),
|
colors = fieldColors(),
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(expanded = scrobbleExpanded, onDismissRequest = { scrobbleExpanded = false }, containerColor = NsSurface2) {
|
||||||
expanded = scrobbleExpanded,
|
|
||||||
onDismissRequest = { scrobbleExpanded = false },
|
|
||||||
containerColor = NsSurface2,
|
|
||||||
) {
|
|
||||||
SCROBBLE_OPTIONS.forEach { (key, label) ->
|
SCROBBLE_OPTIONS.forEach { (key, label) ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||||
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
|
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
|
||||||
colors = MenuDefaults.itemColors(
|
colors = MenuDefaults.itemColors(textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface),
|
||||||
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)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit,
|
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||||
fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
Text("Zeigt Wiedergabezähler in Song-Reihen an", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
Text("Zeigt Wiedergabezähler in Song-Reihen an",
|
|
||||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
|
||||||
}
|
}
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.showPlayCount,
|
checked = state.showPlayCount, onCheckedChange = vm::onShowPlayCount,
|
||||||
onCheckedChange = vm::onShowPlayCount,
|
|
||||||
colors = SwitchDefaults.colors(
|
colors = SwitchDefaults.colors(
|
||||||
checkedThumbColor = NsBackground,
|
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||||
checkedTrackColor = NsAccent,
|
|
||||||
uncheckedThumbColor = NsOnSurface2,
|
uncheckedThumbColor = NsOnSurface2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -174,60 +154,57 @@ fun SettingsScreen() {
|
|||||||
|
|
||||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||||
SectionHeader("🎨 Theme")
|
SectionHeader("🎨 Theme")
|
||||||
ThemePicker(
|
ThemePicker(currentThemeId = state.theme, onSelect = { id -> vm.onTheme(id); applyNsTheme(id) })
|
||||||
currentThemeId = state.theme,
|
|
||||||
onSelect = { id ->
|
|
||||||
vm.onTheme(id)
|
|
||||||
applyNsTheme(id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
// ── 🔑 APIs ──────────────────────────────────────────────────────────
|
// ── 📚 Audiobookshelf ────────────────────────────────────────────────
|
||||||
SectionHeader("🔑 APIs")
|
SectionHeader("📚 Audiobookshelf")
|
||||||
|
|
||||||
NsTextField(
|
NsTextField(
|
||||||
value = state.lastFmKey,
|
value = state.absUrl, onValueChange = vm::onAbsUrl,
|
||||||
onValueChange = vm::onLastFmKey,
|
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",
|
label = "Last.fm API Key",
|
||||||
hint = "Kostenlos unter last.fm/api/account/create",
|
hint = "Kostenlos unter last.fm/api/account/create",
|
||||||
)
|
)
|
||||||
NsTextField(
|
NsTextField(
|
||||||
value = state.backendUrl,
|
value = state.backendUrl, onValueChange = vm::onBackendUrl,
|
||||||
onValueChange = vm::onBackendUrl,
|
|
||||||
label = "NovaSonic Backend URL",
|
label = "NovaSonic Backend URL",
|
||||||
hint = "Für Download-to-Server via yt-dlp",
|
hint = "Für Download-to-Server via yt-dlp",
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
)
|
)
|
||||||
|
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
// Status + Save
|
// ── Save button (BOTTOM) ─────────────────────────────────────────────
|
||||||
if (state.error != null) {
|
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
||||||
|
|
||||||
@@ -238,14 +215,75 @@ fun SettingsScreen() {
|
|||||||
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
|
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text("Abmelden", color = MaterialTheme.colorScheme.error,
|
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
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
|
@Composable
|
||||||
private fun SectionHeader(title: String) {
|
private fun SectionHeader(title: String) {
|
||||||
Text(
|
Text(
|
||||||
@@ -271,8 +309,7 @@ private fun NsTextField(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value, onValueChange = onValueChange,
|
||||||
onValueChange = onValueChange,
|
|
||||||
label = { Text(label, fontFamily = Outfit) },
|
label = { Text(label, fontFamily = Outfit) },
|
||||||
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
|
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -308,29 +345,15 @@ private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
|
|||||||
.clickable { onSelect(theme.id) }
|
.clickable { onSelect(theme.id) }
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.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))
|
Spacer(Modifier.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = theme.name,
|
text = theme.name, fontFamily = Outfit,
|
||||||
fontFamily = Outfit,
|
|
||||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp, color = if (isActive) theme.onSurface else NsOnSurface2,
|
||||||
color = if (isActive) theme.onSurface else NsOnSurface2,
|
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (isActive) {
|
if (isActive) Icon(Icons.Default.Check, null, tint = theme.accent, modifier = Modifier.size(18.dp))
|
||||||
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
|
@Composable
|
||||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||||
focusedBorderColor = NsAccent,
|
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
|
||||||
focusedLabelColor = NsAccent,
|
unfocusedBorderColor = NsOnSurface3, unfocusedLabelColor = NsOnSurface2,
|
||||||
cursorColor = NsAccent,
|
focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
|
||||||
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 android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novasonic.app.api.AbsClient
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.data.PrefsStore
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
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(
|
data class SettingsUiState(
|
||||||
val serverUrl: String = "",
|
val serverUrl: String = "",
|
||||||
val username: String = "",
|
val username: String = "",
|
||||||
@@ -18,9 +31,13 @@ data class SettingsUiState(
|
|||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
val showPlayCount: Boolean = false,
|
||||||
val theme: String = "dark-ocean",
|
val theme: String = "dark-ocean",
|
||||||
|
val absUrl: String = "",
|
||||||
|
val absApiKey: String = "",
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val savedOk: Boolean = false,
|
val savedOk: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
val absStatus: ConnStatus = ConnStatus.Idle,
|
||||||
|
val backendStatus: ConnStatus = ConnStatus.Idle,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
@@ -42,6 +59,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
theme = s.theme,
|
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 onUsername(v: String) = update { it.copy(username = v, savedOk = false) }
|
||||||
fun onPassword(v: String) = update { it.copy(password = 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 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 onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = 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) {
|
fun onTheme(id: String) {
|
||||||
update { it.copy(theme = id) }
|
update { it.copy(theme = id) }
|
||||||
viewModelScope.launch { repo.saveTheme(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() {
|
fun save() {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||||
@@ -78,6 +139,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
theme = s.theme,
|
theme = s.theme,
|
||||||
|
absUrl = s.absUrl.trimEnd('/'),
|
||||||
|
absApiKey = s.absApiKey.trim(),
|
||||||
))
|
))
|
||||||
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
Reference in new issue
Block a user