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