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 */ /** Worldwide top tracks for an iTunes artist */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> { suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") { val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId) parameter("id", artistId)
parameter("entity", "musicTrack") parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
parameter("limit", limit + 1) // first result is the artist itself parameter("limit", limit + 1)
parameter("explicit", "Yes")
}.body() }.body()
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit) 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> = suspend fun getRandomSongs(size: Int = 50): List<Song> =
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList() 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 star(id: String) = get("star.view", mapOf("id" to id))
suspend fun unstar(id: String) = get("unstar.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 song: Song? = null,
val starred2: Starred2? = null, val starred2: Starred2? = null,
val randomSongs: SongListResult? = null, val randomSongs: SongListResult? = null,
val similarSongs2: SongListResult? = null,
val scanStatus: ScanStatus? = null, val scanStatus: ScanStatus? = null,
val artistInfo2: ArtistInfo? = null, val artistInfo2: ArtistInfo? = null,
val topSongs: SongListResult? = 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 getPlaylist(id: String) = api().getPlaylist(id)
suspend fun getStarred() = api().getStarred() suspend fun getStarred() = api().getStarred()
suspend fun getRandomSongs() = api().getRandomSongs() 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 star(id: String) = api().star(id)
suspend fun unstar(id: String) = api().unstar(id) suspend fun unstar(id: String) = api().unstar(id)
suspend fun scrobble(id: String) = api().scrobble(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 // Android Auto root IDs
const val ROOT_ID = "ROOT" const val ROOT_ID = "ROOT"
const val RECENT_ID = "RECENT" const val RECENT_ID = "RECENT"
const val ALBUMS_ID = "ALBUMS"
const val ARTISTS_ID = "ARTISTS"
const val PLAYLISTS_ID = "PLAYLISTS" const val PLAYLISTS_ID = "PLAYLISTS"
const val FAVORITES_ID = "FAVORITES"
const val ITEM_PREFIX_ALBUM = "album:" const val ITEM_PREFIX_ALBUM = "album:"
const val ITEM_PREFIX_ARTIST = "artist:"
const val ITEM_PREFIX_PL = "playlist:" 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 private lateinit var player: ExoPlayer
@@ -80,6 +81,12 @@ class MusicService : MediaLibraryService() {
.setBitmapLoader(bitmapLoader) .setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent) .setSessionActivity(activityIntent)
.build() .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 override fun onGetSession(info: MediaSession.ControllerInfo) = session
@@ -102,7 +109,6 @@ class MusicService : MediaLibraryService() {
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
params: LibraryParams?, params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> { ): ListenableFuture<LibraryResult<MediaItem>> {
val isAuto = params?.isRecent == true || isAndroidAuto(browser)
val root = MediaItem.Builder() val root = MediaItem.Builder()
.setMediaId(ROOT_ID) .setMediaId(ROOT_ID)
.setMediaMetadata( .setMediaMetadata(
@@ -125,7 +131,6 @@ class MusicService : MediaLibraryService() {
params: LibraryParams?, params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val future = scope.async { buildChildren(parentId) } val future = scope.async { buildChildren(parentId) }
val executor = MoreExecutors.directExecutor()
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>() val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
scope.launch { scope.launch {
try { try {
@@ -172,6 +177,12 @@ class MusicService : MediaLibraryService() {
try { try {
val resolved = mediaItems.flatMap { item -> val resolved = mediaItems.flatMap { item ->
when { 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) -> { item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)) val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { song -> song.toMediaItem(repo) } album.song.map { song -> song.toMediaItem(repo) }
@@ -190,6 +201,25 @@ class MusicService : MediaLibraryService() {
} }
return settable 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 ───────────────────────────────────── // ── Build Android Auto browsable tree ─────────────────────────────────────
@@ -197,27 +227,27 @@ class MusicService : MediaLibraryService() {
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> { private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
val items = when (parentId) { val items = when (parentId) {
ROOT_ID -> listOf( ROOT_ID -> listOf(
browseFolder(RECENT_ID, "Recently Played", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), browseFolder(RECENT_ID, "Zuletzt gespielt", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(ALBUMS_ID, "Albums", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), browseFolder(FAVORITES_ID, "Favoriten", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED),
browseFolder(ARTISTS_ID, "Artists", MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
) )
RECENT_ID -> repo.getRecentlyPlayedAlbums(20).map { it.toMediaItem(repo) } RECENT_ID -> {
ALBUMS_ID -> repo.getAlphaAlbums(40).map { it.toMediaItem(repo) } val albums = repo.getRecentlyPlayedAlbums(20)
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem() } listOf(playAllItem(RECENT_ID), shuffleItem(RECENT_ID)) + albums.map { it.toMediaItem(repo) }
ARTISTS_ID -> repo.getArtists().flatMap { idx -> idx.artist.map { it.toMediaItem() } } }
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 { else -> when {
parentId.startsWith(ITEM_PREFIX_ALBUM) -> { parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM)) val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { it.toMediaItem(repo) } listOf(playAllItem(parentId), shuffleItem(parentId)) + 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) }
} }
parentId.startsWith(ITEM_PREFIX_PL) -> { parentId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(parentId.removePrefix(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() else -> emptyList()
} }
@@ -237,6 +267,28 @@ class MusicService : MediaLibraryService() {
.build() .build()
).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 = private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
info.packageName.contains("android.auto", ignoreCase = true) || info.packageName.contains("android.auto", ignoreCase = true) ||
info.packageName.contains("goog.android.projected", 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() ).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 = fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): MediaItem {
MediaItem.Builder() val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
return MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id") .setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(name) .setTitle(name)
.setArtworkUri(artUri)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build() .build()
).build() ).build()
}
@@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
@@ -25,6 +26,8 @@ data class PlayerState(
val repeatMode: Int = Player.REPEAT_MODE_OFF, val repeatMode: Int = Player.REPEAT_MODE_OFF,
val shuffled: Boolean = false, val shuffled: Boolean = false,
val volume: Float = 1f, val volume: Float = 1f,
val artworkUri: String? = null,
val radioMode: Boolean = false,
) )
class PlayerController(private val context: Context, private val repo: MusicRepository) { 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) 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 ──────────────────────────────────────────────────────────────── // ── Ticker ────────────────────────────────────────────────────────────────
private fun startTicker() { private fun startTicker() {
@@ -163,6 +210,17 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
// ── Internal ────────────────────────────────────────────────────────────── // ── 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() { private fun syncState() {
val ctrl = controller ?: return val ctrl = controller ?: return
val idx = ctrl.currentMediaItemIndex val idx = ctrl.currentMediaItemIndex
@@ -177,6 +235,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
repeatMode = ctrl.repeatMode, repeatMode = ctrl.repeatMode,
shuffled = isManuallyShuffled, shuffled = isManuallyShuffled,
volume = ctrl.volume, 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) { override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
syncState() syncState()
checkRadioRefill()
val ctrl = controller ?: return val ctrl = controller ?: return
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)
@@ -198,5 +259,17 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState() override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
override fun onRepeatModeChanged(mode: Int) = 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.api.model.Song
import com.novasonic.app.app import com.novasonic.app.app
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.PlaylistPickerSheet import com.novasonic.app.ui.component.PlaylistPickerSheet
import com.novasonic.app.ui.component.SongContextSheet import com.novasonic.app.ui.component.SongContextSheet
import com.novasonic.app.ui.component.SongInfoSheet import com.novasonic.app.ui.component.SongInfoSheet
@@ -83,9 +84,10 @@ 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) }
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)
@@ -121,7 +123,7 @@ fun AppNavigation() {
) { ) {
MiniPlayer( MiniPlayer(
state = playerState, state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt), 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() },
@@ -185,6 +187,7 @@ fun AppNavigation() {
composable(TopLevel.HOME.route) { composable(TopLevel.HOME.route) {
HomeScreen( HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) }, coverUrl = { album -> coverUrl(album.coverArt) },
onBellClick = { showNotifications = true },
onAlbumClick = { album -> navController.navigate("album/${album.id}") }, onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album -> onAlbumPlay = { album ->
scope.launch { runCatching { scope.launch { runCatching {
@@ -260,6 +263,7 @@ fun AppNavigation() {
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") },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
@@ -314,7 +318,7 @@ fun AppNavigation() {
) { ) {
NowPlayingScreen( NowPlayingScreen(
state = playerState, state = playerState,
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt), coverUrl = heroCoverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
queueCoverUrl = { id -> coverUrl(id) }, queueCoverUrl = { id -> coverUrl(id) },
controller = player, controller = player,
onDismiss = { showNowPlaying = false }, onDismiss = { showNowPlaying = false },
@@ -328,6 +332,7 @@ fun AppNavigation() {
showNowPlaying = false showNowPlaying = false
if (artistId.isNotBlank()) navController.navigate("artist/$artistId") if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
}, },
onToggleRadio = { player.toggleRadio() },
) )
} }
@@ -388,6 +393,11 @@ fun AppNavigation() {
infoSheetSong?.let { song -> infoSheetSong?.let { song ->
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null }) 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, onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onStarToggle: (Song) -> Unit, onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null, onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: ((String) -> Unit)? = null,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val vm: ArtistViewModel = viewModel() val vm: ArtistViewModel = viewModel()
@@ -60,6 +61,13 @@ fun ArtistDetailScreen(
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() } BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() } BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
LaunchedEffect(state.pendingShufflePlay) {
state.pendingShufflePlay?.let { songs ->
onPlay(songs, 0)
vm.consumeShufflePlay()
}
}
// Concerts sheet // Concerts sheet
if (state.showEventsSheet) { if (state.showEventsSheet) {
val artist = state.artist val artist = state.artist
@@ -208,9 +216,11 @@ fun ArtistDetailScreen(
} }
} }
item { item {
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") { ArtistActionPill(
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0) icon = Icons.Default.Shuffle,
} label = if (state.isShuffling) "Laden…" else "Shuffle",
onClick = { vm.shuffleAll() },
)
} }
item { item {
val eventCount = state.events.size val eventCount = state.events.size
@@ -235,22 +245,30 @@ fun ArtistDetailScreen(
modifier = Modifier.padding(bottom = 20.dp), modifier = Modifier.padding(bottom = 20.dp),
) { ) {
items(similarArtists) { sim -> items(similarArtists) { sim ->
val navigable = onArtistClick != null && sim.id.isNotBlank()
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(70.dp), modifier = Modifier
.width(70.dp)
.then(
if (navigable) Modifier.clickable { onArtistClick!!(sim.id) }
else Modifier
),
) { ) {
AsyncImage( AsyncImage(
model = coverUrl(sim.coverArt), model = coverUrl(sim.coverArt),
contentDescription = sim.name, contentDescription = sim.name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.size(60.dp).clip(CircleShape) modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(NsSurface2), .background(NsSurface2),
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
sim.name, fontFamily = Outfit, fontSize = 11.sp, sim.name, fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 2, color = if (navigable) NsOnSurface else NsOnSurface2,
overflow = TextOverflow.Ellipsis, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center, 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.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -27,6 +28,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Genre 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.component.AlbumCard
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.HomeViewModel import com.novasonic.app.viewmodel.HomeViewModel
@@ -74,10 +76,13 @@ fun HomeScreen(
onGenreClick: (Genre) -> Unit, onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)? = null, onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null, onShowAllGenres: (() -> Unit)? = null,
onBellClick: () -> Unit = {},
refreshKey: Int = 0, refreshKey: Int = 0,
) { ) {
val vm: HomeViewModel = viewModel() val vm: HomeViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() 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() } LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
@@ -90,14 +95,34 @@ fun HomeScreen(
) { ) {
item { item {
val name = state.username.replaceFirstChar { it.uppercaseChar() } val name = state.username.replaceFirstChar { it.uppercaseChar() }
Text( Row(
text = if (name.isBlank()) greeting else "$greetingWithName $name", verticalAlignment = Alignment.CenterVertically,
fontFamily = Outfit, modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 24.dp, bottom = 20.dp),
fontSize = 24.sp, ) {
fontWeight = FontWeight.Bold, Text(
color = NsOnSurface, text = if (name.isBlank()) greeting else "$greetingWithName $name",
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 20.dp), 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) { if (state.isLoading) {
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -35,6 +36,7 @@ import androidx.compose.ui.platform.LocalDensity
import coil.request.ImageRequest import coil.request.ImageRequest
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -62,6 +64,7 @@ fun NowPlayingScreen(
onStarToggle: (() -> Unit)? = null, onStarToggle: (() -> Unit)? = null,
onShowOptions: (() -> Unit)? = null, onShowOptions: (() -> Unit)? = null,
onArtistClick: ((artistId: String) -> Unit)? = null, onArtistClick: ((artistId: String) -> Unit)? = null,
onToggleRadio: (() -> Unit)? = null,
) { ) {
val song = state.currentSong val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) } var tab by remember { mutableStateOf(NpTab.PLAYER) }
@@ -137,7 +140,7 @@ fun NowPlayingScreen(
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent) .background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
.clickable { tab = t } .clickable { tab = t }
.padding(horizontal = 14.dp, vertical = 6.dp), .padding(horizontal = 18.dp, vertical = 9.dp),
) { ) {
Text( Text(
when (t) { when (t) {
@@ -145,7 +148,7 @@ fun NowPlayingScreen(
NpTab.LYRICS -> "Lyrics" NpTab.LYRICS -> "Lyrics"
NpTab.QUEUE -> "Queue" 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), color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
) )
} }
@@ -239,8 +242,10 @@ fun NowPlayingScreen(
artistList.forEachIndexed { idx, a -> artistList.forEachIndexed { idx, a ->
if (idx > 0) { if (idx > 0) {
Text( 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() val navigable = onArtistClick != null && a.id.isNotBlank()
@@ -250,10 +255,11 @@ fun NowPlayingScreen(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (navigable) NsAccent else NsAccent.copy(alpha = 0.55f), color = if (navigable) NsAccent else NsAccent.copy(alpha = 0.55f),
modifier = if (navigable) modifier = if (navigable)
Modifier.clickable { onArtistClick!!(a.id) } Modifier.pointerInput(a.id) {
detectTapGestures { onArtistClick!!(a.id) }
}
else Modifier, else Modifier,
) )
} }
@@ -357,25 +363,30 @@ fun NowPlayingScreen(
if (tab == NpTab.LYRICS && song != null) { if (tab == NpTab.LYRICS && song != null) {
val lyricsVm: LyricsViewModel = viewModel() val lyricsVm: LyricsViewModel = viewModel()
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle() val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val lyricsScrollState = rememberScrollState()
val scope = rememberCoroutineScope() 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) } LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
// 200ms lookahead — same as web app for better sync feel
val activeIdx = if (lyricsState.lines.isNotEmpty()) 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 else -1
// Smooth scroll to active line // Absolute scroll: always lands on the exact pixel, no accumulation lag
LaunchedEffect(activeIdx) { LaunchedEffect(activeIdx) {
if (activeIdx >= 0 && lyricsState.lines.isNotEmpty()) { if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
scope.launch { // Center of active line sits at 30% from top (clearly upper area)
listState.animateScrollToItem( val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
(activeIdx - 2).coerceAtLeast(0), - lyricsViewH * 0.30f).coerceAtLeast(0f)
scrollOffset = 0, lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing))
)
}
}
} }
when { when {
@@ -398,45 +409,26 @@ fun NowPlayingScreen(
color = Color.White.copy(0.4f), textAlign = TextAlign.Center) color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
} }
lyricsState.lines.isNotEmpty() -> LazyColumn( lyricsState.lines.isNotEmpty() -> Column(
state = listState, modifier = Modifier
modifier = Modifier.weight(1f).fillMaxWidth(), .weight(1f)
contentPadding = PaddingValues(vertical = 40.dp), .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(6.dp), .onSizeChanged { lyricsViewH = it.height }
.verticalScroll(lyricsScrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
itemsIndexed(lyricsState.lines) { idx, line -> Spacer(Modifier.height(200.dp))
val isActive = idx == activeIdx lyricsState.lines.forEachIndexed { idx, line ->
val isPast = idx < activeIdx key(idx) {
LyricsLineItem(
val targetColor = when { text = line.text,
isActive -> Color.White isActive = idx == activeIdx,
isPast -> Color.White.copy(alpha = 0.32f) isPast = idx < activeIdx,
else -> Color.White.copy(alpha = 0.55f) 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( lyricsState.plainText.isNotBlank() -> LazyColumn(
@@ -458,6 +450,53 @@ fun NowPlayingScreen(
// ── QUEUE TAB ────────────────────────────────────────────────── // ── QUEUE TAB ──────────────────────────────────────────────────
if (tab == NpTab.QUEUE) { 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()) { if (state.queue.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp, Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
@@ -595,3 +634,40 @@ private fun formatMs(ms: Long): String {
val s = (ms / 1000).coerceAtLeast(0) val s = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(s / 60, s % 60) 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.ItunesItem
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.NotificationStore
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +30,9 @@ data class ArtistUiState(
val iAlbums: List<ItunesItem> = emptyList(), val iAlbums: List<ItunesItem> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(), val iTracks: List<ItunesItem> = emptyList(),
val itunesLoading: Boolean = false, val itunesLoading: Boolean = false,
// Shuffle all
val isShuffling: Boolean = false,
val pendingShufflePlay: List<Song>? = null,
// Sub-album detail (tapped not-downloaded album) // Sub-album detail (tapped not-downloaded album)
val subAlbumItem: ItunesItem? = null, val subAlbumItem: ItunesItem? = null,
val subAlbumTracks: List<ItunesItem>? = null, val subAlbumTracks: List<ItunesItem>? = null,
@@ -46,6 +50,7 @@ data class ArtistUiState(
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository private val repo = app.app.repository
private val player = app.app.playerController
private val artistId: String? = savedState["artistId"] private val artistId: String? = savedState["artistId"]
private val _state = MutableStateFlow(ArtistUiState()) private val _state = MutableStateFlow(ArtistUiState())
@@ -59,6 +64,11 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
} else { } else {
load() load()
} }
viewModelScope.launch {
player.state.collect { ps ->
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
}
}
} }
fun load() { fun load() {
@@ -98,13 +108,15 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
ItunesClient.search(artistName, entity = "musicArtist", limit = 5) ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
}.getOrNull() }.getOrNull()
val iArtistId = searchResp?.results val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
?.filter { it.wrapperType == "artist" } val iArtistId = (
?.find { artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
it.artistName?.equals(artistName, ignoreCase = true) == true || ?: artists.find {
it.artistName?.contains(artistName, ignoreCase = true) == true val n = it.artistName ?: return@find false
} n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
?.artistId }
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
)?.artistId
if (iArtistId != null) { if (iArtistId != null) {
coroutineScope { coroutineScope {
@@ -173,6 +185,25 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
// ── Download sheet ─────────────────────────────────────────────────────── // ── 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 openEventsSheet() { _state.value = _state.value.copy(showEventsSheet = true) }
fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) } fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) }
@@ -214,7 +245,10 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
} }
val client = ImporterClient(backendUrl) val client = ImporterClient(backendUrl)
runCatching { client.download(request) } 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}") } .onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close() client.close()
} }
@@ -230,6 +264,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
private fun startPreview(url: String, id: Long) { private fun startPreview(url: String, id: Long) {
stopPreview() stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id) _state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -66,7 +66,8 @@ data class ExploreUiState(
class ExploreViewModel(app: Application) : AndroidViewModel(app) { 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)) private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
val state: StateFlow<ExploreUiState> = _state val state: StateFlow<ExploreUiState> = _state
@@ -74,7 +75,14 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private var searchJob: Job? = null private var searchJob: Job? = null
private var mediaPlayer: MediaPlayer? = 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) { fun selectTab(tab: ExploreTab) {
_state.value = _state.value.copy(tab = tab, albumTracks = null) _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) { private fun startPreview(url: String, id: Long) {
stopPreview() stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id) _state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -7,6 +7,8 @@ import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadRequest import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -120,6 +122,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
} }
private suspend fun pollJob(client: ImporterClient, jobId: String) { private suspend fun pollJob(client: ImporterClient, jobId: String) {
val s = _state.value
val title = s.resolved?.name ?: "Import"
val artist = s.resolved?.artist ?: ""
try { try {
repeat(600) { // up to ~20 min at 2s interval repeat(600) { // up to ~20 min at 2s interval
delay(2000) delay(2000)
@@ -132,6 +137,13 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
) )
if (st.status == "done" || st.status == "error") { if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false) _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 return
} }
} }
@@ -3,11 +3,10 @@
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.LrcLibClient
import com.novasonic.app.api.LrcLine import com.novasonic.app.api.LrcLine
import com.novasonic.app.api.parseLrc
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.MusicRepository
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
@@ -22,6 +21,7 @@ data class LyricsUiState(
class LyricsViewModel(app: Application) : AndroidViewModel(app) { class LyricsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app.applicationContext)
private val _state = MutableStateFlow(LyricsUiState()) private val _state = MutableStateFlow(LyricsUiState())
val state: StateFlow<LyricsUiState> = _state 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 if (_state.value.songId == song.id && !_state.value.isLoading) return
_state.value = LyricsUiState(isLoading = true, songId = song.id) _state.value = LyricsUiState(isLoading = true, songId = song.id)
viewModelScope.launch { viewModelScope.launch {
val artist = song.artist ?: "" // Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
val title = song.title val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull()
val album = song.album val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() }
val durSecs = song.duration val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() }
val result = LrcLibClient.getLyrics(artist, title, album, durSecs)
when { when {
result == null -> _state.value = LyricsUiState( synced != null -> {
notFound = true, songId = song.id) val lines = synced.line
result.instrumental -> _state.value = LyricsUiState( .mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } }
plainText = "🎵 Instrumentalstück", songId = song.id) .filter { it.text.isNotBlank() }
!result.syncedLyrics.isNullOrBlank() -> _state.value = LyricsUiState( .sortedBy { it.timeMs }
lines = parseLrc(result.syncedLyrics), songId = song.id) _state.value = if (lines.isNotEmpty())
!result.plainLyrics.isNullOrBlank() -> _state.value = LyricsUiState( LyricsUiState(lines = lines, songId = song.id)
plainText = result.plainLyrics, songId = song.id) else
else -> _state.value = LyricsUiState( LyricsUiState(notFound = true, songId = song.id)
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>'; 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 token = ++_lyricsLoadToken;
const s = S.queue[S.qIdx];
const { getLyricsBySongId } = await import('../api.js'); const { getLyricsBySongId } = await import('../api.js');
if(token !== _lyricsLoadToken) return; 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); const lyrics = await getLyricsBySongId(songId);
if(token !== _lyricsLoadToken) return; 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 = ''; content.innerHTML = '';
if(lrcText) { const synced = lyrics.find(t => t.synced && t.line?.length);
const parsed = _parseLrc(lrcText); const plain = lyrics.find(t => t.line?.length);
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
}
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) { if(plain) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'lyrics-plain'; el.className = 'lyrics-plain';
el.textContent = plain; el.textContent = plain.line.map(l => l.value).join('\n');
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';
content.appendChild(el); 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) { export function updateLyricsHighlight(currentMs) {