feat: Android Auto, radio mode, lyrics overhaul + web lyrics cleanup

Android:
- fix: Android Auto credentials never set on MusicService → artwork/stream null
- feat: Android Auto tabs reduced to Zuletzt gespielt, Favoriten, Playlists
- feat: radio mode (getSimilarSongs2 → getRandomSongs fallback, <5 songs refill)
- fix: lyrics now use Navidrome getLyricsBySongId plugin exclusively
- fix: lyrics scroll rewritten with Column+ScrollState absolute animateScrollTo
- fix: lyrics recomposition lag — extracted LyricsLineItem + key(idx) per line
- chore: remove dead LrcLibClient (direct lrclib bypassed Navidrome plugin)

Web:
- fix: lyrics now use Navidrome getLyricsBySongId only, removed direct lrclib fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 20:30:20 +02:00
1 parent 8c8f03503b
commit 7e20062995
18 files changed
+700 -243

No files matched your search

@@ -54,9 +54,10 @@ object ItunesClient {
/** Worldwide top tracks for an iTunes artist */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId)
parameter("entity", "musicTrack")
parameter("limit", limit + 1) // first result is the artist itself
parameter("id", artistId)
parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
parameter("limit", limit + 1)
parameter("explicit", "Yes")
}.body()
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
}
@@ -1,64 +0,0 @@
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.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class LrcLibResponse(
val id: Long = 0,
val trackName: String = "",
val artistName: String = "",
val albumName: String = "",
val duration: Double = 0.0,
val instrumental: Boolean = false,
val plainLyrics: String? = null,
val syncedLyrics: String? = null,
)
data class LrcLine(val timeMs: Long, val text: String)
fun parseLrc(lrc: String): List<LrcLine> {
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
return lrc.lines()
.mapNotNull { line ->
regex.find(line.trim())?.let { m ->
val mm = m.groupValues[1].toLong()
val ss = m.groupValues[2].toLong()
val frac = m.groupValues[3]
val ms = mm * 60_000 + ss * 1_000 +
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
val text = m.groupValues[4].trim()
if (text.isNotEmpty()) LrcLine(ms, text) else null
}
}
.sortedBy { it.timeMs }
}
object LrcLibClient {
private val client = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true })
}
}
suspend fun getLyrics(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
): LrcLibResponse? = runCatching {
client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
parameter("track_name", title)
if (!album.isNullOrBlank()) parameter("album_name", album)
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
}.body<LrcLibResponse>()
}.getOrNull()
}
@@ -133,6 +133,9 @@ class SubsonicClient(
suspend fun getRandomSongs(size: Int = 50): List<Song> =
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList()
suspend fun getSimilarSongs(songId: String, count: Int = 20): List<Song> =
get("getSimilarSongs2.view", mapOf("id" to songId, "count" to count)).similarSongs2?.song ?: emptyList()
suspend fun star(id: String) = get("star.view", mapOf("id" to id))
suspend fun unstar(id: String) = get("unstar.view", mapOf("id" to id))
@@ -27,6 +27,7 @@ data class SubsonicResponse(
val song: Song? = null,
val starred2: Starred2? = null,
val randomSongs: SongListResult? = null,
val similarSongs2: SongListResult? = null,
val scanStatus: ScanStatus? = null,
val artistInfo2: ArtistInfo? = null,
val topSongs: SongListResult? = null,
@@ -48,6 +48,7 @@ class MusicRepository(private val context: Context) {
suspend fun getPlaylist(id: String) = api().getPlaylist(id)
suspend fun getStarred() = api().getStarred()
suspend fun getRandomSongs() = api().getRandomSongs()
suspend fun getSimilarSongs(songId: String, count: Int = 20) = api().getSimilarSongs(songId, count)
suspend fun star(id: String) = api().star(id)
suspend fun unstar(id: String) = api().unstar(id)
suspend fun scrobble(id: String) = api().scrobble(id)
@@ -0,0 +1,81 @@
package com.novasonic.app.data
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadStatus
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class AppNotification(
val id: String,
val title: String,
val artist: String = "",
val status: String, // "done" | "error" | "pending"
val message: String = "",
val timestamp: Long = System.currentTimeMillis(),
val read: Boolean = false,
)
object NotificationStore {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _items = MutableStateFlow<List<AppNotification>>(emptyList())
val items: StateFlow<List<AppNotification>> = _items.asStateFlow()
fun add(n: AppNotification) = _items.update { listOf(n) + it }
fun markAllRead() = _items.update { list -> list.map { it.copy(read = true) } }
fun remove(id: String) = _items.update { it.filter { n -> n.id != id } }
fun clear() { _items.value = emptyList() }
/**
* Submit a download job to the backend and poll until done/error,
* then add a notification. Safe to call from any ViewModel — uses
* an app-lifetime coroutine scope so the ViewModel can be cleared
* while polling continues.
*/
fun startPoll(
backendUrl: String,
jobId: String,
title: String,
artist: String,
) {
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
scope.launch {
val client = ImporterClient(backendUrl)
try {
repeat(240) { // up to ~6 minutes at 1.5 s interval
delay(1500)
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
?: return@repeat
when (st.status) {
"done" -> {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "done", message = st.message ?: "Fertig!",
))
return@launch
}
"error" -> {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "error", message = st.message ?: "Fehler",
))
return@launch
}
}
}
} finally {
client.close()
}
}
}
private fun replace(n: AppNotification) =
_items.update { list -> listOf(n) + list.filter { it.id != n.id } }
}
@@ -26,12 +26,13 @@ class MusicService : MediaLibraryService() {
// Android Auto root IDs
const val ROOT_ID = "ROOT"
const val RECENT_ID = "RECENT"
const val ALBUMS_ID = "ALBUMS"
const val ARTISTS_ID = "ARTISTS"
const val PLAYLISTS_ID = "PLAYLISTS"
const val FAVORITES_ID = "FAVORITES"
const val ITEM_PREFIX_ALBUM = "album:"
const val ITEM_PREFIX_ARTIST = "artist:"
const val ITEM_PREFIX_PL = "playlist:"
// Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:"
}
private lateinit var player: ExoPlayer
@@ -80,6 +81,12 @@ class MusicService : MediaLibraryService() {
.setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent)
.build()
// Keep credentials cached so coverArtUrl/streamUrl work when Auto calls us
// without the UI running (AppNavigation never subscribes on this repo instance).
scope.launch {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
}
override fun onGetSession(info: MediaSession.ControllerInfo) = session
@@ -102,7 +109,6 @@ class MusicService : MediaLibraryService() {
browser: MediaSession.ControllerInfo,
params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> {
val isAuto = params?.isRecent == true || isAndroidAuto(browser)
val root = MediaItem.Builder()
.setMediaId(ROOT_ID)
.setMediaMetadata(
@@ -125,7 +131,6 @@ class MusicService : MediaLibraryService() {
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val future = scope.async { buildChildren(parentId) }
val executor = MoreExecutors.directExecutor()
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
scope.launch {
try {
@@ -172,6 +177,12 @@ class MusicService : MediaLibraryService() {
try {
val resolved = mediaItems.flatMap { item ->
when {
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
}
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), shuffle = true)
}
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { song -> song.toMediaItem(repo) }
@@ -190,6 +201,25 @@ class MusicService : MediaLibraryService() {
}
return settable
}
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
val songs: List<com.novasonic.app.api.model.Song> = when {
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
repo.getAlbum(contextId.removePrefix(ITEM_PREFIX_ALBUM)).song
contextId.startsWith(ITEM_PREFIX_PL) ->
repo.getPlaylist(contextId.removePrefix(ITEM_PREFIX_PL)).entry
contextId == FAVORITES_ID ->
repo.getStarred().song
contextId == RECENT_ID -> {
repo.getRecentlyPlayedAlbums(20).flatMap { album ->
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
}
}
else -> emptyList()
}
val ordered = if (shuffle) songs.shuffled() else songs
return ordered.map { it.toMediaItem(repo) }
}
}
// ── Build Android Auto browsable tree ─────────────────────────────────────
@@ -197,27 +227,27 @@ class MusicService : MediaLibraryService() {
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
val items = when (parentId) {
ROOT_ID -> listOf(
browseFolder(RECENT_ID, "Recently Played", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(ALBUMS_ID, "Albums", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(ARTISTS_ID, "Artists", MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
browseFolder(RECENT_ID, "Zuletzt gespielt", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(FAVORITES_ID, "Favoriten", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED),
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
)
RECENT_ID -> repo.getRecentlyPlayedAlbums(20).map { it.toMediaItem(repo) }
ALBUMS_ID -> repo.getAlphaAlbums(40).map { it.toMediaItem(repo) }
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem() }
ARTISTS_ID -> repo.getArtists().flatMap { idx -> idx.artist.map { it.toMediaItem() } }
RECENT_ID -> {
val albums = repo.getRecentlyPlayedAlbums(20)
listOf(playAllItem(RECENT_ID), shuffleItem(RECENT_ID)) + albums.map { it.toMediaItem(repo) }
}
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
FAVORITES_ID -> {
val songs = repo.getStarred().song
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { it.toMediaItem(repo) }
}
else -> when {
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { it.toMediaItem(repo) }
}
parentId.startsWith(ITEM_PREFIX_ARTIST) -> {
val artist = repo.getArtist(parentId.removePrefix(ITEM_PREFIX_ARTIST))
artist.album.map { it.toMediaItem(repo) }
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
}
parentId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
pl.entry.map { it.toMediaItem(repo) }
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { it.toMediaItem(repo) }
}
else -> emptyList()
}
@@ -237,6 +267,28 @@ class MusicService : MediaLibraryService() {
.build()
).build()
private fun playAllItem(contextId: String) = MediaItem.Builder()
.setMediaId("$PLAY_ALL_PREFIX$contextId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("▶ Alle abspielen")
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
private fun shuffleItem(contextId: String) = MediaItem.Builder()
.setMediaId("$SHUFFLE_PREFIX$contextId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("⇀ Zufällig abspielen")
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
info.packageName.contains("android.auto", ignoreCase = true) ||
info.packageName.contains("goog.android.projected", ignoreCase = true)
@@ -282,26 +334,18 @@ fun com.novasonic.app.api.model.Album.toMediaItem(repo: MusicRepository): MediaI
).build()
}
fun com.novasonic.app.api.model.Artist.toMediaItem(): MediaItem =
MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_ARTIST}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
.build()
).build()
fun com.novasonic.app.api.model.Playlist.toMediaItem(): MediaItem =
MediaItem.Builder()
fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): MediaItem {
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
return MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setArtworkUri(artUri)
.setIsPlayable(true)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build()
).build()
}
@@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.novasonic.app.api.model.Song
@@ -25,6 +26,8 @@ data class PlayerState(
val repeatMode: Int = Player.REPEAT_MODE_OFF,
val shuffled: Boolean = false,
val volume: Float = 1f,
val artworkUri: String? = null,
val radioMode: Boolean = false,
)
class PlayerController(private val context: Context, private val repo: MusicRepository) {
@@ -132,6 +135,50 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
fun addToQueue(song: Song) = addToQueueEnd(song)
// ── Radio mode ────────────────────────────────────────────────────────────
private var _radioMode = false
fun toggleRadio() {
_radioMode = !_radioMode
_state.update { it.copy(radioMode = _radioMode) }
if (_radioMode) scope.launch { fillRadioQueue() }
}
private fun checkRadioRefill() {
if (!_radioMode) return
val remaining = queue.size - (_state.value.currentIndex + 1)
if (remaining < 5) scope.launch { fillRadioQueue() }
}
private suspend fun fillRadioQueue() {
val currentSong = _state.value.currentSong ?: return
val recentIds = queue.takeLast(50).map { it.id }.toHashSet()
var candidates = runCatching {
repo.getSimilarSongs(currentSong.id, 20)
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
if (candidates.isEmpty()) {
candidates = runCatching {
repo.getRandomSongs()
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
}
// Small library fallback: allow repeats rather than stopping
if (candidates.isEmpty()) {
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
}
val ctrl = controller ?: return
candidates.forEach { song ->
queue = queue + song
originalQueue = originalQueue + song
ctrl.addMediaItem(song.toMediaItem(repo))
}
syncState()
}
// ── Ticker ────────────────────────────────────────────────────────────────
private fun startTicker() {
@@ -163,6 +210,17 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
// ── Internal ──────────────────────────────────────────────────────────────
private fun songFromMediaItem(item: MediaItem): Song {
val meta = item.mediaMetadata
return Song(
id = item.mediaId,
title = meta.title?.toString() ?: "",
artist = meta.artist?.toString(),
album = meta.albumTitle?.toString(),
track = meta.trackNumber,
)
}
private fun syncState() {
val ctrl = controller ?: return
val idx = ctrl.currentMediaItemIndex
@@ -177,6 +235,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
repeatMode = ctrl.repeatMode,
shuffled = isManuallyShuffled,
volume = ctrl.volume,
artworkUri = ctrl.currentMediaItem?.mediaMetadata?.artworkUri?.toString(),
radioMode = _radioMode,
)
}
@@ -190,6 +250,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
syncState()
checkRadioRefill()
val ctrl = controller ?: return
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
val dur = ctrl.duration.coerceAtLeast(0)
@@ -198,5 +259,17 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
override fun onRepeatModeChanged(mode: Int) = syncState()
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val ctrl = controller ?: return
val ctrlCount = ctrl.mediaItemCount
// Rebuild queue from controller when items were set externally (e.g. Android Auto)
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED && ctrlCount != queue.size) {
queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) }
originalQueue = queue
isManuallyShuffled = false
}
syncState()
}
}
}
@@ -32,6 +32,7 @@ import androidx.navigation.compose.*
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.NotificationSheet
import com.novasonic.app.ui.component.PlaylistPickerSheet
import com.novasonic.app.ui.component.SongContextSheet
import com.novasonic.app.ui.component.SongInfoSheet
@@ -83,9 +84,10 @@ 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 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) }
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
@@ -121,7 +123,7 @@ fun AppNavigation() {
) {
MiniPlayer(
state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt),
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTap = { showNowPlaying = true },
onTogglePlay = { player.togglePlay() },
onSkipPrev = { player.skipPrev() },
@@ -185,6 +187,7 @@ fun AppNavigation() {
composable(TopLevel.HOME.route) {
HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) },
onBellClick = { showNotifications = true },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album ->
scope.launch { runCatching {
@@ -260,6 +263,7 @@ fun AppNavigation() {
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() },
)
}
@@ -314,7 +318,7 @@ fun AppNavigation() {
) {
NowPlayingScreen(
state = playerState,
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt),
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
queueCoverUrl = { id -> coverUrl(id) },
controller = player,
onDismiss = { showNowPlaying = false },
@@ -328,6 +332,7 @@ fun AppNavigation() {
showNowPlaying = false
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
},
onToggleRadio = { player.toggleRadio() },
)
}
@@ -388,6 +393,11 @@ fun AppNavigation() {
infoSheetSong?.let { song ->
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
}
// Notification sheet
if (showNotifications) {
NotificationSheet(onDismiss = { showNotifications = false })
}
}
}
@@ -0,0 +1,154 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
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.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import com.novasonic.app.ui.theme.*
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationSheet(onDismiss: () -> Unit) {
val items by NotificationStore.items.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { NotificationStore.markAllRead() }
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = null,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
) {
Icon(Icons.Default.Notifications, null,
tint = NsAccent, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
"Benachrichtigungen",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
if (items.isNotEmpty()) {
TextButton(onClick = { NotificationStore.clear() }) {
Text("Alle löschen", color = NsOnSurface2, fontFamily = Outfit, fontSize = 12.sp)
}
}
}
HorizontalDivider(color = NsBorder)
if (items.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(140.dp),
) {
Text("Keine Benachrichtigungen", color = NsOnSurface2, fontFamily = Outfit)
}
} else {
LazyColumn(
contentPadding = PaddingValues(top = 4.dp, bottom = 24.dp),
modifier = Modifier.navigationBarsPadding(),
) {
items(items, key = { it.id }) { n ->
NotifCard(n, onDelete = { NotificationStore.remove(n.id) })
}
}
}
}
}
@Composable
private fun NotifCard(n: AppNotification, onDelete: () -> Unit) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.background(
color = if (n.status == "done") Color(0xFF22C55E).copy(0.15f)
else if (n.status == "error") Color(0xFFEF4444).copy(0.15f)
else NsAccent.copy(0.15f),
shape = RoundedCornerShape(8.dp),
),
) {
Text(
text = when (n.status) {
"done" -> "✓"
"error" -> "✕"
else -> "…"
},
fontSize = 14.sp,
color = when (n.status) {
"done" -> Color(0xFF22C55E)
"error" -> Color(0xFFEF4444)
else -> NsAccent
},
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
n.title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface,
)
if (n.artist.isNotBlank()) {
Text(n.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
if (n.message.isNotBlank() && n.status != "pending") {
Text(
n.message,
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp),
)
}
Text(
formatTs(n.timestamp),
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.55f),
modifier = Modifier.padding(top = 4.dp),
)
}
if (n.status != "pending") {
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, null, tint = NsOnSurface2, modifier = Modifier.size(16.dp))
}
}
}
HorizontalDivider(
color = NsBorder.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)
private fun formatTs(ts: Long): String = _fmt.format(Date(ts))
@@ -51,6 +51,7 @@ fun ArtistDetailScreen(
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: ((String) -> Unit)? = null,
onBack: () -> Unit,
) {
val vm: ArtistViewModel = viewModel()
@@ -60,6 +61,13 @@ fun ArtistDetailScreen(
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
LaunchedEffect(state.pendingShufflePlay) {
state.pendingShufflePlay?.let { songs ->
onPlay(songs, 0)
vm.consumeShufflePlay()
}
}
// Concerts sheet
if (state.showEventsSheet) {
val artist = state.artist
@@ -208,9 +216,11 @@ fun ArtistDetailScreen(
}
}
item {
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0)
}
ArtistActionPill(
icon = Icons.Default.Shuffle,
label = if (state.isShuffling) "Laden…" else "Shuffle",
onClick = { vm.shuffleAll() },
)
}
item {
val eventCount = state.events.size
@@ -235,22 +245,30 @@ fun ArtistDetailScreen(
modifier = Modifier.padding(bottom = 20.dp),
) {
items(similarArtists) { sim ->
val navigable = onArtistClick != null && sim.id.isNotBlank()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(70.dp),
modifier = Modifier
.width(70.dp)
.then(
if (navigable) Modifier.clickable { onArtistClick!!(sim.id) }
else Modifier
),
) {
AsyncImage(
model = coverUrl(sim.coverArt),
contentDescription = sim.name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(60.dp).clip(CircleShape)
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(
sim.name, fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = if (navigable) NsOnSurface else NsOnSurface2,
maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -27,6 +28,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Genre
import com.novasonic.app.data.NotificationStore
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.HomeViewModel
@@ -74,10 +76,13 @@ fun HomeScreen(
onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null,
onBellClick: () -> Unit = {},
refreshKey: Int = 0,
) {
val vm: HomeViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val notifs by NotificationStore.items.collectAsStateWithLifecycle()
val unreadCount = notifs.count { !it.read }
LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
@@ -90,14 +95,34 @@ fun HomeScreen(
) {
item {
val name = state.username.replaceFirstChar { it.uppercaseChar() }
Text(
text = if (name.isBlank()) greeting else "$greetingWithName $name",
fontFamily = Outfit,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = NsOnSurface,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 20.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 24.dp, bottom = 20.dp),
) {
Text(
text = if (name.isBlank()) greeting else "$greetingWithName $name",
fontFamily = Outfit,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = NsOnSurface,
modifier = Modifier.weight(1f),
)
BadgedBox(
badge = {
if (unreadCount > 0) Badge(containerColor = NsAccent) {
Text(
if (unreadCount > 9) "9+" else "$unreadCount",
fontSize = 9.sp, color = NsBackground,
)
}
}
) {
IconButton(onClick = onBellClick) {
Icon(Icons.Default.Notifications, "Benachrichtigungen",
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
}
}
}
}
if (state.isLoading) {
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -35,6 +36,7 @@ import androidx.compose.ui.platform.LocalDensity
import coil.request.ImageRequest
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -62,6 +64,7 @@ fun NowPlayingScreen(
onStarToggle: (() -> Unit)? = null,
onShowOptions: (() -> Unit)? = null,
onArtistClick: ((artistId: String) -> Unit)? = null,
onToggleRadio: (() -> Unit)? = null,
) {
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
@@ -137,7 +140,7 @@ fun NowPlayingScreen(
.clip(RoundedCornerShape(16.dp))
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
.clickable { tab = t }
.padding(horizontal = 14.dp, vertical = 6.dp),
.padding(horizontal = 18.dp, vertical = 9.dp),
) {
Text(
when (t) {
@@ -145,7 +148,7 @@ fun NowPlayingScreen(
NpTab.LYRICS -> "Lyrics"
NpTab.QUEUE -> "Queue"
},
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
)
}
@@ -239,8 +242,10 @@ fun NowPlayingScreen(
artistList.forEachIndexed { idx, a ->
if (idx > 0) {
Text(
" · ", fontFamily = Outfit,
fontSize = 14.sp, color = Color.White.copy(0.35f),
" · ",
fontFamily = Outfit,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.35f),
)
}
val navigable = onArtistClick != null && a.id.isNotBlank()
@@ -250,10 +255,11 @@ fun NowPlayingScreen(
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (navigable) NsAccent else NsAccent.copy(alpha = 0.55f),
modifier = if (navigable)
Modifier.clickable { onArtistClick!!(a.id) }
Modifier.pointerInput(a.id) {
detectTapGestures { onArtistClick!!(a.id) }
}
else Modifier,
)
}
@@ -357,25 +363,30 @@ fun NowPlayingScreen(
if (tab == NpTab.LYRICS && song != null) {
val lyricsVm: LyricsViewModel = viewModel()
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val lyricsScrollState = rememberScrollState()
val density = LocalDensity.current
// Pre-compute pixel constants for absolute scroll target calculation
val lineHeightPx = remember(density) {
with(density) { 28.sp.toPx() + 12.dp.toPx() } // lineHeight + 2×vertical padding
}
val lyricsPaddingPx = remember(density) { with(density) { 200.dp.toPx() } }
var lyricsViewH by remember { mutableIntStateOf(0) }
LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
// 200ms lookahead — same as web app for better sync feel
val activeIdx = if (lyricsState.lines.isNotEmpty())
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs }.coerceAtLeast(0)
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
else -1
// Smooth scroll to active line
// Absolute scroll: always lands on the exact pixel, no accumulation lag
LaunchedEffect(activeIdx) {
if (activeIdx >= 0 && lyricsState.lines.isNotEmpty()) {
scope.launch {
listState.animateScrollToItem(
(activeIdx - 2).coerceAtLeast(0),
scrollOffset = 0,
)
}
}
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
// Center of active line sits at 30% from top (clearly upper area)
val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
- lyricsViewH * 0.30f).coerceAtLeast(0f)
lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing))
}
when {
@@ -398,45 +409,26 @@ fun NowPlayingScreen(
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
}
lyricsState.lines.isNotEmpty() -> LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 40.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
lyricsState.lines.isNotEmpty() -> Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.onSizeChanged { lyricsViewH = it.height }
.verticalScroll(lyricsScrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(lyricsState.lines) { idx, line ->
val isActive = idx == activeIdx
val isPast = idx < activeIdx
val targetColor = when {
isActive -> Color.White
isPast -> Color.White.copy(alpha = 0.32f)
else -> Color.White.copy(alpha = 0.55f)
Spacer(Modifier.height(200.dp))
lyricsState.lines.forEachIndexed { idx, line ->
key(idx) {
LyricsLineItem(
text = line.text,
isActive = idx == activeIdx,
isPast = idx < activeIdx,
onSeek = { controller.seekTo(line.timeMs) },
)
}
val animColor by animateColorAsState(
targetValue = targetColor,
animationSpec = tween(350),
label = "lyricColor$idx",
)
val animSize by animateFloatAsState(
targetValue = if (isActive) 20f else 16f,
animationSpec = tween(300),
label = "lyricSize$idx",
)
Text(
text = line.text,
fontFamily = Outfit,
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Normal,
fontSize = animSize.sp,
color = animColor,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable { controller.seekTo(line.timeMs) }
.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
Spacer(Modifier.height(200.dp))
}
lyricsState.plainText.isNotBlank() -> LazyColumn(
@@ -458,6 +450,53 @@ fun NowPlayingScreen(
// ── QUEUE TAB ──────────────────────────────────────────────────
if (tab == NpTab.QUEUE) {
// Radio mode toggle
if (onToggleRadio != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (state.radioMode) NsAccent.copy(alpha = 0.18f)
else Color.White.copy(alpha = 0.07f)
)
.clickable(onClick = onToggleRadio)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Icon(
Icons.Default.Radio, null,
tint = if (state.radioMode) NsAccent else Color.White.copy(0.55f),
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"Radio-Modus",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = if (state.radioMode) NsAccent else Color.White.copy(0.85f),
)
Text(
"Lädt ähnliche Songs automatisch nach",
fontFamily = Outfit, fontSize = 11.sp,
color = Color.White.copy(0.4f),
)
}
Switch(
checked = state.radioMode,
onCheckedChange = { onToggleRadio() },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = NsAccent,
uncheckedThumbColor = Color.White.copy(0.6f),
uncheckedTrackColor = Color.White.copy(0.15f),
),
)
}
}
if (state.queue.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
@@ -595,3 +634,40 @@ private fun formatMs(ms: Long): String {
val s = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(s / 60, s % 60)
}
@Composable
private fun LyricsLineItem(
text: String,
isActive: Boolean,
isPast: Boolean,
onSeek: () -> Unit,
) {
val animColor by animateColorAsState(
targetValue = when {
isActive -> Color.White
isPast -> Color.White.copy(alpha = 0.45f)
else -> Color.White.copy(alpha = 0.22f)
},
animationSpec = tween(300),
label = "lyricColor",
)
val animScale by animateFloatAsState(
targetValue = if (isActive) 1.04f else 1f,
animationSpec = tween(300),
label = "lyricScale",
)
Text(
text = text,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 28.sp,
color = animColor,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = animScale; scaleY = animScale }
.clickable { onSeek() }
.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
@@ -15,6 +15,7 @@ import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +30,9 @@ data class ArtistUiState(
val iAlbums: List<ItunesItem> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(),
val itunesLoading: Boolean = false,
// Shuffle all
val isShuffling: Boolean = false,
val pendingShufflePlay: List<Song>? = null,
// Sub-album detail (tapped not-downloaded album)
val subAlbumItem: ItunesItem? = null,
val subAlbumTracks: List<ItunesItem>? = null,
@@ -46,6 +50,7 @@ data class ArtistUiState(
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
private val player = app.app.playerController
private val artistId: String? = savedState["artistId"]
private val _state = MutableStateFlow(ArtistUiState())
@@ -59,6 +64,11 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
} else {
load()
}
viewModelScope.launch {
player.state.collect { ps ->
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
}
}
}
fun load() {
@@ -98,13 +108,15 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
}.getOrNull()
val iArtistId = searchResp?.results
?.filter { it.wrapperType == "artist" }
?.find {
it.artistName?.equals(artistName, ignoreCase = true) == true ||
it.artistName?.contains(artistName, ignoreCase = true) == true
}
?.artistId
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
val iArtistId = (
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
?: artists.find {
val n = it.artistName ?: return@find false
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
}
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
)?.artistId
if (iArtistId != null) {
coroutineScope {
@@ -173,6 +185,25 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
// ── Download sheet ───────────────────────────────────────────────────────
fun shuffleAll() {
val artist = _state.value.artist ?: return
if (_state.value.isShuffling) return
viewModelScope.launch {
_state.value = _state.value.copy(isShuffling = true)
val allSongs = artist.album.flatMap { album ->
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
}
_state.value = _state.value.copy(
isShuffling = false,
pendingShufflePlay = allSongs.shuffled().ifEmpty { null },
)
}
}
fun consumeShufflePlay() {
_state.value = _state.value.copy(pendingShufflePlay = null)
}
fun openEventsSheet() { _state.value = _state.value.copy(showEventsSheet = true) }
fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) }
@@ -214,7 +245,10 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
}
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess { onDone(true, "✓ Download gestartet") }
.onSuccess {
NotificationStore.startPoll(backendUrl, request.jobId, request.title, request.artist ?: "")
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
@@ -230,6 +264,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
private fun startPreview(url: String, id: Long) {
stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -66,7 +66,8 @@ data class ExploreUiState(
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val repo = app.app.repository
private val player = app.app.playerController
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
val state: StateFlow<ExploreUiState> = _state
@@ -74,7 +75,14 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private var searchJob: Job? = null
private var mediaPlayer: MediaPlayer? = null
init { loadCharts() }
init {
loadCharts()
viewModelScope.launch {
player.state.collect { ps ->
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
}
}
}
fun selectTab(tab: ExploreTab) {
_state.value = _state.value.copy(tab = tab, albumTracks = null)
@@ -264,6 +272,7 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private fun startPreview(url: String, id: Long) {
stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -7,6 +7,8 @@ import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -120,6 +122,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
}
private suspend fun pollJob(client: ImporterClient, jobId: String) {
val s = _state.value
val title = s.resolved?.name ?: "Import"
val artist = s.resolved?.artist ?: ""
try {
repeat(600) { // up to ~20 min at 2s interval
delay(2000)
@@ -132,6 +137,13 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
)
if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false)
NotificationStore.add(AppNotification(
id = jobId,
title = title,
artist = artist,
status = st.status ?: "error",
message = st.message ?: if (st.status == "done") "Fertig!" else "Fehler",
))
return
}
}
@@ -3,11 +3,10 @@
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.LrcLibClient
import com.novasonic.app.api.LrcLine
import com.novasonic.app.api.parseLrc
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.data.MusicRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -22,6 +21,7 @@ data class LyricsUiState(
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app.applicationContext)
private val _state = MutableStateFlow(LyricsUiState())
val state: StateFlow<LyricsUiState> = _state
@@ -29,24 +29,30 @@ class LyricsViewModel(app: Application) : AndroidViewModel(app) {
if (_state.value.songId == song.id && !_state.value.isLoading) return
_state.value = LyricsUiState(isLoading = true, songId = song.id)
viewModelScope.launch {
val artist = song.artist ?: ""
val title = song.title
val album = song.album
val durSecs = song.duration
val result = LrcLibClient.getLyrics(artist, title, album, durSecs)
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull()
val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() }
val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() }
when {
result == null -> _state.value = LyricsUiState(
notFound = true, songId = song.id)
result.instrumental -> _state.value = LyricsUiState(
plainText = "🎵 Instrumentalstück", songId = song.id)
!result.syncedLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
lines = parseLrc(result.syncedLyrics), songId = song.id)
!result.plainLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
plainText = result.plainLyrics, songId = song.id)
else -> _state.value = LyricsUiState(
notFound = true, songId = song.id)
synced != null -> {
val lines = synced.line
.mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } }
.filter { it.text.isNotBlank() }
.sortedBy { it.timeMs }
_state.value = if (lines.isNotEmpty())
LyricsUiState(lines = lines, songId = song.id)
else
LyricsUiState(notFound = true, songId = song.id)
}
plain != null -> {
val text = plain.line.joinToString("\n") { it.value }.trim()
_state.value = if (text.isNotBlank())
LyricsUiState(plainText = text, songId = song.id)
else
LyricsUiState(notFound = true, songId = song.id)
}
else -> _state.value = LyricsUiState(notFound = true, songId = song.id)
}
}
}
+14 -42
View File
@@ -147,61 +147,33 @@ async function _loadNpLyrics(songId) {
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
const token = ++_lyricsLoadToken;
const s = S.queue[S.qIdx];
const { getLyricsBySongId } = await import('../api.js');
if(token !== _lyricsLoadToken) return;
// 1. Navidrome (plugin handles fetching + caching server-side)
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
const lyrics = await getLyricsBySongId(songId);
if(token !== _lyricsLoadToken) return;
const track = lyrics[0];
if(track?.synced && track.line?.length) {
content.innerHTML = '';
_renderSyncedLines(content, track.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value));
return;
}
// 2. Fallback: LRCLIB directly (synced LRC format)
const firstArtist = (s?.artist||'').split(/[/,]/)[0].trim();
let lrcText = null;
let lrclibPlain = null;
try {
const attempts = [
new URLSearchParams({ artist_name: firstArtist, track_name: s?.title||'', ...(s?.album ? { album_name: s.album } : {}) }),
...(s?.album ? [new URLSearchParams({ artist_name: firstArtist, track_name: s?.title||'' })] : []),
];
for(const p of attempts) {
if(token !== _lyricsLoadToken) return;
const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: AbortSignal.timeout(6000) });
if(r.ok) {
const d = await r.json();
lrcText = d.syncedLyrics || null;
lrclibPlain = d.plainLyrics || null;
if(lrcText || lrclibPlain) break;
}
}
} catch {}
if(token !== _lyricsLoadToken) return;
content.innerHTML = '';
if(lrcText) {
const parsed = _parseLrc(lrcText);
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
}
const synced = lyrics.find(t => t.synced && t.line?.length);
const plain = lyrics.find(t => t.line?.length);
const plain = lrclibPlain || (track?.line?.length ? track.line.map(l => l.value).join('\n') : null);
if(synced) {
_renderSyncedLines(content, synced.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value));
return;
}
if(plain) {
const el = document.createElement('div');
el.className = 'lyrics-plain';
el.textContent = plain;
content.appendChild(el);
} else {
const el = document.createElement('div');
el.className = 'lyrics-none';
el.style.cssText = 'text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px';
el.textContent = 'Keine Lyrics verfügbar';
el.textContent = plain.line.map(l => l.value).join('\n');
content.appendChild(el);
return;
}
const el = document.createElement('div');
el.className = 'lyrics-none';
el.style.cssText = 'text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px';
el.textContent = 'Keine Lyrics verfügbar';
content.appendChild(el);
}
export function updateLyricsHighlight(currentMs) {