From 93dfea9a309b46a47d21a9458a196029e5e2f3e1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 13:53:19 +0600 Subject: [PATCH] feat(player): refactor artist display and improve search field labels --- .../library/album/LibraryAlbumsScreen.kt | 13 +-- .../library/artist/LibraryArtistsScreen.kt | 2 +- .../local_tracks/LibraryLocalTracksScreen.kt | 2 +- .../library/playlist/AddToPlaylistPicker.kt | 2 +- .../playlist/LibraryPlaylistsScreen.kt | 2 +- .../playlist/AddTracksToPlaylistDialog.kt | 1 - .../spotube/modules/search/SearchScreen.kt | 2 +- .../modules/shell/AppExpandedPlayer.kt | 9 +- .../spotube/modules/shell/AppLargePlayer.kt | 17 ++-- .../spotube/modules/shell/AppPlayerState.kt | 26 ++++-- .../modules/shell/PlayerArtistsText.kt | 82 +++++++++++++++++++ .../dev/krtirtho/spotube/Platform.jvm.kt | 21 ++++- 12 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index c9d0832c..ed4b7a00 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -93,12 +93,13 @@ fun LibraryAlbumsScreen( } Column(modifier = modifier) { - AnimatedVisibility(searchMode){ + AnimatedVisibility(searchMode) { MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.fillMaxWidth(), - placeholder = "Search saved albums...") + placeholder = "Search saved albums..." + ) } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -119,7 +120,8 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -168,7 +170,8 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index 9056f51d..eadb5bce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -95,7 +95,7 @@ fun LibraryArtistsScreen( AnimatedVisibility(searchMode){ MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.fillMaxWidth(), placeholder = "Search saved artists...") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 288df6e3..31ce23ce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -106,7 +106,7 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean contentDescription = "Search") MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.weight(1f), placeholder = if (state.currentFolderPath == null) { "Search folders or tracks" diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt index 694bb012..d5908f90 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -129,7 +129,7 @@ fun AddToPlaylistPicker( contentDescription = "Search") MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.weight(1f), placeholder = "Search your playlists...") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 779e1df8..45247b53 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -110,7 +110,7 @@ fun LibraryPlaylistsScreen( modifier = Modifier.weight(1f)) { MillenniumTextField( state = searchState, - label = "Search", + modifier = Modifier.fillMaxWidth(), placeholder = "Search saved playlists...") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index 7e59fbc0..ef9d8a74 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -143,7 +143,6 @@ fun AddTracksToPlaylistDialog( verticalAlignment = Alignment.CenterVertically) { MillenniumTextField( state = queryState, - label = "Search", modifier = Modifier.weight(1f), placeholder = "Search tracks...") MillenniumIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index a3900f93..f4709cb0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -478,7 +478,7 @@ private fun SearchBar( .padding(bottom = 8.dp)) { MillenniumAutocomplete( state = searchState, - label = "Search", + suggestions = suggestions, onSuggestionSelected = { entry -> entry.onClick() }, suggestionLabel = { entry -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index df23acf1..228141aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -481,12 +481,13 @@ fun AppExpandedPlayer( maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) - Text( - text = playerUiState.artists, + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + }, style = AppTheme.typography.bodyLarge, color = AppTheme.colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) } MillenniumIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index fd274a6e..e2a87728 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -252,19 +252,24 @@ fun AppLargePlayer( contentScale = ContentScale.Crop ) } - Column(modifier = Modifier.padding(start = 12.dp)) { + Column( + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 12.dp) + ) { Text( text = playerUiState.title, style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = playerUiState.artists, + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + }, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis + color = AppTheme.colors.onSurfaceVariant ) } Spacer(modifier = Modifier.width(10.dp)) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt index c05c604c..06fcf1ef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt @@ -20,21 +20,25 @@ package dev.krtirtho.spotube.modules.shell import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.rememberLogger import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +internal data class PlayerArtist( + val id: String?, + val name: String +) + internal data class PlayerUiState( val queue: List = emptyList(), val currentQueueEntry: QueueEntry? = null, val title: String = "No-op track title", val artists: String = "No-op artists", + val artistList: List = emptyList(), val album: String = "", val coverUrl: String = "", val position: Duration = 0.milliseconds, @@ -43,7 +47,8 @@ internal data class PlayerUiState( val playerState: PlayerState = PlayerState.IDLE, val loopState: LoopState = LoopState.NONE, val isShuffling: Boolean = false, - val volume: Float = 0f) { + val volume: Float = 0f +) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val displayDuration: Duration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration @@ -73,7 +78,8 @@ internal data class PlayerUiState( @Composable internal fun rememberPlayerUiState( audioPlayer: AudioPlayerInterface, - audioPlayerQueue: AudioPlayerQueue): PlayerUiState { + audioPlayerQueue: AudioPlayerQueue +): PlayerUiState { val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null) val playerState by audioPlayer.playerStateFlow.collectAsState() @@ -89,6 +95,7 @@ internal fun rememberPlayerUiState( currentQueueEntry = currentQueueEntry, title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" }, artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" }, + artistList = currentQueueEntry?.displayArtistList().orEmpty(), album = currentQueueEntry?.displayAlbum().orEmpty(), coverUrl = currentQueueEntry?.coverUrl().orEmpty(), position = position, @@ -97,7 +104,8 @@ internal fun rememberPlayerUiState( playerState = playerState, loopState = loopState, isShuffling = isShuffling, - volume = volume) + volume = volume + ) // val logger = rememberLogger() // logger.d { state.toDebugString() } @@ -118,6 +126,14 @@ private fun QueueEntry.displayArtists(): String { } } +private fun QueueEntry.displayArtistList(): List { + return when (this) { + is QueueEntry.StreamingTrack -> track.artists.map { PlayerArtist(it.id, it.name) } + // Local tracks have no artist ids, so they are shown but not navigable. + is QueueEntry.LocalTrack -> artists.map { PlayerArtist(id = null, name = it) } + } +} + private fun QueueEntry.displayAlbum(): String { return when (this) { is QueueEntry.StreamingTrack -> track.album?.title ?: "Unknown Album" diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt new file mode 100644 index 00000000..588c00fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerArtistsText.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.shell + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +/** + * Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id] + * are tappable links that open their artist page; local tracks (no ids) render as plain text. + */ +@Composable +internal fun PlayerArtistsText( + artists: List, + onArtistClick: (String) -> Unit, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Unspecified) { + if (artists.isEmpty()) return + val hoverColor = AppTheme.colors.primary + val annotatedArtists = buildAnnotatedString { + artists.forEachIndexed { index, artist -> + if (index > 0) append(", ") + val artistId = artist.id + if (artistId == null) { + append(artist.name) + } else { + withLink( + LinkAnnotation.Clickable( + tag = artistId, + styles = TextLinkStyles( + style = SpanStyle(textDecoration = TextDecoration.Underline), + hoveredStyle = SpanStyle( + color = hoverColor, + textDecoration = TextDecoration.Underline), + ), + linkInteractionListener = { onArtistClick(artistId) }, + ) + ) { + append(artist.name) + } + } + } + } + Text( + text = annotatedArtists, + modifier = modifier, + style = style, + color = color, + textAlign = textAlign, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt index dba09a14..b816c283 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt @@ -39,5 +39,24 @@ class JVMPlatform : Platform { actual fun getPlatform(): Platform = JVMPlatform() actual fun openUrlInBrowser(url: String) { - Desktop.getDesktop().browse(URI(url)) + // The app runs on a native (Tao) window, not an AWT frame, so java.awt.Desktop is not a + // reliable way to hand a URL to the OS. Prefer the platform's own opener process and only + // fall back to AWT when no opener is available on PATH. + if (openWithSystemOpener(url)) return + runCatching { Desktop.getDesktop().browse(URI(url)) } +} + +private fun openWithSystemOpener(url: String): Boolean { + val osName = System.getProperty("os.name").lowercase() + val command = when { + osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", url) + osName.contains("mac") -> listOf("open", url) + osName.contains("nux") || osName.contains("nix") || osName.contains("aix") -> + listOf("xdg-open", url) + else -> return false + } + return runCatching { + ProcessBuilder(command).start() + true + }.getOrDefault(false) } \ No newline at end of file