diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt index 025e31e..45351ee 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt @@ -54,9 +54,10 @@ object ItunesClient { /** Worldwide top tracks for an iTunes artist */ suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List { 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) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt deleted file mode 100644 index 33aac32..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt +++ /dev/null @@ -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 { - 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() - }.getOrNull() -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt index 7fa8eca..3dd8179 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt @@ -133,6 +133,9 @@ class SubsonicClient( suspend fun getRandomSongs(size: Int = 50): List = get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList() + suspend fun getSimilarSongs(songId: String, count: Int = 20): List = + 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)) diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt index c193690..ff16ac5 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt @@ -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, diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index b0f631c..9fb54db 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/NotificationStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/NotificationStore.kt new file mode 100644 index 0000000..2f094e0 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/data/NotificationStore.kt @@ -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>(emptyList()) + val items: StateFlow> = _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 } } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt index 03329a1..cc11cda 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt @@ -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> { - 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>> { val future = scope.async { buildChildren(parentId) } - val executor = MoreExecutors.directExecutor() val settable = SettableFuture.create>>() 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 { + val songs: List = 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 { 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() +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index 1f7a3fa..fab5adc 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -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() + } } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index cdf66f8..f8e690f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -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(null) } - var playlistPickerSong by remember { mutableStateOf(null) } - var infoSheetSong by remember { mutableStateOf(null) } + var contextMenuSong by remember { mutableStateOf(null) } + var playlistPickerSong by remember { mutableStateOf(null) } + var infoSheetSong by remember { mutableStateOf(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 }) + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/NotificationSheet.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/NotificationSheet.kt new file mode 100644 index 0000000..86ea8e2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/NotificationSheet.kt @@ -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)) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt index 97d3f6e..15d004e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt @@ -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, ) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt index ad5b1f5..7f15eff 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index 489c24c..42ed285 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -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), + ) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt index e1e0edc..70a64b8 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt @@ -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 = emptyList(), val iTracks: List = emptyList(), val itunesLoading: Boolean = false, + // Shuffle all + val isShuffling: Boolean = false, + val pendingShufflePlay: List? = null, // Sub-album detail (tapped not-downloaded album) val subAlbumItem: ItunesItem? = null, val subAlbumTracks: List? = 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 { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt index 5d23b48..e966eb2 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt @@ -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 = _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 { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt index 296de66..61466c2 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt @@ -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 } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt index 6cf63f3..0690f0c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt @@ -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 = _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) } } } diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js index 2fe8bbb..2c4ab20 100644 --- a/src/player/nowplaying.js +++ b/src/player/nowplaying.js @@ -147,61 +147,33 @@ async function _loadNpLyrics(songId) { content.innerHTML = '
Lade Lyrics…
'; 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) {