Compare commits

..

No commits in common. "7743d6d4c3ff1cc0835fe6462b492da55538d9f5" and "2bef59ee765d0a810b425e6cdebd0089b694cd96" have entirely different histories.

59 changed files with 1572 additions and 3111 deletions

View File

@ -336,14 +336,6 @@ android {
} }
// ditchoom's socket-quic-quiche ships libquiche_jni.so with 4 KB LOAD segment alignment
// (not 16 KB compatible), even in the latest release. It is only an optional QUIC transport
// pulled in transitively by mqtt-client, so drop it on Android until upstream fixes it.
configurations.matching { it.name.startsWith("debug") || it.name.startsWith("release") }.configureEach {
exclude(group = "com.ditchoom", module = "socket-quic-quiche")
exclude(group = "com.ditchoom", module = "socket-quic-quiche-android")
}
dependencies { dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)

View File

@ -26,19 +26,12 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
/** NewPipe reports durations in seconds (-1 when unknown). */
private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0
actual class NewPipeService { actual class NewPipeService {
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> { actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extractor = YouTube.getSearchExtractor( val extractor = YouTube.getSearchExtractor(
query, query,
mutableListOf( mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS),
when (kind) {
SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS
}),
"" ""
) )
extractor.fetchPage() extractor.fetchPage()
@ -52,7 +45,7 @@ actual class NewPipeService {
title = item.name, title = item.name,
url = item.url, url = item.url,
uploader = item.uploaderName, uploader = item.uploaderName,
durationMs = secondsToMs(item.duration), durationMs = item.duration,
thumbnailUrl = item.thumbnails.first().url) thumbnailUrl = item.thumbnails.first().url)
} else { } else {
null null
@ -108,7 +101,7 @@ actual class NewPipeService {
title = extractor.name, title = extractor.name,
url = extractor.url, url = extractor.url,
uploader = extractor.uploaderName, uploader = extractor.uploaderName,
durationMs = secondsToMs(extractor.length), durationMs = extractor.length,
thumbnailUrl = extractor.thumbnails.first().url, thumbnailUrl = extractor.thumbnails.first().url,
// fallback to video streams if no audio streams are available // fallback to video streams if no audio streams are available
audioStreams = audioStreams.ifEmpty { audioStreams = audioStreams.ifEmpty {

View File

@ -84,8 +84,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private var disposed = false private var disposed = false
private val currentPlaylist = mutableListOf<MediaItem>() private val currentPlaylist = mutableListOf<MediaItem>()
private val originalPlaylist = mutableListOf<MediaItem>()
private var shuffleEnabled = false
private val urlIndexMap = mutableMapOf<String, Int>() private val urlIndexMap = mutableMapOf<String, Int>()
private val _playerState = MutableStateFlow(PlayerState.IDLE) private val _playerState = MutableStateFlow(PlayerState.IDLE)
@ -145,6 +143,10 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
) )
} }
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
_shuffleMode.tryEmit(shuffleModeEnabled)
}
override fun onVolumeChanged(volume: Float) { override fun onVolumeChanged(volume: Float) {
_volume.tryEmit(volume) _volume.tryEmit(volume)
} }
@ -258,37 +260,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override suspend fun shuffle(enabled: Boolean) { actual override suspend fun shuffle(enabled: Boolean) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return@withContext exoPlayer.shuffleModeEnabled = enabled
val currentIndex = exoPlayer.currentMediaItemIndex
val current = currentPlaylist.getOrNull(currentIndex) ?: return@withContext
val rest = if (enabled) {
originalPlaylist.filterNot { it.url == current.url }.shuffled()
} else {
originalPlaylist.filterNot { it.url == current.url }
}
// Disabling restores original order around the current item.
val before = if (enabled) emptyList() else originalPlaylist.takeWhile { it.url != current.url }
val after = if (enabled) rest else originalPlaylist.dropWhile { it.url != current.url }.drop(1)
// Reorder in place around the playing item so playback is not interrupted.
if (currentIndex < exoPlayer.mediaItemCount - 1) {
exoPlayer.removeMediaItems(currentIndex + 1, exoPlayer.mediaItemCount)
}
if (currentIndex > 0) exoPlayer.removeMediaItems(0, currentIndex)
exoPlayer.addMediaItems(0, before.map { it.toExoMediaItem() })
exoPlayer.addMediaItems(after.map { it.toExoMediaItem() })
currentPlaylist.clear()
currentPlaylist.addAll(before + current + after)
urlIndexMap.clear()
currentPlaylist.forEachIndexed { i, item -> urlIndexMap[item.url] = i }
shuffleEnabled = enabled
_shuffleMode.tryEmit(enabled) _shuffleMode.tryEmit(enabled)
_playlist.tryEmit(currentPlaylist.toList())
syncCurrentItem()
} }
} }
@ -300,10 +273,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
currentPlaylist.clear() currentPlaylist.clear()
currentPlaylist.addAll(playlist) currentPlaylist.addAll(playlist)
originalPlaylist.clear()
originalPlaylist.addAll(playlist)
shuffleEnabled = false
_shuffleMode.tryEmit(false)
urlIndexMap.clear() urlIndexMap.clear()
playlist.forEachIndexed { index, item -> playlist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index urlIndexMap[item.url] = index
@ -336,7 +305,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override suspend fun addMediaItem(mediaItem: MediaItem) { actual override suspend fun addMediaItem(mediaItem: MediaItem) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
currentPlaylist.add(mediaItem) currentPlaylist.add(mediaItem)
originalPlaylist.add(mediaItem)
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
exoPlayer.addMediaItem(mediaItem.toExoMediaItem()) exoPlayer.addMediaItem(mediaItem.toExoMediaItem())
@ -349,8 +317,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size) val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size)
currentPlaylist.add(insertIndex, mediaItem) currentPlaylist.add(insertIndex, mediaItem)
val origCurrent = currentPlaylist.getOrNull(currentIndex)?.let { c -> originalPlaylist.indexOfFirst { it.url == c.url } } ?: -1
originalPlaylist.add((origCurrent + 1).coerceIn(0, originalPlaylist.size), mediaItem)
urlIndexMap.clear() urlIndexMap.clear()
currentPlaylist.forEachIndexed { index, item -> currentPlaylist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index urlIndexMap[item.url] = index
@ -365,7 +331,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val index = urlIndexMap[mediaItem.url] ?: return@withContext val index = urlIndexMap[mediaItem.url] ?: return@withContext
currentPlaylist.removeAt(index) currentPlaylist.removeAt(index)
originalPlaylist.removeAll { it.url == mediaItem.url }
urlIndexMap.clear() urlIndexMap.clear()
currentPlaylist.forEachIndexed { i, item -> currentPlaylist.forEachIndexed { i, item ->
urlIndexMap[item.url] = i urlIndexMap[item.url] = i
@ -381,9 +346,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
val item = currentPlaylist.removeAt(fromIndex) val item = currentPlaylist.removeAt(fromIndex)
currentPlaylist.add(toIndex, item) currentPlaylist.add(toIndex, item)
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) {
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex))
}
urlIndexMap.clear() urlIndexMap.clear()
currentPlaylist.forEachIndexed { i, it -> currentPlaylist.forEachIndexed { i, it ->
urlIndexMap[it.url] = i urlIndexMap[it.url] = i
@ -436,7 +398,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
disposed = true disposed = true
exoPlayer.release() exoPlayer.release()
currentPlaylist.clear() currentPlaylist.clear()
originalPlaylist.clear()
urlIndexMap.clear() urlIndexMap.clear()
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)

View File

@ -68,8 +68,6 @@
<string name="library_local_permission_description">Allow audio access to browse music on your device.</string> <string name="library_local_permission_description">Allow audio access to browse music on your device.</string>
<string name="library_local_grant_access">Grant access</string> <string name="library_local_grant_access">Grant access</string>
<string name="library_local_empty_folders">No local folders found</string> <string name="library_local_empty_folders">No local folders found</string>
<string name="library_local_empty_folders_description">Add a folder with music to play it right here. No plugin needed.</string>
<string name="library_local_add_folder">Add folder</string>
<string name="library_local_empty_tracks">No tracks in this folder</string> <string name="library_local_empty_tracks">No tracks in this folder</string>
<string name="library_local_no_results">No matching folders or tracks</string> <string name="library_local_no_results">No matching folders or tracks</string>
<string name="library_local_refresh_failed">Failed to refresh local media</string> <string name="library_local_refresh_failed">Failed to refresh local media</string>
@ -265,24 +263,10 @@
<string name="plugin_error_download_failed">Failed to download plugin</string> <string name="plugin_error_download_failed">Failed to download plugin</string>
<string name="plugin_empty_title">No plugins installed yet</string> <string name="plugin_empty_title">No plugins installed yet</string>
<string name="plugin_empty_subtitle">Paste a download URL or pick a file above.</string> <string name="plugin_empty_subtitle">Paste a download URL or pick a file above.</string>
<string name="no_metadata_plugin_title">No metadata plugin selected</string>
<string name="no_metadata_plugin_description_install">Spotube needs a metadata plugin to browse, search and show your music. Install one to get started.</string>
<string name="no_metadata_plugin_description_select">You already have plugins installed, but none is set as your metadata source. Pick one to start browsing.</string>
<string name="no_metadata_plugin_step_discover">Discover a metadata plugin</string>
<string name="no_metadata_plugin_step_install">Review its permissions and install it</string>
<string name="no_metadata_plugin_step_select">Select it as your metadata source</string>
<string name="no_metadata_plugin_action_install">Install a plugin</string>
<string name="no_metadata_plugin_action_select">Choose a plugin</string>
<string name="no_metadata_plugin_action_local">Play local music</string>
<string name="no_metadata_plugin_local_hint">Your local tracks and downloads work without a plugin.</string>
<plurals name="plugin_installed_plugins_count"> <plurals name="plugin_installed_plugins_count">
<item quantity="one">%1$d plugin installed</item> <item quantity="one">%1$d plugin installed</item>
<item quantity="other">%1$d plugins installed</item> <item quantity="other">%1$d plugins installed</item>
</plurals> </plurals>
<string name="plugin_install_sheet_subtitle">Add a plugin from a link or a file on your device.</string>
<string name="plugin_install_file_hint">Pick a .smplug file from your device</string>
<string name="plugin_install_or">or</string>
<string name="plugin_info_dialog_title">Plugin details</string>
<string name="plugin_install_section_title">Install a Plugin</string> <string name="plugin_install_section_title">Install a Plugin</string>
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string> <string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
<string name="plugin_action_download">Download</string> <string name="plugin_action_download">Download</string>
@ -576,7 +560,6 @@
<string name="search_start_typing">Start typing to search</string> <string name="search_start_typing">Start typing to search</string>
<string name="search_start_typing_tracks">Start typing to search tracks</string> <string name="search_start_typing_tracks">Start typing to search tracks</string>
<string name="search_no_results">No results found</string> <string name="search_no_results">No results found</string>
<string name="common_no_results">No results</string>
<string name="search_see_all">See all</string> <string name="search_see_all">See all</string>
<string name="search_searching">Searching</string> <string name="search_searching">Searching</string>
<string name="search_loading_tracks">Loading tracks...</string> <string name="search_loading_tracks">Loading tracks...</string>
@ -630,12 +613,6 @@
<string name="media_user">User</string> <string name="media_user">User</string>
<string name="media_playlist">Playlist</string> <string name="media_playlist">Playlist</string>
<!-- Login required -->
<string name="login_required_title">Sign in to continue</string>
<string name="login_required_description">This part of Spotube needs you to be signed in with your music provider. Sign in once and everything will show up here.</string>
<string name="login_required_description_plugin">%1$s needs you to sign in before it can show this. Sign in once and everything will show up here.</string>
<string name="login_required_action">Sign in</string>
<!-- Error display --> <!-- Error display -->
<string name="error_title">We\'re Truly Sorry</string> <string name="error_title">We\'re Truly Sorry</string>
<string name="error_description">Something isn\'t working right now. Your experience matters to us, and we\'re doing our best to fix this. Please try again or check the error details below.</string> <string name="error_description">Something isn\'t working right now. Your experience matters to us, and we\'re doing our best to fix this. Please try again or check the error details below.</string>

View File

@ -54,7 +54,6 @@ import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.home.HomeScreenRepository import dev.krtirtho.spotube.modules.home.HomeScreenRepository
import dev.krtirtho.spotube.modules.home.HomeScreenViewModel import dev.krtirtho.spotube.modules.home.HomeScreenViewModel
import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel
@ -71,7 +70,6 @@ import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.plugin.PluginViewModel import dev.krtirtho.spotube.modules.plugin.PluginViewModel
import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuardViewModel
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
@ -117,7 +115,6 @@ val sharedModules = module {
// Home // Home
singleOf(::HomeScreenRepository) singleOf(::HomeScreenRepository)
viewModelOf(::HomeScreenViewModel) viewModelOf(::HomeScreenViewModel)
viewModelOf(::MetadataAuthViewModel)
// Search // Search
singleOf(::SearchRepository) singleOf(::SearchRepository)
@ -141,7 +138,6 @@ val sharedModules = module {
bind<ScrobblePluginSource>() bind<ScrobblePluginSource>()
} }
viewModelOf(::PluginViewModel) viewModelOf(::PluginViewModel)
viewModelOf(::MetadataPluginGuardViewModel)
// Settings // Settings
singleOf(::SettingsRepository) { bind<UserSettingsSource>() } singleOf(::SettingsRepository) { bind<UserSettingsSource>() }

View File

@ -29,7 +29,6 @@ import dev.krtirtho.spotube.modules.library.LibraryScreen
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
import dev.krtirtho.spotube.modules.playlist.PlaylistScreen import dev.krtirtho.spotube.modules.playlist.PlaylistScreen
import dev.krtirtho.spotube.modules.plugin.PluginScreen import dev.krtirtho.spotube.modules.plugin.PluginScreen
import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.search.SearchScreen
@ -102,17 +101,12 @@ sealed interface Routes : NavKey {
val navigationModule = module { val navigationModule = module {
singleOf(::NavigationCommands) singleOf(::NavigationCommands)
navigation<Routes.Home> { navigation<Routes.Home> {
MetadataPluginGuard { HomeScreen(viewModel = koinViewModel())
HomeScreen(viewModel = koinViewModel())
}
} }
navigation<Routes.Search> { navigation<Routes.Search> {
MetadataPluginGuard { SearchScreen()
SearchScreen()
}
} }
navigation<Routes.Library> { navigation<Routes.Library> {
// Not guarded as a whole: Local tracks and Downloads work without a plugin.
LibraryScreen() LibraryScreen()
} }
navigation<Routes.Settings> { navigation<Routes.Settings> {
@ -123,57 +117,49 @@ val navigationModule = module {
} }
navigation<Routes.WebView> {} navigation<Routes.WebView> {}
navigation<Routes.Playlist> { navigation<Routes.Playlist> {
MetadataPluginGuard(backButton = true) { PlaylistScreen(
PlaylistScreen( playlistId = it.playlistId,
playlistId = it.playlistId, viewModel = koinViewModel(
viewModel = koinViewModel( key = it.playlistId,
key = it.playlistId, parameters = { parametersOf(it.playlistId) }
parameters = { parametersOf(it.playlistId) } ),
), audioPlayerQueue = get(),
audioPlayerQueue = get(), audioPlayer = get(),
audioPlayer = get(), navigationCommands = get())
navigationCommands = get())
}
} }
navigation<Routes.Artist> { navigation<Routes.Artist> {
MetadataPluginGuard(backButton = true) { ArtistScreen(
ArtistScreen( viewModel = koinViewModel(
viewModel = koinViewModel( key = it.artistId,
key = it.artistId, parameters = { parametersOf(it.artistId) }
parameters = { parametersOf(it.artistId) } ),
), audioPlayerQueue = get(),
audioPlayerQueue = get(), audioPlayer = get(),
audioPlayer = get(), navigationCommands = get())
navigationCommands = get())
}
} }
navigation<Routes.Album> { navigation<Routes.Album> {
MetadataPluginGuard(backButton = true) { AlbumScreen(
AlbumScreen( albumId = it.albumId,
albumId = it.albumId, viewModel = koinViewModel(
viewModel = koinViewModel( key = it.albumId,
key = it.albumId, parameters = { parametersOf(it.albumId) }
parameters = { parametersOf(it.albumId) } ),
), audioPlayerQueue = get(),
audioPlayerQueue = get(), audioPlayer = get(),
audioPlayer = get(), navigationCommands = get())
navigationCommands = get())
}
} }
navigation<Routes.Lyrics> { navigation<Routes.Lyrics> {
LyricsScreen(viewModel = koinViewModel()) LyricsScreen(viewModel = koinViewModel())
} }
navigation<Routes.SavedTracks> { navigation<Routes.SavedTracks> {
MetadataPluginGuard(backButton = true) { SavedTracksScreen(
SavedTracksScreen( viewModel = koinViewModel(
viewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID,
key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) } ),
), audioPlayerQueue = get(),
audioPlayerQueue = get(), audioPlayer = get(),
audioPlayer = get(), navigationCommands = get())
navigationCommands = get())
}
} }
navigation<Routes.Blacklist> { navigation<Routes.Blacklist> {
BlacklistScreen() BlacklistScreen()

View File

@ -17,15 +17,7 @@
package dev.krtirtho.spotube.core.newpipe package dev.krtirtho.spotube.core.newpipe
enum class SearchKind {
/** YouTube Music "Songs" catalogue. */
Songs,
/** Regular YouTube videos; covers tracks that are not in the Music songs catalogue. */
Videos
}
expect class NewPipeService() { expect class NewPipeService() {
suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult> suspend fun searchVideos(query: String): List<VideoSearchResult>
suspend fun getVideoInfo(id: String): VideoInfo suspend fun getVideoInfo(id: String): VideoInfo
} }

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.core.remote package dev.krtirtho.spotube.core.remote
import org.spotube.millennium.composables.MillenniumAlertDialog
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -35,6 +34,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -56,25 +56,21 @@ fun ConnectionRequestDialogHost() {
} }
pendingRequest?.let { request -> pendingRequest?.let { request ->
MillenniumAlertDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = { onDismissRequest = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
pendingRequest = null pendingRequest = null
}, },
title = { Text(stringResource(Res.string.remote_request_title)) }, title = stringResource(Res.string.remote_request_title)) {
description = { Text(
Text(stringResource(Res.string.remote_request_message, request.deviceName)) text = stringResource(Res.string.remote_request_message, request.deviceName),
}, style = AppTheme.typography.bodyMedium,
) { textAlign = TextAlign.Start)
MillenniumButton( Row(
onClick = { modifier = Modifier.fillMaxWidth(),
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
pendingRequest = null verticalAlignment = Alignment.CenterVertically) {
},
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.remote_request_allow_always))
}
MillenniumButton( MillenniumButton(
onClick = { onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
@ -90,6 +86,15 @@ fun ConnectionRequestDialogHost() {
}) { }) {
Text(stringResource(Res.string.remote_request_allow)) Text(stringResource(Res.string.remote_request_allow))
} }
MillenniumButton(
onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
pendingRequest = null
},
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.remote_request_allow_always))
}
} }
}
} }
} }

View File

@ -157,12 +157,12 @@ class CacheManager(
suspend fun commitCacheEntry( suspend fun commitCacheEntry(
trackId: String, trackId: String,
filename: String, filename: String,
sizeBytes: Long, allBytes: ByteArray,
contentType: String) { contentType: String) {
val entry = CacheEntry( val entry = CacheEntry(
trackId = trackId, trackId = trackId,
filename = filename, filename = filename,
sizeBytes = sizeBytes, sizeBytes = allBytes.size.toLong(),
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
contentType = contentType) contentType = contentType)
cacheMutex.withLock { cacheMutex.withLock {

View File

@ -23,7 +23,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsChannel
@ -39,19 +38,16 @@ import io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLParameter import io.ktor.http.encodeURLParameter
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.util.cio.ChannelWriteException
import io.ktor.utils.io.ClosedWriteChannelException
import io.ktor.utils.io.copyTo import io.ktor.utils.io.copyTo
import io.ktor.utils.io.readAvailable import io.ktor.utils.io.readRemaining
import io.ktor.utils.io.writeFully import io.ktor.utils.io.writeFully
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.io.readByteArray
import okio.FileSystem import okio.FileSystem
import okio.IOException
import okio.Path.Companion.toPath import okio.Path.Companion.toPath
import okio.SYSTEM import okio.SYSTEM
import okio.buffer import okio.buffer
@ -131,11 +127,8 @@ internal class StreamProxy(
} }
return return
} }
// The caching request ended without committing (player disconnected or the
// source was switched); nothing left to wait for.
if (streamingMutex.withLock { trackId !in streamingInProgress }) break
} }
logger.d { "No cache for track $trackId after waiting, falling through to upstream" } logger.w { "Timeout waiting for cache of track $trackId, falling through to upstream" }
} }
try { try {
@ -267,25 +260,18 @@ internal class StreamProxy(
) { ) {
logger.d { "Proxying segment for track $trackId" } logger.d { "Proxying segment for track $trackId" }
runCatching { val response = runCatching {
httpClient.prepareRequest(segmentUrl) { httpClient.request(segmentUrl) {
method = HttpMethod.Get method = HttpMethod.Get
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
}.execute { response -> respondWithSegment(call, response, trackId) }
}.onFailure { throwable ->
if (throwable is CancellationException) throw throwable
if (throwable.isClientDisconnect()) {
logger.d { "Player closed the connection while streaming segment of track $trackId" }
return
} }
}.getOrElse { throwable ->
logger.w(throwable) { "Failed to fetch segment for track $trackId" } logger.w(throwable) { "Failed to fetch segment for track $trackId" }
runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") } call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment")
return
} }
}
private suspend fun respondWithSegment(call: ApplicationCall, response: HttpResponse, trackId: String) {
if (response.status.value >= 400) { if (response.status.value >= 400) {
logger.w { "Segment request failed for track $trackId with status ${response.status.value}" } logger.w { "Segment request failed for track $trackId with status ${response.status.value}" }
@ -319,65 +305,48 @@ internal class StreamProxy(
var currentUrl = streamUrl var currentUrl = streamUrl
var attemptedRefresh = false var attemptedRefresh = false
var upstream: HttpResponse?
// `request()` buffers the whole body before returning, which stalls the player until
// the full track is downloaded. `execute {}` hands over the response as soon as headers
// arrive so the body can be streamed.
while (true) { while (true) {
var retryUrl: String? = null val response = runCatching {
val failure = runCatching { httpClient.request(currentUrl) {
httpClient.prepareRequest(currentUrl) {
method = requestMethod method = requestMethod
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
}.execute { response ->
if (response.status.value < 400) {
respondWithUpstream(call, response, trackId, requestMethod)
return@execute
}
logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" }
streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl)
if (!attemptedRefresh) {
val refreshedUrl =
streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url
if (!refreshedUrl.isNullOrBlank() && refreshedUrl != currentUrl) {
retryUrl = refreshedUrl
return@execute
}
}
respondWithUpstream(call, response, trackId, requestMethod)
} }
}.exceptionOrNull() }.getOrElse { throwable ->
logger.w(throwable) { "Failed to fetch upstream stream for track $trackId" }
if (failure != null) { call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream")
if (failure is CancellationException) throw failure
if (failure.isClientDisconnect()) {
logger.d { "Player closed the connection while streaming track $trackId" }
return
}
logger.w(failure) { "Failed to fetch upstream stream for track $trackId" }
// Throws if the response already started streaming; nothing more to send then
runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream") }
return return
} }
val next = retryUrl ?: return if (response.status.value < 400) {
upstream = response
break
}
logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" }
streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl)
if (attemptedRefresh) {
upstream = response
break
}
val refreshedUrl =
streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url
if (refreshedUrl.isNullOrBlank() || refreshedUrl == currentUrl) {
upstream = response
break
}
logger.d { "Retrying stream for track $trackId using refreshed URL" } logger.d { "Retrying stream for track $trackId using refreshed URL" }
currentUrl = next currentUrl = refreshedUrl
attemptedRefresh = true attemptedRefresh = true
} }
}
private suspend fun respondWithUpstream(
call: ApplicationCall,
upstream: HttpResponse,
trackId: String,
requestMethod: HttpMethod
) {
if (upstream.status.value >= 400 && isCachingEnabled()) { if (upstream.status.value >= 400 && isCachingEnabled()) {
val cachedEntry = cacheManager.findCachedEntry(trackId) val cachedEntry = cacheManager.findCachedEntry(trackId)
if (cachedEntry != null) { if (cachedEntry != null) {
@ -411,9 +380,7 @@ internal class StreamProxy(
return return
} }
// Only a full 200 response can be cached; 206 partial responses must not be stored as the whole track val doCache = requestMethod == HttpMethod.Get && isCachingEnabled()
val doCache = requestMethod == HttpMethod.Get && isCachingEnabled() &&
upstream.status == HttpStatusCode.OK
val cacheTrack = if (doCache) getTrackMetadata(trackId) else null val cacheTrack = if (doCache) getTrackMetadata(trackId) else null
val cacheFilename = if (cacheTrack != null) { val cacheFilename = if (cacheTrack != null) {
cacheManager.resolveCacheFilename(cacheTrack, downstreamHeaders[HttpHeaders.ContentType]) cacheManager.resolveCacheFilename(cacheTrack, downstreamHeaders[HttpHeaders.ContentType])
@ -421,7 +388,6 @@ internal class StreamProxy(
null null
} }
val trackIdForCache = trackId val trackIdForCache = trackId
val expectedLength = downstreamContentLength
val cacheContentType = downstreamHeaders[HttpHeaders.ContentType] ?: "application/octet-stream" val cacheContentType = downstreamHeaders[HttpHeaders.ContentType] ?: "application/octet-stream"
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
@ -436,38 +402,26 @@ internal class StreamProxy(
val cacheDir = cacheManager.resolveCacheDir() val cacheDir = cacheManager.resolveCacheDir()
val cacheFile = cacheDir / cacheFilename.toPath() val cacheFile = cacheDir / cacheFilename.toPath()
fileSystem.createDirectories(cacheDir) fileSystem.createDirectories(cacheDir)
var completed = false
var totalBytes = 0L
try { try {
val source = upstream.bodyAsChannel() val packet = upstream.bodyAsChannel().readRemaining()
val buffer = ByteArray(64 * 1024) val allBytes = packet.readByteArray()
channel.writeFully(allBytes)
fileSystem.sink(cacheFile).buffer().use { sink -> fileSystem.sink(cacheFile).buffer().use { sink ->
while (true) { sink.write(allBytes)
val read = source.readAvailable(buffer, 0, buffer.size)
if (read < 0) break
if (read == 0) continue
channel.writeFully(buffer, 0, read)
channel.flush()
sink.write(buffer, 0, read)
totalBytes += read
}
} }
completed = expectedLength == null || expectedLength == totalBytes cacheManager.commitCacheEntry(
if (completed) { trackId = trackIdForCache,
cacheManager.commitCacheEntry( filename = cacheFilename,
trackId = trackIdForCache, allBytes = allBytes,
filename = cacheFilename, contentType = cacheContentType)
sizeBytes = totalBytes, scope.launch {
contentType = cacheContentType) cacheManager.evictIfNeeded()
scope.launch {
cacheManager.evictIfNeeded()
}
} }
} finally { } catch (e: Exception) {
// Player disconnected or stream was incomplete: drop the partial file if (fileSystem.exists(cacheFile)) {
if (!completed && fileSystem.exists(cacheFile)) {
fileSystem.delete(cacheFile) fileSystem.delete(cacheFile)
} }
throw e
} }
} else { } else {
upstream.bodyAsChannel().copyTo(channel) upstream.bodyAsChannel().copyTo(channel)
@ -533,16 +487,6 @@ internal class StreamProxy(
return rewritten return rewritten
} }
/**
* True when the failure came from writing to the player's socket (seek, skip or stop closes
* it mid-stream), as opposed to a failure fetching from upstream.
*/
private fun Throwable.isClientDisconnect(): Boolean =
generateSequence(this) { it.cause }.take(10).any {
it is ChannelWriteException || it is ClosedWriteChannelException ||
(it is IOException && it.message?.contains("Broken pipe", ignoreCase = true) == true)
}
private fun encodeUrl(url: String): String = url.encodeURLParameter() private fun encodeUrl(url: String): String = url.encodeURLParameter()
private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent() private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent()

View File

@ -17,185 +17,18 @@
package dev.krtirtho.spotube.core.tools.user_agents package dev.krtirtho.spotube.core.tools.user_agents
import co.touchlab.kermit.Logger
import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.getPlatform
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import okio.FileSystem
import okio.Path.Companion.toPath
import okio.SYSTEM
import kotlin.time.Clock
/**
* Provides browser User-Agent strings that match the platform the app is running on
* (Android UAs only on Android, iOS only on iOS, and desktop UAs only on the matching
* Windows / macOS / Linux host).
*
* The primary source is the `top-user-agents` list maintained by Microlink (the 100 most
* used UAs, kept current), cached on disk for [CACHE_TTL_MS]. The hardcoded [FALLBACKS]
* are used whenever nothing has been fetched yet or the fetch fails.
*
* [random] is intentionally synchronous and never blocks on the network: it serves
* whatever is cached (even stale) and refreshes in the background when needed.
*/
object UserAgents { object UserAgents {
private const val SOURCE_URL = private val agents = listOf(
"https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
private const val CACHE_FILE_NAME = "user_agents_cache.json" "Mozilla/5.0 (Linux; Android 15; ALI-NX1 Build/HONORALI-N21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.92 Mobile Safari/537.36",
private const val CACHE_TTL_MS = 24L * 60 * 60 * 1000 // 1 day "Mozilla/5.0 (Linux; Android 14; SM-A528B Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.108 Mobile Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
private val logger = Logger.withTag("UserAgents") "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.7; rv:68.0) Gecko/20100101 Firefox/68.0 Waterfox/56.6.2021.2816",
private val json = Json { ignoreUnknownKeys = true } "Mozilla/5.0 (Macintosh; Intel Mac OS X10.14; rv:65.0) Gecko/20100101 Firefox/65.0 Waterfox/56.2.7",
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Vivaldi/7.6.3797.63",
private val refreshMutex = Mutex() "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.91 Safari/537.36 Vivaldi/1.92.917.39",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.5.3735.47"
private val platformType: PlatformType = getPlatform().type
@Serializable
private data class CacheEntry(val fetchedAtMs: Long, val agents: List<String>)
private var remoteAgents: List<String> = emptyList()
private var fetchedAtMs: Long = 0
// Loads the disk cache once and kicks off a background refresh if it is missing/stale.
private val bootstrap by lazy {
loadFromDisk()
if (isStale()) scope.launch { refresh() }
}
fun random(): String {
bootstrap
return pool().random()
}
/**
* Fetches the latest list from the network and caches it. No-op while the cache is
* still fresh unless [force] is set. Failures keep whatever data is already cached.
*/
suspend fun refresh(force: Boolean = false) = refreshMutex.withLock {
if (!force && !isStale()) return@withLock
try {
val agents = fetchRemote().filter(::isValidAgent)
if (agents.isEmpty()) return@withLock
remoteAgents = agents
fetchedAtMs = now()
saveToDisk(CacheEntry(fetchedAtMs, agents))
} catch (e: Exception) {
logger.w(e) { "Failed to refresh user agents, using cached/fallback ones" }
}
}
private fun pool(): List<String> {
val matching = remoteAgents.filter { matchesPlatform(it, platformType) }
return matching.ifEmpty { FALLBACKS.getValue(platformType) }
}
private fun isStale() = now() - fetchedAtMs >= CACHE_TTL_MS
private fun now() = Clock.System.now().toEpochMilliseconds()
private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/")
private suspend fun fetchRemote(): List<String> {
val client = HttpClient(CIO)
try {
val res = client.get(SOURCE_URL)
if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
} finally {
client.close()
}
}
private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath()
private fun loadFromDisk() {
try {
val path = cachePath()
val fs = FileSystem.SYSTEM
if (!fs.exists(path)) return
val entry = json.decodeFromString<CacheEntry>(fs.read(path) { readUtf8() })
val agents = entry.agents.filter(::isValidAgent)
if (agents.isEmpty()) return
remoteAgents = agents
fetchedAtMs = entry.fetchedAtMs
} catch (e: Exception) {
logger.w(e) { "Failed to read user agents cache" }
}
}
private fun saveToDisk(entry: CacheEntry) {
try {
val path = cachePath()
val tmp = "$path.tmp".toPath()
val fs = FileSystem.SYSTEM
path.parent?.let { fs.createDirectories(it) }
fs.write(tmp) { writeUtf8(json.encodeToString(CacheEntry.serializer(), entry)) }
fs.atomicMove(tmp, path)
} catch (e: Exception) {
logger.w(e) { "Failed to write user agents cache" }
}
}
internal fun matchesPlatform(ua: String, type: PlatformType): Boolean {
val isIos = "iPhone" in ua || "iPad" in ua || "iPod" in ua
val isAndroid = "Android" in ua
return when (type) {
PlatformType.IOS -> isIos
PlatformType.Android -> isAndroid
PlatformType.Windows -> "Windows NT" in ua && !isAndroid
PlatformType.MacOS -> "Macintosh" in ua && !isIos
PlatformType.Linux -> ("X11" in ua || "Linux" in ua) && !isAndroid && "CrOS" !in ua
// Unknown host: any desktop UA is a safe choice.
PlatformType.Unknown -> !isIos && !isAndroid
}
}
private val FALLBACKS: Map<PlatformType, List<String>> = mapOf(
PlatformType.Windows to listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Vivaldi/7.6.3797.63",
),
PlatformType.MacOS to listOf(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.5.3735.47",
),
PlatformType.Linux to listOf(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
),
PlatformType.Android to listOf(
"Mozilla/5.0 (Linux; Android 15; ALI-NX1 Build/HONORALI-N21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.92 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 14; SM-A528B Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.108 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36",
),
PlatformType.IOS to listOf(
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/146.0.0.0 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1",
),
PlatformType.Unknown to listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
),
) )
fun random(): String = agents.random()
} }

View File

@ -19,15 +19,6 @@ package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
@ -40,6 +31,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent
import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -58,9 +50,6 @@ import spotube.composeapp.generated.resources.*
fun AdaptiveDialogBottomSheet( fun AdaptiveDialogBottomSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
title: @Composable (() -> Unit)? = null, title: @Composable (() -> Unit)? = null,
// Plain-text title for the large-screen dialog's own titlebar; when set it replaces [title]
// there so the titlebar isn't left empty above a duplicated in-body title.
dialogTitle: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
actions: @Composable (() -> Unit)? = null, actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit) { content: @Composable () -> Unit) {
@ -70,19 +59,14 @@ fun AdaptiveDialogBottomSheet(
MillenniumDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = modifier, title = "",
paneTitle = dialogTitle ?: "") { modifier = modifier) {
MillenniumDialogHeader { title?.let { it() }
if (dialogTitle != null) { content()
MillenniumDialogTitle(text = dialogTitle, textStyle = AppTheme.typography.titleMedium) actions?.let {
} else { MillenniumSeparator()
MillenniumDialogTitle { title?.invoke() } it()
}
MillenniumDialogClose(
contentDescription = stringResource(Res.string.common_close_dialog))
} }
MillenniumDialogBody { content() }
actions?.let { MillenniumDialogFooter(content = { it() }) }
} }
} else { } else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
@ -93,7 +77,10 @@ fun AdaptiveDialogBottomSheet(
} }
MillenniumModalBottomSheet(state = sheetState) { MillenniumModalBottomSheet(state = sheetState) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp),
horizontalAlignment = Alignment.Start) { horizontalAlignment = Alignment.Start) {
title?.let { title?.let {
Row( Row(
@ -112,13 +99,7 @@ fun AdaptiveDialogBottomSheet(
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
Column( content()
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 560.dp)
.verticalScroll(rememberScrollState())) {
content()
}
actions?.let { actions?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
MillenniumSeparator() MillenniumSeparator()
@ -126,6 +107,7 @@ fun AdaptiveDialogBottomSheet(
it() it()
} }
} }
Spacer(Modifier.height(24.dp))
} }
} }
} }

View File

@ -22,18 +22,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -41,7 +30,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -53,7 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -70,14 +57,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumDropdownMenu
import org.spotube.millennium.composables.MillenniumDropdownMenuContent
import org.spotube.millennium.composables.MillenniumDropdownMenuEmpty
import org.spotube.millennium.composables.MillenniumDropdownMenuFilter
import org.spotube.millennium.composables.MillenniumDropdownMenuHeader
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import org.spotube.millennium.composables.MillenniumDropdownMenuItemIcon
import org.spotube.millennium.composables.MillenniumDropdownMenuSeparator
import com.composeunstyled.Text
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
@ -88,7 +67,6 @@ import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTextField
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.accessibility_selected import spotube.composeapp.generated.resources.accessibility_selected
import spotube.composeapp.generated.resources.common_no_results
import spotube.composeapp.generated.resources.search_filter_placeholder import spotube.composeapp.generated.resources.search_filter_placeholder
data class AdaptiveMenuItem( data class AdaptiveMenuItem(
@ -121,13 +99,17 @@ fun AdaptiveDropdownBottomSheet(
Box(modifier = modifier) { Box(modifier = modifier) {
if (isLargeScreen) { if (isLargeScreen) {
DesktopDropdownMenu( MillenniumDropdownMenu(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = it }, onExpandedChange = { expanded = it },
items = items, items = items.map { it.label },
menuMinWidth = menuMinWidth, onItemClick = { label ->
filter = filter, items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
trigger = { trigger { expanded = true } }) expanded = false
},
minimumWidth = menuMinWidth,
anchor = { trigger { expanded = true } }
)
} else { } else {
trigger { expanded = true } trigger { expanded = true }
if (expanded) { if (expanded) {
@ -152,7 +134,6 @@ private fun AdaptiveBottomSheetContent(
header: @Composable (() -> Unit)?, header: @Composable (() -> Unit)?,
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
val hasSelection = items.any { it.selected } val hasSelection = items.any { it.selected }
val hasLeading = items.any { it.icon != null || it.leadingContent != null }
val queryState = rememberTextFieldState() val queryState = rememberTextFieldState()
val query = queryState.text.toString() val query = queryState.text.toString()
val filteredItems = if (filter != null && query.isNotBlank()) { val filteredItems = if (filter != null && query.isNotBlank()) {
@ -166,10 +147,7 @@ private fun AdaptiveBottomSheetContent(
onDismiss() onDismiss()
} }
} }
MillenniumModalBottomSheet( MillenniumModalBottomSheet(state = sheetState) {
state = sheetState,
contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp),
contentSpacing = 4.dp) {
Column( Column(
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth()) {
header?.invoke() header?.invoke()
@ -197,134 +175,69 @@ private fun AdaptiveBottomSheetContent(
if (item.dividerBefore) { if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
} }
AdaptiveMenuTile( MillenniumListTile(
item = item, title = {
hasSelection = hasSelection, MillenniumText(
hasLeading = hasLeading, item.label,
textStyle = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
minimumHeight = 56.dp, color = if (item.enabled) {
AppTheme.colors.onSurface
} else {
AppTheme.colors.onSurface.copy(alpha = 0.38f)
}
)
},
subtitle = item.subtitle?.let { subtitle ->
{
MillenniumText(
subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant
)
}
},
leading = when {
item.leadingContent != null -> item.leadingContent
item.icon != null -> {
{
MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
}
item.selected -> {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
}
hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }}
else -> null
},
trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
} else {
null
},
selected = item.selected,
enabled = item.enabled,
onClick = { onClick = {
item.onClick() item.onClick()
onDismiss() onDismiss()
}) },
} minimumHeight = 56.dp
} )
}
}
}
/** One menu row. The leading slot is always reserved when any sibling has an icon/leading content
* (or a selection), so labels and icons stay aligned in a column. */
@Composable
private fun AdaptiveMenuTile(
item: AdaptiveMenuItem,
hasSelection: Boolean,
hasLeading: Boolean,
textStyle: androidx.compose.ui.text.TextStyle,
minimumHeight: Dp,
onClick: () -> Unit) {
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
val contentColor = if (item.enabled) AppTheme.colors.onSurface
else AppTheme.colors.onSurface.copy(alpha = 0.38f)
MillenniumListTile(
title = { MillenniumText(item.label, style = textStyle, color = contentColor) },
subtitle = item.subtitle?.let { subtitle ->
{
MillenniumText(
subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
},
leading = when {
item.leadingContent != null -> item.leadingContent
item.icon != null -> {
{
MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = contentColor)
}
}
item.selected -> {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp))
}
}
hasSelection || hasLeading -> {{ Spacer(modifier = Modifier.size(22.dp)) }}
else -> null
},
trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp))
}
} else {
null
},
selected = item.selected,
enabled = item.enabled,
onClick = onClick,
minimumHeight = minimumHeight)
}
/** Desktop popup built from Millennium's dropdown bricks: native icons, descriptions,
* selection indicator, separators and an optional search header. */
@Composable
private fun DesktopDropdownMenu(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
items: List<AdaptiveMenuItem>,
menuMinWidth: Dp,
filter: ((AdaptiveMenuItem, String) -> Boolean)?,
trigger: @Composable () -> Unit) {
val queryState = rememberTextFieldState()
LaunchedEffect(expanded) { if (!expanded) queryState.clearText() }
val query = queryState.text.toString()
val visibleItems = if (filter != null && query.isNotBlank()) {
items.filter { filter(it, query) }
} else {
items
}
val hasLeading = items.any { it.icon != null || it.leadingContent != null }
val iconSize = MillenniumTheme.dropdownMenuTheme.states.default.iconSize
MillenniumDropdownMenu(
expanded = expanded,
onExpandedChange = onExpandedChange,
minimumWidth = menuMinWidth,
width = menuMinWidth,
anchor = trigger) {
if (filter != null) {
MillenniumDropdownMenuHeader(showDivider = false) {
MillenniumDropdownMenuFilter(
state = queryState,
placeholder = stringResource(Res.string.search_filter_placeholder))
}
}
MillenniumDropdownMenuContent {
if (visibleItems.isEmpty()) {
MillenniumDropdownMenuEmpty(stringResource(Res.string.common_no_results))
}
visibleItems.forEach { item ->
if (item.dividerBefore) MillenniumDropdownMenuSeparator()
MillenniumDropdownMenuItem(
onClick = item.onClick,
enabled = item.enabled,
selected = item.selected,
leading = item.leadingContent
?: item.icon?.let { icon -> { MillenniumDropdownMenuItemIcon(icon) } }
?: if (hasLeading) ({ Spacer(Modifier.size(iconSize)) }) else null,
description = item.subtitle?.let { subtitle -> { Text(subtitle) } }) {
Text(item.label)
} }
} }
} }

View File

@ -18,11 +18,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -50,9 +45,6 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -80,16 +72,7 @@ import spotube.composeapp.generated.resources.*
fun ErrorDisplay( fun ErrorDisplay(
errorMessage: String, errorMessage: String,
onRetry: () -> Unit, onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
authViewModel: MetadataAuthViewModel = koinViewModel()) {
val authState by authViewModel.uiState.collectAsStateWithLifecycle()
if (authState.loginRequired) {
LoginRequiredDisplay(
pluginName = authState.pluginName,
onLogin = authViewModel::login,
modifier = modifier)
return
}
var showFullLog by remember { mutableStateOf(false) } var showFullLog by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
@ -208,11 +191,19 @@ private fun ErrorLogDialog(
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
MillenniumDialog(visible = true, onDismissRequest = onDismiss) { MillenniumDialog(
MillenniumDialogHeader { visible = true,
MillenniumDialogTitle( onDismissRequest = onDismiss,
title = "") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.error_full_log_title), text = stringResource(Res.string.error_full_log_title),
textStyle = AppTheme.typography.titleMedium) style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.Bold)
MillenniumIconButton( MillenniumIconButton(
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(errorMessage)) clipboardManager.setText(AnnotatedString(errorMessage))
@ -222,38 +213,47 @@ private fun ErrorLogDialog(
imageVector = FeatherIcons.Copy, imageVector = FeatherIcons.Copy,
contentDescription = stringResource(Res.string.error_copy_to_clipboard)) contentDescription = stringResource(Res.string.error_copy_to_clipboard))
} }
MillenniumDialogClose()
} }
MillenniumDialogBody(scrollable = false) { Box(
Box( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .height(400.dp)
.height(400.dp) .clip(AppTheme.shapes.small)
.clip(AppTheme.shapes.small) .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) { SelectionContainer {
SelectionContainer { Text(
Text( text = errorMessage,
text = errorMessage, style = AppTheme.typography.bodySmall.copy(
style = AppTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace),
fontFamily = FontFamily.Monospace), color = AppTheme.colors.onSurfaceVariant,
color = AppTheme.colors.onSurfaceVariant, modifier = Modifier
modifier = Modifier .fillMaxSize()
.fillMaxSize() .verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()) .padding(12.dp))
.padding(12.dp))
}
} }
} }
MillenniumDialogFooter { Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End) {
MillenniumButton( MillenniumButton(
onClick = onDismiss, onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.common_close)) Text(stringResource(Res.string.common_close))
} }
} }
} }
} }
@Preview
@Composable
private fun ErrorDisplayPreview() {
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
ErrorDisplay(
errorMessage = "This is an error message",
onRetry = {})
}
}
@Preview @Preview
@Composable @Composable
private fun ErrorLogDialogPreview() { private fun ErrorLogDialogPreview() {

View File

@ -1,101 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import compose.icons.FeatherIcons
import compose.icons.feathericons.Lock
import compose.icons.feathericons.LogIn
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumIcon
import spotube.composeapp.generated.resources.*
/** Friendly "sign in first" state for screens whose metadata plugin needs an account. */
@Composable
fun LoginRequiredDisplay(
pluginName: String?,
onLogin: () -> Unit,
modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)) {
Box(
modifier = Modifier
.size(88.dp)
.clip(CircleShape)
.background(AppTheme.colors.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center) {
MillenniumIcon(
imageVector = FeatherIcons.Lock,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = AppTheme.colors.primary)
}
Text(
text = stringResource(Res.string.login_required_title),
style = AppTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = AppTheme.colors.onSurface,
textAlign = TextAlign.Center)
Text(
text = if (pluginName.isNullOrBlank()) {
stringResource(Res.string.login_required_description)
} else {
stringResource(Res.string.login_required_description_plugin, pluginName)
},
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center)
MillenniumButton(
onClick = onLogin,
modifier = Modifier.height(44.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIcon(
imageVector = FeatherIcons.LogIn,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp).size(18.dp))
Text(stringResource(Res.string.login_required_action))
}
}
}
}

View File

@ -28,9 +28,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -145,10 +143,7 @@ fun TrackOptionsBottomSheet(
onDismiss() onDismiss()
} }
} }
MillenniumModalBottomSheet( MillenniumModalBottomSheet(state = sheetState) {
state = sheetState,
contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp),
contentSpacing = 4.dp) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
TrackOptionsSheetHeader(track = track) TrackOptionsSheetHeader(track = track)
@ -189,7 +184,7 @@ fun TrackOptionsBottomSheet(
} else { } else {
AppTheme.colors.onSurface.copy(alpha = 0.38f) AppTheme.colors.onSurface.copy(alpha = 0.38f)
}) })
} ?: Spacer(modifier = Modifier.size(22.dp)) }
Text( Text(
text = item.label, text = item.label,
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,

View File

@ -42,8 +42,6 @@ import androidx.compose.ui.unit.TextUnit
import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumBevelBrush
val LocalSkeletonLoading = compositionLocalOf { false } val LocalSkeletonLoading = compositionLocalOf { false }
@ -82,16 +80,11 @@ fun SkeletonTree(
} }
fun Modifier.shimmerApply( fun Modifier.shimmerApply(
color: Color? = null // defaults to Millennium's recessed skeleton fill color: Color = Color.LightGray.copy(alpha = 0.5f) // Adjust alpha/color to match your theme
): Modifier = composed { ): Modifier = composed {
val isTreeLoading = LocalSkeletonLoading.current val isTreeLoading = LocalSkeletonLoading.current
val skeletonBrush = millenniumBevelBrush(MillenniumTheme.colors, sunken = true)
when { if (isTreeLoading) this.background(color) else this
!isTreeLoading -> this
color != null -> this.background(color)
else -> this.background(skeletonBrush)
}
} }
@Composable @Composable

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.core.ui.theming package dev.krtirtho.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -36,11 +35,9 @@ import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.composables.MillenniumModalBottomSheetTheme
import org.spotube.millennium.composables.MillenniumTooltipHost import org.spotube.millennium.composables.MillenniumTooltipHost
import org.spotube.millennium.composables.MillenniumTopAppBarTheme import org.spotube.millennium.composables.MillenniumTopAppBarTheme
import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties
import org.spotube.millennium.composables.defaultMillenniumModalBottomSheetTheme
import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme
import org.spotube.millennium.theme.MillenniumColors import org.spotube.millennium.theme.MillenniumColors
import org.spotube.millennium.theme.MillenniumDarkThemeComponents import org.spotube.millennium.theme.MillenniumDarkThemeComponents
@ -221,16 +218,14 @@ fun SpotubeTheme(
properties = darkTheme.topAppBarTheme.properties.copy( properties = darkTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent separatorColor = Color.Transparent
) )
), )
modalBottomSheetTheme = compactModalBottomSheetTheme()
), ),
light = lightTheme.copy( light = lightTheme.copy(
topAppBarTheme = MillenniumTopAppBarTheme( topAppBarTheme = MillenniumTopAppBarTheme(
properties = lightTheme.topAppBarTheme.properties.copy( properties = lightTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent separatorColor = Color.Transparent
) )
), )
modalBottomSheetTheme = compactModalBottomSheetTheme()
) )
) )
) { ) {
@ -246,23 +241,6 @@ fun SpotubeTheme(
} }
} }
/** Library default is 24dp padding + 16dp spacing, which is far too roomy on phones. */
private fun compactModalBottomSheetTheme(): MillenniumModalBottomSheetTheme {
val padding = PaddingValues(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp)
fun org.spotube.millennium.composables.MillenniumModalBottomSheetThemeProperties.compact() =
copy(contentPadding = padding, contentSpacing = 8.dp)
val states = defaultMillenniumModalBottomSheetTheme().states
return MillenniumModalBottomSheetTheme(
states = org.spotube.millennium.theme.MillenniumControlStateProperty(
default = states.default.compact(),
hovered = states.hovered.compact(),
focused = states.focused.compact(),
pressed = states.pressed.compact(),
disabled = states.disabled.compact(),
selected = states.selected.compact(),
error = states.error.compact()))
}
/** Whether the active Millennium appearance is light. */ /** Whether the active Millennium appearance is light. */
@Composable @Composable
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f

View File

@ -21,10 +21,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import dev.krtirtho.spotube.core.di.injectLogger
import org.koin.core.component.KoinComponent
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
@ -41,10 +37,7 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class ISRCProviders : KoinComponent { class ISRCProviders {
private val logger by injectLogger<ISRCProviders>()
private val lenientJson = Json { isLenient = true; ignoreUnknownKeys = true }
companion object { companion object {
val greedyGreenCorp = val greedyGreenCorp =
listOf(-0x03, -0x01, 0x05, -0x0B, -0x03, 0x13) listOf(-0x03, -0x01, 0x05, -0x0B, -0x03, 0x13)
@ -64,17 +57,6 @@ class ISRCProviders : KoinComponent {
} }
} }
private fun MetadataTrack.describe() =
"id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " +
"durationMs=$durationMs isrc=$isrcCode externalUri=$externalUri"
/** Logs the full raw body (so parse failures are diagnosable) then decodes it. */
private suspend inline fun <reified T> HttpResponse.decodeLogged(provider: String): T {
val text = bodyAsText()
logger.i { "[ISRC:$provider] status=${status.value} rawBody=$text" }
return lenientJson.decodeFromString<T>(text)
}
private var ifpiSessionToken: String? = null private var ifpiSessionToken: String? = null
private val ifpiDefaultHeaders = mapOf( private val ifpiDefaultHeaders = mapOf(
"Content-Type" to "application/json", "Content-Type" to "application/json",
@ -96,23 +78,20 @@ class ISRCProviders : KoinComponent {
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
if (ifpiSessionToken != null) return@withContext ifpiSessionToken if (ifpiSessionToken != null) return@withContext ifpiSessionToken
logger.i { "[ISRC:ifpi] requesting session token" }
val res = client.get("https://isrc-api.soundexchange.com/api/ext/login") { val res = client.get("https://isrc-api.soundexchange.com/api/ext/login") {
headers.appendAll(ifpiDefaultHeaders) headers.appendAll(ifpiDefaultHeaders)
} }
if (res.status.value != 200) { if (res.status.value != 200) {
logger.w { "[ISRC:ifpi] session login failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.decodeLogged<IFPISessionResponse>("ifpi-login") val body = res.body<IFPISessionResponse>()
ifpiSessionToken = body.token ifpiSessionToken = body.token
body.token body.token
} }
suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) { suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) {
logger.i { "[ISRC:ifpi] input: ${track.describe()}" }
val sessonToken = ifpiSessionToken() ?: return@withContext null val sessonToken = ifpiSessionToken() ?: return@withContext null
val input = IFPIRecordingRequestInput( val input = IFPIRecordingRequestInput(
searchFields = IFPIRecordingRequestInput.SearchFields( searchFields = IFPIRecordingRequestInput.SearchFields(
@ -134,7 +113,6 @@ class ISRCProviders : KoinComponent {
) )
) )
logger.i { "[ISRC:ifpi] request searchFields=${input.searchFields}" }
val res = client.post("https://isrc-api.soundexchange.com/api/ext/recordings") { val res = client.post("https://isrc-api.soundexchange.com/api/ext/recordings") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
headers { headers {
@ -145,28 +123,17 @@ class ISRCProviders : KoinComponent {
} }
if (res.status.value != 200) { if (res.status.value != 200) {
logger.w { "[ISRC:ifpi] search failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.decodeLogged<IFPIRecordingResponse>("ifpi") val body = res.body<IFPIRecordingResponse>()
if (body.recordings.isEmpty()) { if (body.recordings.isEmpty()) {
logger.i { "[ISRC:ifpi] no recordings returned" }
return@withContext null return@withContext null
} }
body.recordings.forEachIndexed { i, r ->
logger.i {
"[ISRC:ifpi] candidate #$i isrc=${r.isrc} title='${r.recordingTitle}' " +
"artist='${r.recordingArtistName}' duration=${r.duration} year=${r.recordingYear} " +
"version=${r.recordingVersion} valid=${r.isValidIsrc}"
}
}
logger.i { "[ISRC:ifpi] picking FIRST candidate isrc=${body.recordings.first().isrc} (no artist/title validation)" }
body.recordings.first().isrc body.recordings.first().isrc
} }
suspend fun musicGateway(track: MetadataTrack): String? = withContext(Dispatchers.IO) { suspend fun musicGateway(track: MetadataTrack): String? = withContext(Dispatchers.IO) {
logger.i { "[ISRC:musicGateway] input: ${track.describe()}" }
val res = client.get("https://www.musicgateway.com/isrc-finder") { val res = client.get("https://www.musicgateway.com/isrc-finder") {
parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}") parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}")
parameter("type", "name") parameter("type", "name")
@ -174,47 +141,39 @@ class ISRCProviders : KoinComponent {
} }
if (res.status != HttpStatusCode.OK) { if (res.status != HttpStatusCode.OK) {
logger.w { "[ISRC:musicGateway] failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.decodeLogged<MusicGatewayResponse>("musicGateway") val body = res.body<MusicGatewayResponse>()
val results = body.result.tracks.items val results = body.result.tracks.items
if (results.isEmpty()) { if (results.isEmpty()) {
logger.i { "[ISRC:musicGateway] no results" }
return@withContext null return@withContext null
} }
var isrcCode: String? = null var isrcCode: String? = null
for ((_, artists, externalIds, id, name) in results) { for (item in results) {
val isExactTitle = name.equals(track.title, ignoreCase = true) val isExactTitle = item.name.equals(track.title, ignoreCase = true)
val isArtistMatch = artists.any { val isArtistMatch = item.artists.any {
it.name.equals( it.name.equals(
track.artists.firstOrNull()?.name ?: "", track.artists.firstOrNull()?.name ?: "",
ignoreCase = true ignoreCase = true
) )
} }
val isSameId = id == track.id val isSameId = item.id == track.id
logger.i {
"[ISRC:musicGateway] candidate id=$id name='$name' artists=${artists.map { it.name }} " +
"isrc=${externalIds.isrc} exactTitle=$isExactTitle artistMatch=$isArtistMatch sameId=$isSameId"
}
if (isSameId || (isExactTitle && isArtistMatch)) { if (isSameId || (isExactTitle && isArtistMatch)) {
isrcCode = externalIds.isrc isrcCode = item.externalIds.isrc
break break
} }
} }
logger.i { "[ISRC:musicGateway] result isrc=$isrcCode" }
isrcCode isrcCode
} }
suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) { suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) {
val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false
val uri = "https://open.$greedyGreenCorp.com/track/${track.id}" val uri = "https://open.$greedyGreenCorp.com/track/${track.id}"
logger.i { "[ISRC:soundplate] input: ${track.describe()} useUri=$isGreedyGreenCorp" }
val res = val res =
client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") { client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") {
if (isGreedyGreenCorp) { if (isGreedyGreenCorp) {
@ -226,39 +185,16 @@ class ISRCProviders : KoinComponent {
} }
if (res.status.value != 200) { if (res.status.value != 200) {
logger.w { "[ISRC:soundplate] failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.decodeLogged<SoundplateResponse>("soundplate") val body = res.body<SoundplateResponse>()
logger.i {
"[ISRC:soundplate] returned isrc=${body.isrc} name='${body.name}' artist='${body.artist}' " +
"album='${body.album}' year=${body.year} (input title='${track.title}' " +
"artist='${track.artists.firstOrNull()?.name}') — not validated against input"
}
body.isrc body.isrc
} }
suspend fun auto(track: MetadataTrack): String? { suspend fun auto(track: MetadataTrack): String? {
logger.i { "[ISRC:auto] start: ${track.describe()}" } return runCatching { soundplate(track) }.getOrNull()
val providers = listOf<Pair<String, suspend (MetadataTrack) -> String?>>( ?: runCatching { ifpi(track) }.getOrNull()
"soundplate" to ::soundplate, ?: runCatching { musicGateway(track) }.getOrNull()
"ifpi" to ::ifpi,
"musicGateway" to ::musicGateway)
for ((name, provider) in providers) {
val result = runCatching { provider(track) }
result.exceptionOrNull()?.let {
if (it is kotlinx.coroutines.CancellationException) throw it
logger.w(it) { "[ISRC:auto] provider $name threw" }
}
val isrc = result.getOrNull()
logger.i { "[ISRC:auto] provider $name -> $isrc" }
if (isrc != null) {
logger.i { "[ISRC:auto] using isrc=$isrc from $name" }
return isrc
}
}
logger.w { "[ISRC:auto] no provider produced an ISRC" }
return null
} }
} }

View File

@ -25,7 +25,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.newpipe.NewPipeService import dev.krtirtho.spotube.core.newpipe.NewPipeService
import dev.krtirtho.spotube.core.newpipe.SearchKind
import dev.krtirtho.spotube.core.newpipe.VideoSearchResult import dev.krtirtho.spotube.core.newpipe.VideoSearchResult
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -35,61 +34,27 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
companion object { companion object {
private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$") private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$")
private val isrcRegex = Regex("^[A-Z]{2}[A-Z0-9]{3}\\d{7}$")
private const val minConfidence = 0.3f
private const val confidentMatch = 0.6f
} }
private val isrcProvider = ISRCProviders() private val isrcProvider = ISRCProviders()
private fun normalize(value: String): String =
value.lowercase()
.replace(Regex("[^\\p{L}\\p{N}]+"), " ")
.trim()
/** Drops "(feat. X)", "[Remastered]" style suffixes so the core title can be matched. */
private fun coreTitle(title: String): String =
normalize(title.replace(Regex("[(\\[].*?[)\\]]"), " "))
private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic {
var confidence = 0f var confidence = 0f
val videoTitle = normalize(video.title) val isTitleMatch = track.title.lowercase() in video.title.lowercase()
val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() }
val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) val isAlbumInTitle =
val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() } track.album != null && track.album!!.title.lowercase() in video.title.lowercase()
val title = coreTitle(track.title) val isUploaderMatch =
video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "")
val videoTokens = videoTitle.split(' ').toSet() val isDurationMatch =
val isTitleMatch = title.isNotEmpty() && video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds
(title in videoTitle || title.split(' ').all { it in videoTokens })
val isArtistInTitle = artistNames.any { it in videoTitle }
val isAlbumInTitle = track.album?.title?.let { normalize(it) }
?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false
val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it }
// Unknown duration (0) must neither reward nor punish
val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE
if (isTitleMatch) confidence += 0.3f if (isTitleMatch) confidence += 0.3f
if (isUploaderMatch) confidence += 0.3f if (isUploaderMatch) confidence += 0.3f
if (durationDiff <= 10_000) confidence += 0.2f if (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds
else if (durationDiff <= 30_000) confidence += 0.1f
if (isArtistInTitle) confidence += 0.1f if (isArtistInTitle) confidence += 0.1f
if (isAlbumInTitle) confidence += 0.1f if (isAlbumInTitle) confidence += 0.1f
// Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos
if (isTopicChannel && isUploaderMatch) confidence += 0.1f
confidence = confidence.coerceAtMost(1f)
logger.i {
"[Match] video id=${video.id} title='${video.title}' uploader='${video.uploader}' " +
"durationMs=${video.durationMs} url=${video.url}\n" +
" vs track title='${track.title}' artists=${track.artists.map { it.name }} " +
"album='${track.album?.title}' durationMs=${track.durationMs}\n" +
" normalized: videoTitle='$videoTitle' uploader='$uploader' coreTitle='$title' artists=$artistNames\n" +
" titleMatch=$isTitleMatch uploaderMatch=$isUploaderMatch topic=$isTopicChannel " +
"artistInTitle=$isArtistInTitle albumInTitle=$isAlbumInTitle durationDiffMs=$durationDiff " +
"=> confidence=$confidence"
}
return AudioSource.Basic( return AudioSource.Basic(
id = video.id, id = video.id,
@ -160,85 +125,25 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
logger.i { "No YouTube ID or URL found, performing search with track metadata" } logger.i { "No YouTube ID or URL found, performing search with track metadata" }
logger.i { val isrcCode = track.isrcCode ?: isrcProvider.auto(track)
"[Search] input track id=${track.id} title='${track.title}' artists=${track.artists.map { it.name }} " + val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim()
"album='${track.album?.title}' durationMs=${track.durationMs} trackIsrc=${track.isrcCode} " +
"externalUri=${track.externalUri}" logger.i { "Searching for video with query: $searchQuery" }
} val videos = newPipeService.searchVideos(isrcCode ?: searchQuery)
val rawIsrc = track.isrcCode ?: isrcProvider.auto(track)
val isrcCode = rawIsrc logger.i { "Found ${videos.size} videos with query: $searchQuery" }
?.trim()?.uppercase()?.replace("-", "") var sources = videos.map { transformVideo(track, it) }
?.takeIf { isrcRegex.matches(it) }
logger.i { if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) {
"[Search] isrc raw=$rawIsrc (from ${if (track.isrcCode != null) "track" else "providers"}) " + logger.i { "ISRC search failed, fallback to title search" }
"validated=$isrcCode" val videos = newPipeService.searchVideos(searchQuery)
sources = videos.map { transformVideo(track, it) }
} }
val artistText = track.artists.joinToString(" ") { it.name }.trim() logger.i { "Found ${sources.size} sources with confidence >= 0.3f" }
val firstArtist = track.artists.firstOrNull()?.name?.trim().orEmpty()
val plainTitle = track.title.replace(Regex("[(\\[].*?[)\\]]"), " ").replace(Regex("\\s+"), " ").trim()
// Ordered from most to least trusted. The ISRC is only a hint: a text search for it on // Return 5 highest to lowest confidence
// YouTube Music often returns unrelated songs, so every step is scored against the track return sources.sortedByDescending { it.confidence }.take(5)
// and the next step runs until one result clears [confidentMatch].
val plan = buildList {
if (isrcCode != null) add("isrc" to (isrcCode to SearchKind.Songs))
add("songs: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Songs))
if (plainTitle.isNotEmpty() && firstArtist.isNotEmpty() && plainTitle != track.title) {
add("songs: plain title+first artist" to ("$plainTitle $firstArtist" to SearchKind.Songs))
}
add("videos: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Videos))
add("songs: title only" to (plainTitle.ifEmpty { track.title } to SearchKind.Songs))
}
val sources = linkedMapOf<String, AudioSource.Basic>()
val searched = mutableSetOf<Pair<String, SearchKind>>()
// Results of the text searches in YouTube's relevance order, used if nothing scores well
val textSearchResults = linkedMapOf<String, AudioSource.Basic>()
for ((stage, request) in plan) {
if (sources.values.any { it.confidence >= confidentMatch }) break
if (!searched.add(request)) continue
val (query, kind) = request
val raw = runCatching { newPipeService.searchVideos(query, kind) }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
logger.w(it) { "[Search] stage '$stage' failed for query '$query' ($kind)" }
}.getOrDefault(emptyList())
logger.i { "[Search] stage '$stage' query='$query' kind=$kind -> ${raw.size} results: $raw" }
val scored = raw.map { transformVideo(track, it) }
logger.i {
"[Search] stage '$stage' best confidence=${scored.maxOfOrNull { it.confidence }} " +
"(need >= $confidentMatch to stop)"
}
if (stage != "isrc") scored.forEach { if (it.id !in textSearchResults) textSearchResults[it.id] = it }
scored.forEach { s ->
val existing = sources[s.id]
if (existing == null || existing.confidence < s.confidence) sources[s.id] = s
}
}
val ranked = sources.values.sortedByDescending { it.confidence }
val matched = ranked.filter { it.confidence >= minConfidence }
logger.i { "[Search] ${matched.size} of ${ranked.size} candidates have confidence >= $minConfidence" }
logger.i {
"[Search] final ranking:\n" + matched.take(5).mapIndexed { i, s ->
" #$i ${s.id} conf=${s.confidence} '${s.title}' by '${s.artist}'"
}.joinToString("\n")
}
if (matched.isNotEmpty()) return matched.take(5)
// Nothing scored well. Playing YouTube's best text-search guess beats failing playback,
// and ISRC results are skipped because an ISRC text search tends to return unrelated songs.
val fallback = textSearchResults.values.toList()
.sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order
.take(5)
.ifEmpty { ranked.take(5) }
logger.w {
"[Search] no confident match for '${track.title}'; falling back to best guesses: " +
fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" }
}
return fallback
} }
override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> { override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {

View File

@ -84,6 +84,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -142,7 +143,8 @@ fun ArtistScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize()
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp) verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
item { item {

View File

@ -18,10 +18,10 @@
package dev.krtirtho.spotube.modules.devices package dev.krtirtho.spotube.modules.devices
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -103,9 +103,10 @@ fun DevicesScreen(
title = { Text(stringResource(Res.string.devices_title)) }, title = { Text(stringResource(Res.string.devices_title)) },
actions = { actions = {
if (isDiscovering && connectingToDevice == null) { if (isDiscovering && connectingToDevice == null) {
MillenniumSpokeSpinner( MillenniumIndeterminateProgress(
size = 24.dp, modifier = Modifier
modifier = Modifier.padding(end = 8.dp)) .size(24.dp)
.padding(end = 8.dp))
} else { } else {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxRefreshRight, imageVector = Iconsax.IconsaxRefreshRight,
@ -176,7 +177,8 @@ fun DevicesScreen(
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
MillenniumSpokeSpinner(size = 24.dp) MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
Text( Text(
text = stringResource(Res.string.devices_connecting), text = stringResource(Res.string.devices_connecting),
style = AppTheme.typography.bodyMedium) style = AppTheme.typography.bodyMedium)
@ -248,7 +250,8 @@ private fun DeviceRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (isConnecting) { if (isConnecting) {
MillenniumSpokeSpinner(size = 24.dp) MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
} else { } else {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxMirroringScreen, imageVector = Iconsax.IconsaxMirroringScreen,

View File

@ -18,11 +18,6 @@
package dev.krtirtho.spotube.modules.devices package dev.krtirtho.spotube.modules.devices
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
@ -87,12 +82,10 @@ fun PlayDestinationPickerHost() {
PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next) PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next)
} }
MillenniumDialog(visible = true, onDismissRequest = controller::dismissPicker) { MillenniumDialog(
MillenniumDialogHeader { visible = true,
MillenniumDialogTitle(stringResource(Res.string.devices_destination_title)) onDismissRequest = controller::dismissPicker,
MillenniumDialogClose() title = stringResource(Res.string.devices_destination_title)) {
}
MillenniumDialogBody {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)) {
@ -145,9 +138,6 @@ fun PlayDestinationPickerHost() {
}) })
} }
}
}
MillenniumDialogFooter {
MillenniumButton( MillenniumButton(
onClick = controller::dismissPicker, onClick = controller::dismissPicker,
style = MillenniumButtonStyle.Quiet) { style = MillenniumButtonStyle.Quiet) {

View File

@ -18,8 +18,6 @@
package dev.krtirtho.spotube.modules.devices package dev.krtirtho.spotube.modules.devices
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
@ -606,6 +604,11 @@ private fun RemoteQueueItemRow(
MillenniumDropdownMenu( MillenniumDropdownMenu(
expanded = showMenu, expanded = showMenu,
onExpandedChange = { showMenu = it }, onExpandedChange = { showMenu = it },
items = listOf(stringResource(Res.string.devices_remote_remove_from_queue)),
onItemClick = {
onRemoveClick()
showMenu = false
},
anchor = { anchor = {
MillenniumIconButton( MillenniumIconButton(
onClick = { showMenu = true }, onClick = { showMenu = true },
@ -617,16 +620,7 @@ private fun RemoteQueueItemRow(
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
} }
}) { })
MillenniumDropdownMenuItem(
label = stringResource(Res.string.devices_remote_remove_from_queue),
icon = Iconsax.IconsaxMusicSquareRemove,
destructive = true,
onClick = {
onRemoveClick()
showMenu = false
})
}
} }
) )
} }

View File

@ -3,7 +3,6 @@
*/ */
package dev.krtirtho.spotube.modules.downloads package dev.krtirtho.spotube.modules.downloads
import org.spotube.millennium.composables.MillenniumStripedProgress
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -43,6 +42,7 @@ import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumProgress
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumPanelBrush import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface import org.spotube.millennium.theme.millenniumSurface
@ -225,10 +225,9 @@ internal fun DownloadListRow(
}, },
maxLines = 1, overflow = TextOverflow.Ellipsis maxLines = 1, overflow = TextOverflow.Ellipsis
) )
if (item.status == DownloadStatus.Downloading) MillenniumStripedProgress( if (item.status == DownloadStatus.Downloading) MillenniumProgress(
progress = item.progress, item.progress,
modifier = Modifier.fillMaxWidth(), Modifier.fillMaxWidth().height(4.dp)
height = 6.dp
) )
} }
MillenniumIconButton( MillenniumIconButton(

View File

@ -17,9 +17,6 @@
package dev.krtirtho.spotube.modules.jam package dev.krtirtho.spotube.modules.jam
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -424,27 +421,25 @@ internal fun SessionPanel(
) )
} }
if (participant.canModerate) { if (participant.canModerate) {
val removeLabel = stringResource(Res.string.jam_participants_remove)
val banLabel = stringResource(Res.string.jam_participants_ban)
// Keep destructive moderation out of the participant's primary row. // Keep destructive moderation out of the participant's primary row.
org.spotube.millennium.composables.MillenniumDropdownMenu( org.spotube.millennium.composables.MillenniumDropdownMenu(
expanded = participant.menuExpanded, expanded = participant.menuExpanded,
onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) }, onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) },
items = listOf(removeLabel, banLabel),
onItemClick = { label ->
if (label == removeLabel) onKick(participant.id) else onBan(
participant.id
)
},
anchor = { anchor = {
MillenniumButton( MillenniumButton(
onClick = { onMenuChange(participant.id) }, onClick = { onMenuChange(participant.id) },
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { Text(stringResource(Res.string.jam_participants_manage)) } ) { Text(stringResource(Res.string.jam_participants_manage)) }
} }
) { )
MillenniumDropdownMenuItem(
label = stringResource(Res.string.jam_participants_remove),
icon = Iconsax.IconsaxUserRemove,
onClick = { onKick(participant.id) })
MillenniumDropdownMenuItem(
label = stringResource(Res.string.jam_participants_ban),
icon = Iconsax.IconsaxForbidden,
destructive = true,
onClick = { onBan(participant.id) })
}
} }
} }
} }

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.modules.jam package dev.krtirtho.spotube.modules.jam
import org.spotube.millennium.composables.MillenniumAlertDialog
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
@ -48,23 +47,27 @@ fun PlayDestinationPicker(
onSuggestToJam: () -> Unit) { onSuggestToJam: () -> Unit) {
if (!visible) return if (!visible) return
MillenniumAlertDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.jam_destination_title)) }, title = stringResource(Res.string.jam_destination_title)) {
description = { Text(stringResource(Res.string.jam_destination_message)) }) { Text(
MillenniumButton( text = stringResource(Res.string.jam_destination_message),
onClick = onDismiss, style = AppTheme.typography.bodyMedium)
style = MillenniumButtonStyle.Quiet) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(Res.string.common_cancel)) MillenniumButton(
} onClick = onDismiss,
MillenniumButton( style = MillenniumButtonStyle.Quiet) {
onClick = onPlayLocally, Text(stringResource(Res.string.common_cancel))
style = MillenniumButtonStyle.Secondary) { }
Text(stringResource(Res.string.jam_destination_play_here)) MillenniumButton(
} onClick = onPlayLocally,
MillenniumButton(onClick = onSuggestToJam) { style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.jam_destination_suggest)) Text(stringResource(Res.string.jam_destination_play_here))
}
MillenniumButton(onClick = onSuggestToJam) {
Text(stringResource(Res.string.jam_destination_suggest))
}
} }
} }
} }

View File

@ -57,7 +57,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
@ -117,20 +116,14 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
} }
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
when (state.currentTab) { when (state.currentTab) {
LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryTab.Playlists -> LibraryPlaylistsScreen(
LibraryPlaylistsScreen( showCreatePlaylist = state.showCreatePlaylist,
showCreatePlaylist = state.showCreatePlaylist, onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, viewMode = viewMode
viewMode = viewMode )
)
}
LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode)
LibraryAlbumsScreen(viewMode = viewMode) LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode)
}
LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
LibraryArtistsScreen(viewMode = viewMode)
}
LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
LibraryTab.Downloads -> DownloadsScreen() LibraryTab.Downloads -> DownloadsScreen()
} }

View File

@ -3,8 +3,6 @@
*/ */
package dev.krtirtho.spotube.modules.library.local_tracks package dev.krtirtho.spotube.modules.library.local_tracks
import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -38,9 +36,6 @@ import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState
import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
@ -53,6 +48,7 @@ import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumPanelBrush import org.spotube.millennium.theme.millenniumPanelBrush
@ -62,8 +58,7 @@ import spotube.composeapp.generated.resources.*
@Composable @Composable
fun LibraryLocalTracksScreen( fun LibraryLocalTracksScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LibraryLocalTracksViewModel = koinViewModel(), viewModel: LibraryLocalTracksViewModel = koinViewModel()
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel()
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val permission = rememberLocalMediaPermissionState() val permission = rememberLocalMediaPermissionState()
@ -71,10 +66,8 @@ fun LibraryLocalTracksScreen(
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
LocalTracksContent( LocalTracksContent(
state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp,
viewModel::playCurrentFolder, permission.requestPermission, modifier, viewModel::playCurrentFolder, permission.requestPermission, modifier
onManageFolders = foldersViewModel::open
) )
LocalMediaFoldersDialogHost(foldersViewModel)
} }
@Composable @Composable
@ -85,15 +78,14 @@ internal fun LocalTracksContent(
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onPlayFolder: () -> Unit, onPlayFolder: () -> Unit,
onRequestPermission: () -> Unit, onRequestPermission: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
onManageFolders: () -> Unit = {}
) { ) {
val inset = LocalAppShellBottomInset.current val inset = LocalAppShellBottomInset.current
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Column( Column(
Modifier.widthIn(max = 960.dp).fillMaxSize() Modifier.widthIn(max = 960.dp).fillMaxSize()
) { ) {
LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder, onManageFolders = onManageFolders) LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder)
when { when {
!state.canAccessMedia -> MediaEmptyState( !state.canAccessMedia -> MediaEmptyState(
Iconsax.IconsaxFolderOpen, Iconsax.IconsaxFolderOpen,
@ -114,7 +106,7 @@ internal fun LocalTracksContent(
Modifier.fillMaxSize().padding(bottom = inset), Modifier.fillMaxSize().padding(bottom = inset),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
MillenniumCircularProgress(size = 40.dp) MillenniumIndeterminateProgress()
} }
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
@ -151,35 +143,16 @@ internal fun LocalTracksContent(
} }
} }
else -> { else -> MediaEmptyState(
val noFolders = state.currentFolderPath == null && state.query.isBlank() Iconsax.IconsaxFolderOpen,
MediaEmptyState( state.error ?: stringResource(
Iconsax.IconsaxFolderOpen, when {
state.error ?: stringResource( state.query.isNotBlank() -> Res.string.library_local_no_results
when { state.currentFolderPath == null -> Res.string.library_local_empty_folders
state.query.isNotBlank() -> Res.string.library_local_no_results else -> Res.string.library_local_empty_tracks
state.currentFolderPath == null -> Res.string.library_local_empty_folders }
else -> Res.string.library_local_empty_tracks ), Modifier.padding(bottom = inset)
} )
), Modifier.padding(bottom = inset),
description = if (noFolders && state.error == null)
stringResource(Res.string.library_local_empty_folders_description) else null,
action = if (noFolders) ({
MillenniumButton(onClick = onManageFolders) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MillenniumIcon(
Iconsax.IconsaxFolderAdd, null,
modifier = Modifier.size(18.dp)
)
Text(stringResource(Res.string.library_local_add_folder))
}
}
}) else null
)
}
} }
} }
} }
@ -190,8 +163,7 @@ internal fun LocalTracksBreadcrumb(
state: LibraryLocalTracksState, state: LibraryLocalTracksState,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onPlayFolder: () -> Unit, onPlayFolder: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
onManageFolders: (() -> Unit)? = null
) { ) {
val backDescription = stringResource(Res.string.library_local_back) val backDescription = stringResource(Res.string.library_local_back)
Row( Row(
@ -265,17 +237,7 @@ internal fun LocalTracksBreadcrumb(
style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant, style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1 maxLines = 1
) )
if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp) if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp))
if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton(
onClick = onManageFolders,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxFolderAdd,
stringResource(Res.string.library_local_add_folder),
modifier = Modifier.size(14.dp)
)
}
if (state.currentFolderPath != null) MillenniumIconButton( if (state.currentFolderPath != null) MillenniumIconButton(
onClick = onPlayFolder, enabled = state.canPlayFolder, onClick = onPlayFolder, enabled = state.canPlayFolder,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.modules.library.playlist package dev.krtirtho.spotube.modules.library.playlist
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
@ -155,25 +154,34 @@ fun AddToPlaylistPicker(
} }
} }
val pickerTitle = stringResource(Res.string.library_add_to_playlist_title) if (isLargeScreen) {
AdaptiveDialogBottomSheet( MillenniumDialog(
onDismiss = onDismiss, visible = true,
modifier = modifier, onDismissRequest = onDismiss,
dialogTitle = pickerTitle, title = stringResource(Res.string.library_add_to_playlist_title),
title = { Text(pickerTitle, style = AppTheme.typography.headlineSmall) }, modifier = modifier) {
actions = { Box(modifier = Modifier.fillMaxWidth().height(400.dp)) {
Row( content()
modifier = Modifier.fillMaxWidth(), }
horizontalArrangement = Arrangement.End) { MillenniumButton(
MillenniumButton( onClick = onDismiss,
onClick = onDismiss, style = MillenniumButtonStyle.Secondary) {
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_cancel))
Text(stringResource(Res.string.common_cancel)) }
} }
} else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
MillenniumModalBottomSheet(state = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = stringResource(Res.string.library_add_to_playlist_title),
style = AppTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp))
content()
} }
}) {
Box(modifier = Modifier.fillMaxWidth().height(400.dp)) {
content()
} }
} }
} }

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.modules.library.playlist package dev.krtirtho.spotube.modules.library.playlist
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
@ -163,42 +162,90 @@ fun PlaylistFormSheet(
val handleImagePick = { imagePicker.launch() } val handleImagePick = { imagePicker.launch() }
val formTitle = stringResource( if (isLargeScreen) {
if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title) MillenniumDialog(
AdaptiveDialogBottomSheet( visible = true,
onDismiss = onDismiss, onDismissRequest = onDismiss,
modifier = modifier, title = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title),
dialogTitle = formTitle, modifier = modifier
title = { Text(formTitle, style = AppTheme.typography.headlineSmall) }, ) {
actions = { PlaylistFormFields(
Row( nameState = nameState,
modifier = Modifier.fillMaxWidth(), descriptionState = descriptionState,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { isPublic = isPublic,
MillenniumButton( onIsPublicChange = { isPublic = it },
onClick = onDismiss, isCollaborating = isCollaborating,
style = MillenniumButtonStyle.Secondary) { onIsCollaboratingChange = { isCollaborating = it },
Text(stringResource(Res.string.common_cancel)) imageBase64 = imageBase64,
} imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton( MillenniumButton(
onClick = { onClick = {
onSubmit(currentData) onSubmit(currentData)
onDismiss() onDismiss()
}, },
enabled = name.isNotBlank()) { enabled = name.isNotBlank()
) {
Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create))
} }
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.common_cancel))
}
} }
}) { }
PlaylistFormFields( } else {
nameState = nameState, val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
descriptionState = descriptionState, MillenniumModalBottomSheet(state = sheetState) {
isPublic = isPublic, Column(
onIsPublicChange = { isPublic = it }, modifier = Modifier
isCollaborating = isCollaborating, .fillMaxWidth()
onIsCollaboratingChange = { isCollaborating = it }, .padding(horizontal = 16.dp, vertical = 8.dp)
imageBase64 = imageBase64, ) {
imagePreviewUrl = imagePreviewUrl, Text(
onPickImage = handleImagePick) text = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title),
style = AppTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
PlaylistFormFields(
nameState = nameState,
descriptionState = descriptionState,
isPublic = isPublic,
onIsPublicChange = { isPublic = it },
isCollaborating = isCollaborating,
onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.common_cancel))
}
MillenniumButton(
onClick = {
onSubmit(currentData)
onDismiss()
},
enabled = name.isNotBlank()
) {
Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create))
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
} }
} }

View File

@ -18,56 +18,52 @@
package dev.krtirtho.spotube.modules.lyrics package dev.krtirtho.spotube.modules.lyrics
import org.spotube.millennium.composables.MillenniumBarsLoader
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSegmentedButton import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
import org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImage
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import compose.icons.FeatherIcons
import compose.icons.feathericons.X
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -84,87 +80,96 @@ fun LyricsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClose: (() -> Unit)? = null) { onClose: (() -> Unit)? = null) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = MillenniumTheme.colors val positionMillis by viewModel.positionMillisFlow.collectAsState()
val scope = rememberCoroutineScope()
BoxWithConstraints( Column(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) {
modifier = modifier Row(
.fillMaxSize() modifier = Modifier
.background( .fillMaxWidth()
Brush.verticalGradient( .padding(horizontal = 8.dp, vertical = 6.dp),
0f to colors.accent.copy(alpha = 0.14f), verticalAlignment = Alignment.CenterVertically) {
0.55f to colors.background, if (onClose != null) {
1f to colors.background MillenniumIconButton(
) onClick = onClose,
) style = MillenniumIconButtonStyle.Quiet
) {
val isWide = maxWidth >= 840.dp
Column(Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(48.dp), contentAlignment = Alignment.CenterStart) {
if (onClose != null) {
MillenniumIconButton(
onClick = onClose,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = stringResource(Res.string.common_close)
)
}
} else {
ApplicationBackButton()
}
}
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
MillenniumSegmentedButtonRow(Modifier.widthIn(max = 280.dp)) {
MillenniumSegmentedButton(
selected = uiState.mode == LyricType.SYNCED,
onClick = { viewModel.setMode(LyricType.SYNCED) },
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2),
modifier = Modifier.weight(1f)
) { Text(stringResource(Res.string.lyrics_mode_synced)) }
MillenniumSegmentedButton(
selected = uiState.mode == LyricType.STATIC,
onClick = { viewModel.setMode(LyricType.STATIC) },
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2),
modifier = Modifier.weight(1f)
) { Text(stringResource(Res.string.lyrics_mode_plain)) }
}
}
Spacer(Modifier.width(48.dp))
}
if (isWide) {
Row(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 40.dp),
horizontalArrangement = Arrangement.spacedBy(40.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
TrackPane(uiState, Modifier.weight(0.38f)) MillenniumIcon(
Column(Modifier.weight(0.62f).fillMaxHeight()) { imageVector = Iconsax.IconsaxArrowDown4,
LyricsBody(uiState, viewModel) contentDescription = stringResource(Res.string.common_close)
} )
} }
} else { } else {
CompactTrackHeader( ApplicationBackButton()
uiState, }
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp) Spacer(Modifier.weight(1f))
Row(
modifier = Modifier
.clip(RoundedCornerShape(50.dp))
.background(AppTheme.colors.surfaceVariant)
.padding(3.dp),
horizontalArrangement = Arrangement.spacedBy(3.dp)
) {
LyricsModeTab(
label = stringResource(Res.string.lyrics_mode_synced),
isSelected = uiState.mode == LyricType.SYNCED,
onClick = { viewModel.setMode(LyricType.SYNCED) }
) )
Column( LyricsModeTab(
Modifier label = stringResource(Res.string.lyrics_mode_plain),
.weight(1f) isSelected = uiState.mode == LyricType.STATIC,
.fillMaxWidth() onClick = { viewModel.setMode(LyricType.STATIC) }
.padding(horizontal = 8.dp) )
) { }
LyricsBody(uiState, viewModel) Spacer(Modifier.weight(1f))
Spacer(Modifier.width(44.dp))
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
when {
uiState.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
MillenniumIndeterminateProgress(modifier = Modifier.fillMaxWidth(0.4f))
}
}
uiState.error != null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = uiState.error ?: stringResource(Res.string.common_unknown_error),
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.error,
textAlign = TextAlign.Center
)
}
}
uiState.mode == LyricType.SYNCED -> {
SyncedLyricsContent(
syncedLyrics = uiState.syncedLyrics ?: emptyList(),
currentIndex = viewModel.currentLyricIndex(positionMillis),
onScrollToIndex = { index ->
scope.launch {
// scroll will be handled by LazyColumn
}
}
)
}
uiState.mode == LyricType.STATIC -> {
PlainLyricsContent(plainLyrics = uiState.plainLyrics ?: "")
} }
} }
} }
@ -172,175 +177,151 @@ fun LyricsScreen(
} }
@Composable @Composable
private fun ColumnScope.LyricsBody(uiState: LyricsUiState, viewModel: LyricsViewModel) { private fun LyricsModeTab(
when { label: String,
uiState.isLoading -> CenteredMessage { isSelected: Boolean,
MillenniumBarsLoader(size = 48.dp) onClick: () -> Unit) {
}
uiState.error != null -> CenteredMessage {
Text(
text = uiState.error ?: stringResource(Res.string.common_unknown_error),
style = AppTheme.typography.bodyLarge,
color = MillenniumTheme.colors.danger,
textAlign = TextAlign.Center
)
}
uiState.mode == LyricType.SYNCED -> SyncedLyricsContent(
syncedLyrics = uiState.syncedLyrics ?: emptyList(),
currentIndex = uiState.currentIndex,
onLineClick = viewModel::seekToLine
)
uiState.mode == LyricType.STATIC -> PlainLyricsContent(
plainLyrics = uiState.plainLyrics ?: ""
)
}
}
@Composable
private fun CoverArt(url: String, size: Dp, radius: Dp, elevation: Dp, modifier: Modifier = Modifier) {
val colors = MillenniumTheme.colors
Box( Box(
modifier = modifier modifier = Modifier
.size(size) .clip(RoundedCornerShape(50.dp))
.millenniumSurface( .background(
background = millenniumPanelBrush(colors), if (isSelected) {
shape = RoundedCornerShape(radius), AppTheme.colors.primary
borderColor = colors.border, } else {
borderWidth = 1.dp, Color.Transparent
shadowElevation = elevation }
) )
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 7.dp)
) { ) {
AsyncImage(
model = url.takeIf { it.isNotBlank() },
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
@Composable
private fun TrackPane(uiState: LyricsUiState, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CoverArt(
url = uiState.coverUrl,
size = 280.dp,
radius = 24.dp,
elevation = 14.dp
)
Spacer(Modifier.height(24.dp))
Text( Text(
text = uiState.trackTitle, text = label,
style = AppTheme.typography.headlineSmall, style = AppTheme.typography.labelLarge,
color = MillenniumTheme.colors.text, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center, color = if (isSelected) {
maxLines = 2, AppTheme.colors.onPrimary
overflow = TextOverflow.Ellipsis } else {
) AppTheme.colors.onSurfaceVariant
Spacer(Modifier.height(4.dp)) }
Text(
text = uiState.trackArtists,
style = AppTheme.typography.bodyLarge,
color = MillenniumTheme.colors.muted,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }
@Composable @Composable
private fun CompactTrackHeader(uiState: LyricsUiState, modifier: Modifier = Modifier) { private fun SyncedLyricsContent(
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
CoverArt(url = uiState.coverUrl, size = 48.dp, radius = 12.dp, elevation = 4.dp)
Column(Modifier.weight(1f)) {
Text(
text = uiState.trackTitle,
style = AppTheme.typography.titleSmall,
color = MillenniumTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = uiState.trackArtists,
style = AppTheme.typography.bodySmall,
color = MillenniumTheme.colors.muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun ColumnScope.CenteredMessage(content: @Composable () -> Unit) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) { content() }
}
@Composable
private fun ColumnScope.SyncedLyricsContent(
syncedLyrics: List<LyricsLine>, syncedLyrics: List<LyricsLine>,
currentIndex: Int, currentIndex: Int,
onLineClick: (LyricsLine) -> Unit) { onScrollToIndex: (Int) -> Unit) {
if (syncedLyrics.isEmpty()) { if (syncedLyrics.isEmpty()) {
CenteredMessage { Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text( Text(
text = stringResource(Res.string.lyrics_no_synced), text = stringResource(Res.string.lyrics_no_synced),
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = MillenniumTheme.colors.muted, color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
return return
} }
SyncedLyricsList( val listState = rememberLazyListState()
lines = syncedLyrics,
currentIndex = currentIndex,
onLineClick = onLineClick,
modifier = Modifier.weight(1f).fillMaxWidth()
)
}
@Composable LaunchedEffect(currentIndex) {
private fun ColumnScope.PlainLyricsContent( if (currentIndex >= 0) {
plainLyrics: String) { listState.animateScrollToItem(
if (plainLyrics.isBlank()) { index = currentIndex.coerceIn(0, syncedLyrics.lastIndex),
CenteredMessage { scrollOffset = -100
Text(
text = stringResource(Res.string.lyrics_no_plain),
style = AppTheme.typography.bodyLarge,
color = MillenniumTheme.colors.muted,
textAlign = TextAlign.Center
) )
} }
return
} }
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(), state = listState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp) modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
itemsIndexed(syncedLyrics) { index, line ->
val isCurrentLine = index == currentIndex
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.then(
if (isCurrentLine) {
Modifier.background(AppTheme.colors.primaryContainer)
} else {
Modifier
}
)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = formatLyricTime(line.time),
style = AppTheme.typography.labelSmall,
color = if (isCurrentLine) {
AppTheme.colors.primary
} else {
AppTheme.colors.onSurfaceVariant
},
modifier = Modifier.width(44.dp)
)
Text(
text = line.text.ifBlank { "..." },
modifier = Modifier.weight(1f),
style = AppTheme.typography.bodyLarge.copy(
fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal,
color = if (isCurrentLine) {
AppTheme.colors.primary
} else {
AppTheme.colors.onSurface
}
)
)
}
}
}
}
private fun formatLyricTime(millis: Long): String {
val totalSeconds = (millis / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
@Composable
private fun PlainLyricsContent(
plainLyrics: String) {
if (plainLyrics.isBlank()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(Res.string.lyrics_no_plain),
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
return
}
LazyColumn {
item { item {
Text( Text(
text = plainLyrics, text = plainLyrics,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
style = AppTheme.typography.titleMedium.copy(lineHeight = 34.sp), .fillMaxWidth()
color = MillenniumTheme.colors.text, .padding(horizontal = 16.dp),
style = AppTheme.typography.bodyLarge.copy(lineHeight = 30.sp),
color = AppTheme.colors.onSurface,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }

View File

@ -25,16 +25,13 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.milliseconds
@Serializable @Serializable
enum class LyricType { enum class LyricType {
@ -47,32 +44,22 @@ data class LyricsUiState(
val syncedLyrics: List<LyricsLine>? = null, val syncedLyrics: List<LyricsLine>? = null,
val plainLyrics: String? = null, val plainLyrics: String? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null)
val currentIndex: Int = -1,
val trackTitle: String = "",
val trackArtists: String = "",
val coverUrl: String = "")
class LyricsViewModel( class LyricsViewModel(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() {
private val _uiState = MutableStateFlow(LyricsUiState()) private val _uiState = MutableStateFlow(LyricsUiState())
val uiState: StateFlow<LyricsUiState> = _uiState.asStateFlow()
// Only re-emits when the active line changes, not on every position tick.
private val currentIndexFlow = combine(
_uiState.map { it.syncedLyrics }.distinctUntilChanged(),
audioPlayer.positionFlow
) { lyrics, position -> activeLineIndex(lyrics, position.inWholeMilliseconds) }
.distinctUntilChanged()
val uiState: StateFlow<LyricsUiState> = combine(_uiState, currentIndexFlow) { state, index ->
state.copy(currentIndex = index)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LyricsUiState())
private var currentTrackId: String? = null private var currentTrackId: String? = null
val positionMillisFlow: StateFlow<Long> = audioPlayer.positionFlow
.map { it.inWholeMilliseconds }
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), 0L)
fun setMode(mode: LyricType) { fun setMode(mode: LyricType) {
_uiState.update { it.copy(mode = mode) } _uiState.update { it.copy(mode = mode) }
} }
@ -119,17 +106,6 @@ class LyricsViewModel(
is QueueEntry.StreamingTrack -> entry.track.id is QueueEntry.StreamingTrack -> entry.track.id
else -> null else -> null
} }
if (entry is QueueEntry.StreamingTrack) {
val track = entry.track
_uiState.update {
it.copy(
trackTitle = track.title,
trackArtists = track.artists.joinToString(", ") { artist -> artist.name },
coverUrl = (track.album?.thumbnails ?: track.thumbnails)
?.firstOrNull()?.url.orEmpty()
)
}
}
if (trackId != null && trackId != currentTrackId) { if (trackId != null && trackId != currentTrackId) {
loadLyrics() loadLyrics()
} }
@ -137,15 +113,17 @@ class LyricsViewModel(
} }
} }
fun seekToLine(line: LyricsLine) { fun currentLyricIndex(positionMillis: Long): Int {
viewModelScope.launch { val lyrics = _uiState.value.syncedLyrics ?: emptyList()
audioPlayer.seekTo(line.time.milliseconds) if (lyrics.isEmpty()) return -1
var lastIndex = 0
for (i in lyrics.indices) {
if (lyrics[i].time <= positionMillis) {
lastIndex = i
} else {
break
}
} }
return lastIndex
} }
}
private fun activeLineIndex(lyrics: List<LyricsLine>?, positionMillis: Long): Int {
if (lyrics.isNullOrEmpty()) return -1
val index = lyrics.indexOfLast { it.time <= positionMillis }
return index.coerceAtLeast(0)
}
}

View File

@ -1,196 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.lyrics
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import org.spotube.millennium.theme.MillenniumTheme
private const val LYRICS_BLANK_LINE = "\u266A"
/**
* Time-synced lyrics list that keeps the active line vertically centered.
*
* With [onLineClick] it renders Millennium list tiles (the active one is the selected tile);
* without it, it renders a passive compact preview.
*/
@Composable
fun SyncedLyricsList(
lines: List<LyricsLine>,
currentIndex: Int,
modifier: Modifier = Modifier,
onLineClick: ((LyricsLine) -> Unit)? = null
) {
val listState = rememberLazyListState()
val colors = MillenniumTheme.colors
BoxWithConstraints(modifier = modifier) {
// Half-viewport padding lets the first/last lines reach the center too.
val edgePadding = maxHeight / 2
LaunchedEffect(currentIndex, lines.size) {
if (currentIndex !in lines.indices) return@LaunchedEffect
val visible = listState.layoutInfo.visibleItemsInfo
val itemHeight = visible.firstOrNull { it.index == currentIndex }?.size
?: visible.firstOrNull()?.size
?: 0
listState.animateScrollToItem(index = currentIndex, scrollOffset = itemHeight / 2)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().then(
if (onLineClick != null) Modifier.fadingEdges(maxHeight * 0.18f) else Modifier
),
contentPadding = PaddingValues(vertical = edgePadding),
verticalArrangement = Arrangement.spacedBy(if (onLineClick != null) 4.dp else 2.dp)
) {
itemsIndexed(lines, key = { index, line -> "$index-${line.time}" }) { index, line ->
val isCurrent = index == currentIndex
val text = line.text.ifBlank { LYRICS_BLANK_LINE }
val color by animateColorAsState(
when {
isCurrent -> colors.accent
index < currentIndex -> colors.muted.copy(alpha = 0.55f)
else -> colors.muted
}
)
if (onLineClick != null) {
ImmersiveLyricLine(
text = text,
distance = kotlin.math.abs(index - currentIndex),
isCurrent = isCurrent,
onClick = { onLineClick(line) }
)
} else {
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
style = AppTheme.typography.bodyMedium.copy(
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal,
color = color
),
textAlign = TextAlign.Center,
maxLines = 2
)
}
}
}
}
}
@Composable
private fun ImmersiveLyricLine(
text: String,
distance: Int,
isCurrent: Boolean,
onClick: () -> Unit
) {
val colors = MillenniumTheme.colors
val interaction = remember { MutableInteractionSource() }
val hovered by interaction.collectIsHoveredAsState()
val targetAlpha = when (distance) {
0 -> 1f
1 -> 0.7f
2 -> 0.5f
else -> 0.32f
}
val alpha by animateFloatAsState(targetAlpha, tween(300))
val scale by animateFloatAsState(if (isCurrent) 1f else 0.9f, tween(300))
val color by animateColorAsState(if (isCurrent) colors.accent else colors.text, tween(300))
val hoverBackground by animateColorAsState(
if (hovered && !isCurrent) colors.accent.copy(alpha = 0.10f) else Color.Transparent
)
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = if (hovered) maxOf(alpha, 0.85f) else alpha
}
.clip(RoundedCornerShape(14.dp))
.background(hoverBackground)
.hoverable(interaction)
.pointerHoverIcon(PointerIcon.Hand)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
style = AppTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold,
color = color
),
textAlign = TextAlign.Center
)
}
/** Fades the top and bottom [fade] of the content to transparent. */
private fun Modifier.fadingEdges(fade: Dp): Modifier = this
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
val fraction = (fade.toPx() / size.height).coerceIn(0f, 0.5f)
drawRect(
brush = Brush.verticalGradient(
0f to Color.Transparent,
fraction to Color.Black,
1f - fraction to Color.Black,
1f to Color.Transparent
),
blendMode = BlendMode.DstIn
)
}

View File

@ -18,15 +18,13 @@
package dev.krtirtho.spotube.modules.playlist package dev.krtirtho.spotube.modules.playlist
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -171,7 +169,7 @@ fun AddTracksToPlaylistDialog(
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
MillenniumCircularProgress(size = 40.dp) MillenniumIndeterminateProgress()
} }
} }
hasSearched && searchResults.isEmpty() -> { hasSearched && searchResults.isEmpty() -> {
@ -211,23 +209,33 @@ fun AddTracksToPlaylistDialog(
} }
} }
val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) if (isLargeScreen) {
AdaptiveDialogBottomSheet( MillenniumDialog(
onDismiss = onDismiss, visible = true,
modifier = modifier, onDismissRequest = onDismiss,
dialogTitle = dialogTitle, title = stringResource(Res.string.playlist_add_tracks_title),
title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, modifier = modifier) {
actions = { Box(modifier = Modifier.fillMaxWidth().height(500.dp)) {
Row( content()
modifier = Modifier.fillMaxWidth(), }
horizontalArrangement = Arrangement.End) { MillenniumButton(onClick = onDismiss) {
MillenniumButton(onClick = onDismiss) { Text(stringResource(Res.string.playlist_add_tracks_done))
Text(stringResource(Res.string.playlist_add_tracks_done)) }
} }
} else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
MillenniumModalBottomSheet(state = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(600.dp)) {
Text(
text = stringResource(Res.string.playlist_add_tracks_title),
style = AppTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp))
content()
} }
}) {
Box(modifier = Modifier.fillMaxWidth().height(500.dp)) {
content()
} }
} }
} }
@ -289,7 +297,8 @@ private fun TrackSearchResultRow(
modifier = Modifier.size(24.dp)) modifier = Modifier.size(24.dp))
} }
isAdding -> { isAdding -> {
MillenniumSpokeSpinner(size = 24.dp) MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
} }
else -> { else -> {
MillenniumButton( MillenniumButton(

View File

@ -1,80 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.di.injectLogger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
data class MetadataAuthUiState(
/** True when the selected metadata plugin needs an account and the user isn't signed in. */
val loginRequired: Boolean = false,
val pluginName: String? = null)
/**
* Tells screens whether the selected metadata plugin is waiting for a login, so they can show a
* friendly sign-in prompt instead of surfacing the plugin's raw authorization exception.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MetadataAuthViewModel(
private val pluginManager: PluginManager) : ViewModel(), KoinComponent {
private val logger by injectLogger<MetadataAuthViewModel>()
val uiState: StateFlow<MetadataAuthUiState> = combine(
pluginManager.selectedMetadataPlugin.flatMapLatest { service ->
if (service == null) flowOf(false) else loginRequiredFlow(service)
},
pluginManager.state) { loginRequired, managerState ->
val name = (managerState as? PluginManagerStates.Data)
?.selectedPlugins?.get(PluginAbility.METADATA)?.name
MetadataAuthUiState(loginRequired = loginRequired, pluginName = name)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState())
private fun loginRequiredFlow(service: dev.krtirtho.spotube.core.zipline.PluginService): Flow<Boolean> = flow {
val requiresAuth = try {
service.use { coreAPI.requiresAuthentication }
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
if (!requiresAuth) {
emit(false)
} else {
service.loggedInFlow.collect { emit(!it) }
}
}
fun login() {
val service = pluginManager.selectedMetadataPlugin.value ?: return
pluginManager.launchTask {
runCatching { service.use { coreAPI.login() } }
.onFailure { logger.e(it) { "Metadata plugin login failed" } }
}
}
}

View File

@ -18,15 +18,13 @@
package dev.krtirtho.spotube.modules.plugin package dev.krtirtho.spotube.modules.plugin
import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumDotsLoader
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumSegmentedButton import org.spotube.millennium.composables.MillenniumSegmentedButton
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
@ -87,22 +85,6 @@ import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid
import dev.krtirtho.spotube.modules.plugin.components.PluginCard import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginDialogActions
import dev.krtirtho.spotube.modules.plugin.components.PluginDialogBody
import dev.krtirtho.spotube.modules.plugin.components.PluginDialogTitle
import dev.krtirtho.spotube.modules.plugin.components.PluginIconTile
import dev.krtirtho.spotube.modules.plugin.components.PluginLogoTile
import dev.krtirtho.spotube.modules.plugin.components.PluginPanel
import dev.krtirtho.spotube.modules.plugin.components.PluginPill
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.User
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import spotube.composeapp.generated.resources.plugin_install_sheet_subtitle
import spotube.composeapp.generated.resources.plugin_install_file_hint
import spotube.composeapp.generated.resources.plugin_install_or
import spotube.composeapp.generated.resources.plugin_info_dialog_title
import spotube.composeapp.generated.resources.plugin_version_label
import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
import dev.krtirtho.spotube.modules.plugin.components.PluginLogo import dev.krtirtho.spotube.modules.plugin.components.PluginLogo
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
@ -285,7 +267,7 @@ internal fun PluginScreenContent(
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { MillenniumCircularProgress(size = 40.dp) } ) { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) }
} }
is PluginUiState.Data -> { is PluginUiState.Data -> {
@ -510,7 +492,7 @@ internal fun PluginScreenContent(
Modifier.fillMaxWidth().padding(16.dp), Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
MillenniumDotsLoader() MillenniumIndeterminateProgress(Modifier.widthIn(max = 120.dp))
} }
} }
} }
@ -542,7 +524,7 @@ internal fun PluginScreenContent(
.padding(vertical = 16.dp), .padding(vertical = 16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
MillenniumDotsLoader() MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 120.dp))
} }
} }
} }
@ -624,17 +606,24 @@ private fun InstallSheet(
AdaptiveDialogBottomSheet( AdaptiveDialogBottomSheet(
onDismiss = onDismiss, onDismiss = onDismiss,
title = { title = {
PluginDialogTitle( Row(
title = stringResource(Res.string.plugin_install_section_title), verticalAlignment = Alignment.CenterVertically,
icon = Iconsax.IconsaxBoxAdd) horizontalArrangement = Arrangement.spacedBy(8.dp)
}, ) {
dialogTitle = stringResource(Res.string.plugin_install_section_title)) { MillenniumIcon(
PluginDialogBody { Iconsax.IconsaxImportArrow2Bulk,
Text( contentDescription = null,
stringResource(Res.string.plugin_install_sheet_subtitle), modifier = Modifier.size(18.dp),
style = AppTheme.typography.bodySmall, tint = AppTheme.colors.primary
color = AppTheme.colors.onSurfaceVariant) )
Text(
stringResource(Res.string.plugin_install_section_title),
style = AppTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
}) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val urlState = rememberTextFieldState(urlInput) val urlState = rememberTextFieldState(urlInput)
LaunchedEffect(urlInput) { LaunchedEffect(urlInput) {
if (urlState.text.toString() != urlInput) { if (urlState.text.toString() != urlInput) {
@ -647,82 +636,51 @@ private fun InstallSheet(
} }
} }
PluginPanel { Row(
Column( modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.spacedBy(8.dp)
Row( ) {
verticalAlignment = Alignment.CenterVertically, MillenniumTextField(
horizontalArrangement = Arrangement.spacedBy(12.dp)) { state = urlState,
PluginIconTile(Iconsax.IconsaxLink) label = stringResource(Res.string.plugin_section_url_title),
Text( modifier = Modifier.weight(1f),
stringResource(Res.string.plugin_section_url_title), placeholder = stringResource(Res.string.plugin_url_placeholder),
style = AppTheme.typography.titleSmall, error = urlError?.text())
fontWeight = FontWeight.SemiBold) MillenniumIconButton(
} onClick = onSubmitUrl,
MillenniumTextField( enabled = !isLoadingUrl,
state = urlState, style = MillenniumIconButtonStyle.Secondary) {
label = "", if (isLoadingUrl) {
modifier = Modifier.fillMaxWidth(), MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp))
placeholder = stringResource(Res.string.plugin_url_placeholder), } else {
error = urlError?.text()) MillenniumIcon(
MillenniumButton( Iconsax.IconsaxImportArrow2Bulk,
onClick = onSubmitUrl, contentDescription = stringResource(Res.string.plugin_action_download))
enabled = !isLoadingUrl,
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Primary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (isLoadingUrl) {
MillenniumSpokeSpinner(size = 16.dp, color = MillenniumTheme.colors.onAccent)
} else {
MillenniumIcon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(18.dp))
}
Text(stringResource(Res.string.plugin_action_download))
}
} }
} }
} }
Row( MillenniumSeparator()
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumSeparator(modifier = Modifier.weight(1f))
Text(
stringResource(Res.string.plugin_install_or),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
MillenniumSeparator(modifier = Modifier.weight(1f))
}
PluginPanel(modifier = Modifier.clickable(onClick = onPickFile)) { Text(
stringResource(Res.string.plugin_section_file_title),
style = AppTheme.typography.labelLarge,
color = AppTheme.colors.onSurfaceVariant
)
MillenniumButton(
onClick = onPickFile,
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) { horizontalArrangement = Arrangement.spacedBy(8.dp)
PluginIconTile(Iconsax.IconsaxExportArrowBulk) ) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
stringResource(Res.string.plugin_action_install_from_file),
style = AppTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold)
Text(
stringResource(Res.string.plugin_install_file_hint),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
}
MillenniumIcon( MillenniumIcon(
FeatherIcons.ChevronRight, Iconsax.IconsaxExportArrowBulk,
contentDescription = null, contentDescription = stringResource(Res.string.plugin_action_install_from_file)
modifier = Modifier.size(18.dp), )
tint = AppTheme.colors.onSurfaceVariant) Text(stringResource(Res.string.plugin_action_install_from_file))
} }
} }
} }
@ -734,82 +692,84 @@ private fun PluginInfoDialog(
plugin: PluginEntry, plugin: PluginEntry,
logoPath: Path?, logoPath: Path?,
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
AdaptiveDialogBottomSheet( MillenniumDialog(
onDismiss = onDismiss, visible = true,
title = { PluginDialogTitle(stringResource(Res.string.plugin_info_dialog_title)) }, onDismissRequest = onDismiss,
dialogTitle = stringResource(Res.string.plugin_info_dialog_title), title = plugin.name) {
actions = { Box(
PluginDialogActions( modifier = Modifier
secondaryLabel = stringResource(Res.string.common_close), .size(48.dp)
onSecondary = onDismiss) .clip(RoundedCornerShape(12.dp))
}) { .background(AppTheme.colors.primary.copy(alpha = 0.12f))
PluginDialogBody { ) {
PluginPanel { PluginLogo(
Row( plugin = plugin,
modifier = Modifier.fillMaxWidth().padding(14.dp), logoPath = logoPath,
verticalAlignment = Alignment.CenterVertically, contentDescription = plugin.name,
horizontalArrangement = Arrangement.spacedBy(14.dp)) { modifier = Modifier.fillMaxSize(),
PluginLogoTile(plugin = plugin, logoPath = logoPath) fallback = {
Column( Box(contentAlignment = Alignment.Center) {
modifier = Modifier.weight(1f), MillenniumIcon(
verticalArrangement = Arrangement.spacedBy(6.dp)) { Iconsax.IconsaxBox,
Text( contentDescription = null,
plugin.name, modifier = Modifier.size(24.dp),
style = AppTheme.typography.titleMedium, tint = AppTheme.colors.primary
fontWeight = FontWeight.SemiBold, )
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PluginPill(plugin.author, icon = Iconsax.User)
PluginPill(
stringResource(Res.string.plugin_version_label, plugin.version),
tint = AppTheme.colors.primary)
}
} }
} })
if (plugin.description.isNotBlank()) { }
Text( Column(
plugin.description, verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), modifier = Modifier.fillMaxWidth()
style = AppTheme.typography.bodySmall, ) {
color = AppTheme.colors.onSurfaceVariant) if (plugin.description.isNotBlank()) {
Text(
plugin.description,
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurface
)
}
DetailRow(stringResource(Res.string.plugin_info_version), plugin.version)
DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion)
DetailRow(stringResource(Res.string.plugin_info_author), plugin.author)
if (plugin.license.isNotBlank()) {
DetailRow(stringResource(Res.string.plugin_info_license), plugin.license)
}
if (plugin.capabilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_capabilities),
plugin.capabilities.map { it.name })
}
if (plugin.abilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_abilities),
plugin.abilities.map { ability -> ability.displayLabel() })
}
if (plugin.repository.isNotBlank()) {
ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) {
openUrlInBrowser(plugin.repository)
} }
} }
PluginPanel { if (plugin.contact.isNotBlank()) {
Column( DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact)
modifier = Modifier.fillMaxWidth().padding(14.dp), }
verticalArrangement = Arrangement.spacedBy(10.dp)) {
DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion) if (plugin.bugs.isNotBlank()) {
if (plugin.license.isNotBlank()) { ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) {
DetailRow(stringResource(Res.string.plugin_info_license), plugin.license) openUrlInBrowser(plugin.bugs)
}
if (plugin.capabilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_capabilities),
plugin.capabilities.map { it.name })
}
if (plugin.abilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_abilities),
plugin.abilities.map { ability -> ability.displayLabel() })
}
if (plugin.repository.isNotBlank()) {
ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) {
openUrlInBrowser(plugin.repository)
}
}
if (plugin.contact.isNotBlank()) {
DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact)
}
if (plugin.bugs.isNotBlank()) {
ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) {
openUrlInBrowser(plugin.bugs)
}
}
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(onClick = onDismiss) {
Text(stringResource(Res.string.common_close))
}
}
} }
} }
@ -819,30 +779,27 @@ private fun SupportDialog(
supportText: String?, supportText: String?,
isLoading: Boolean, isLoading: Boolean,
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
AdaptiveDialogBottomSheet( MillenniumDialog(
onDismiss = onDismiss, visible = true,
title = { PluginDialogTitle(stringResource(Res.string.plugin_support_dialog_title, plugin.name)) }, onDismissRequest = onDismiss,
dialogTitle = stringResource(Res.string.plugin_support_dialog_title, plugin.name), title = stringResource(Res.string.plugin_support_dialog_title, plugin.name)) {
actions = { if (isLoading) {
PluginDialogActions( Box(
secondaryLabel = stringResource(Res.string.common_close), modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
onSecondary = onDismiss) contentAlignment = Alignment.Center
}) { ) {
PluginDialogBody { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp))
if (isLoading) { }
Box( } else {
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), Text(
contentAlignment = Alignment.Center) { supportText ?: "",
MillenniumCircularProgress(size = 36.dp) style = AppTheme.typography.bodyMedium,
} color = AppTheme.colors.onSurface
} else { )
PluginPanel { }
Text( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
supportText ?: "", MillenniumButton(onClick = onDismiss) {
modifier = Modifier.padding(14.dp), Text(stringResource(Res.string.common_close))
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurface)
}
} }
} }
} }
@ -1015,7 +972,7 @@ private fun DiscoverRepoCard(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (isInstalling) { if (isInstalling) {
MillenniumSpokeSpinner(size = 16.dp) MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp))
} else { } else {
MillenniumIcon( MillenniumIcon(
Iconsax.IconsaxAdd, Iconsax.IconsaxAdd,

View File

@ -1,249 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import okio.Path
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
// Shared building blocks for the plugin dialogs/sheets so they all read as one family and
// match the panel/tile language used by the plugin cards and the rest of the app.
/** Body column for plugin dialogs; the adaptive dialog/sheet around it handles scrolling. */
@Composable
internal fun PluginDialogBody(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
content = content)
}
/** Title for the phone bottom sheet; the desktop dialog shows the same text in its titlebar. */
@Composable
internal fun PluginDialogTitle(
title: String,
icon: ImageVector? = null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (icon != null) {
PluginIconTile(icon, size = 40.dp)
}
Text(
title,
style = AppTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface)
}
}
/** Rounded, softly bordered panel — the same surface the plugin cards use. */
@Composable
internal fun PluginPanel(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = modifier
.fillMaxWidth()
.millenniumSurface(
background = millenniumPanelBrush(MillenniumTheme.colors),
shape = RoundedCornerShape(14.dp),
borderColor = AppTheme.colors.outlineVariant,
borderWidth = 1.dp),
content = content)
}
@Composable
internal fun PluginIconTile(
icon: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 38.dp,
tint: Color = AppTheme.colors.primary) {
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(size / 3.4f))
.background(tint.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center) {
MillenniumIcon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(size * 0.5f),
tint = tint)
}
}
@Composable
internal fun PluginLogoTile(
plugin: PluginEntry,
logoPath: Path?,
size: Dp = 56.dp) {
Box(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(size / 3.4f))
.background(AppTheme.colors.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center) {
PluginLogo(
plugin = plugin,
logoPath = logoPath,
contentDescription = plugin.name,
modifier = Modifier.fillMaxSize(),
fallback = {
MillenniumIcon(
imageVector = Iconsax.IconsaxBoxAdd,
contentDescription = null,
tint = AppTheme.colors.primary,
modifier = Modifier.size(size * 0.5f))
})
}
}
@Composable
internal fun PluginPill(
text: String,
icon: ImageVector? = null,
tint: Color = AppTheme.colors.onSurfaceVariant) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(tint.copy(alpha = 0.12f))
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (icon != null) {
MillenniumIcon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = tint)
}
Text(
text,
style = AppTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = tint,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
}
/** Tinted info strip; [isWarning] switches it to the error tone. */
@Composable
internal fun PluginCallout(text: String, isWarning: Boolean = false) {
val tint = if (isWarning) AppTheme.colors.error else AppTheme.colors.primary
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(tint.copy(alpha = 0.10f))
.padding(12.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumIcon(
imageVector = Iconsax.IconsaxInformation,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = tint)
Text(
text,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurface)
}
}
@Composable
internal fun PluginSectionLabel(text: String, badge: String? = null) {
Row(
modifier = Modifier.padding(horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text,
style = AppTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface)
if (badge != null) {
PluginPill(badge, tint = AppTheme.colors.primary)
}
}
}
/** Equal-width secondary + primary action pair. */
@Composable
internal fun PluginDialogActions(
secondaryLabel: String,
onSecondary: () -> Unit,
primaryLabel: String? = null,
onPrimary: (() -> Unit)? = null,
primaryEnabled: Boolean = true) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(
onClick = onSecondary,
modifier = Modifier.weight(1f),
style = if (primaryLabel != null) MillenniumButtonStyle.Secondary else MillenniumButtonStyle.Primary) {
Text(secondaryLabel)
}
if (primaryLabel != null && onPrimary != null) {
MillenniumButton(
onClick = onPrimary,
enabled = primaryEnabled,
modifier = Modifier.weight(1f),
style = MillenniumButtonStyle.Primary) {
Text(primaryLabel)
}
}
}
}

View File

@ -17,7 +17,7 @@
package dev.krtirtho.spotube.modules.plugin.components package dev.krtirtho.spotube.modules.plugin.components
import org.spotube.millennium.composables.MillenniumCircularProgress import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -32,6 +32,10 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -48,22 +52,19 @@ import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.crossfade import coil3.request.crossfade
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.plugin.GitHubRelease
import dev.krtirtho.spotube.modules.plugin.GitHubRepo import dev.krtirtho.spotube.modules.plugin.GitHubRepo
import dev.krtirtho.spotube.modules.plugin.GitHubRelease
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumDropdownMenu
import org.spotube.millennium.composables.MillenniumDropdownMenuContent
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_cancel
import spotube.composeapp.generated.resources.plugin_action_install import spotube.composeapp.generated.resources.plugin_action_install
import spotube.composeapp.generated.resources.plugin_install_no_releases import spotube.composeapp.generated.resources.plugin_install_no_releases
import spotube.composeapp.generated.resources.plugin_install_prerelease import spotube.composeapp.generated.resources.plugin_install_prerelease
@ -87,24 +88,21 @@ fun PluginInstallDialog(
} }
} }
AdaptiveDialogBottomSheet( MillenniumDialog(
onDismiss = onDismiss, visible = true,
title = { PluginDialogTitle(stringResource(Res.string.plugin_action_install)) }, onDismissRequest = onDismiss,
dialogTitle = stringResource(Res.string.plugin_action_install), title = repo.fullName) {
actions = { Row(
PluginDialogActions( verticalAlignment = Alignment.CenterVertically,
secondaryLabel = stringResource(Res.string.common_cancel), horizontalArrangement = Arrangement.spacedBy(12.dp)
onSecondary = onDismiss, ) {
primaryLabel = stringResource(Res.string.plugin_action_install), Box(
onPrimary = { selectedRelease?.let(onInstall) }, modifier = Modifier
primaryEnabled = selectedRelease != null && !isLoadingReleases) .size(48.dp)
}) { .clip(RoundedCornerShape(12.dp))
PluginDialogBody { .background(AppTheme.colors.primary.copy(alpha = 0.12f))
PluginPanel { ) {
Row( Box(contentAlignment = Alignment.Center) {
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
val platformContext = LocalPlatformContext.current val platformContext = LocalPlatformContext.current
AsyncImage( AsyncImage(
model = ImageRequest.Builder(platformContext) model = ImageRequest.Builder(platformContext)
@ -112,100 +110,132 @@ fun PluginInstallDialog(
.crossfade(true) .crossfade(true)
.build(), .build(),
contentDescription = repo.owner.login, contentDescription = repo.owner.login,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp))) modifier = Modifier.fillMaxSize()
Column( )
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
repo.fullName,
style = AppTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
if (!repo.description.isNullOrBlank()) {
Text(
repo.description,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis)
}
}
} }
} }
Column(modifier = Modifier.weight(1f)) {
if (isLoadingReleases) { Text(
Box( repo.fullName,
modifier = Modifier.fillMaxWidth().height(96.dp), style = AppTheme.typography.titleMedium,
contentAlignment = Alignment.Center) { fontWeight = FontWeight.SemiBold,
MillenniumCircularProgress(size = 36.dp) maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!repo.description.isNullOrBlank()) {
Text(
repo.description,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
} }
} else if (releases.isEmpty()) { }
PluginCallout(stringResource(Res.string.plugin_install_no_releases), isWarning = true) }
} else {
PluginSectionLabel(stringResource(Res.string.plugin_install_select_release_label)) if (isLoadingReleases) {
MillenniumDropdownMenu( Box(
expanded = expanded, modifier = Modifier.fillMaxWidth().height(120.dp),
onExpandedChange = { expanded = it }, contentAlignment = Alignment.Center
modifier = Modifier.fillMaxWidth(), ) {
anchor = { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp))
MillenniumButton( }
onClick = { expanded = true }, } else if (releases.isEmpty()) {
Text(
stringResource(Res.string.plugin_install_no_releases),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant
)
} else {
Text(
stringResource(Res.string.plugin_install_select_release_label),
style = AppTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface
)
MillenniumDropdownMenu(
expanded = expanded,
onExpandedChange = { expanded = it },
items = releases.map { it.tagName },
onItemClick = { tagName ->
selectedRelease = releases.firstOrNull { it.tagName == tagName }
expanded = false
},
modifier = Modifier.fillMaxWidth(),
anchor = {
MillenniumButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary
) {
Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) { verticalAlignment = Alignment.CenterVertically,
Row( horizontalArrangement = Arrangement.SpaceBetween
modifier = Modifier.fillMaxWidth(), ) {
verticalAlignment = Alignment.CenterVertically, Text(
horizontalArrangement = Arrangement.SpaceBetween) { selectedRelease?.tagName
Text( ?: stringResource(Res.string.plugin_install_select_release_placeholder),
selectedRelease?.tagName style = AppTheme.typography.bodyMedium
?: stringResource(Res.string.plugin_install_select_release_placeholder), )
style = AppTheme.typography.bodyMedium) MillenniumIcon(
MillenniumIcon( imageVector = Iconsax.IconsaxArrowDown4,
imageVector = Iconsax.IconsaxArrowDown4, contentDescription = null,
contentDescription = null, modifier = Modifier.size(16.dp)
modifier = Modifier.size(16.dp)) )
}
}
}) {
MillenniumDropdownMenuContent {
releases.forEach { release ->
MillenniumDropdownMenuItem(
label = release.tagName,
selected = release.tagName == selectedRelease?.tagName,
description = if (release.prerelease) {
stringResource(Res.string.plugin_install_prerelease)
} else null,
onClick = {
selectedRelease = release
expanded = false
})
} }
} }
} }
)
selectedRelease?.let { release -> selectedRelease?.let { release ->
if (release.prerelease) { if (release.prerelease) {
PluginCallout(stringResource(Res.string.plugin_install_prerelease), isWarning = true) Text(
} stringResource(Res.string.plugin_install_prerelease),
if (!release.body.isNullOrBlank()) { style = AppTheme.typography.labelSmall,
PluginSectionLabel(stringResource(Res.string.plugin_install_release_notes)) color = AppTheme.colors.error
PluginPanel { )
Column( }
modifier = Modifier if (!release.body.isNullOrBlank()) {
.fillMaxWidth() Text(
.heightIn(max = 200.dp) stringResource(Res.string.plugin_install_release_notes),
.verticalScroll(rememberScrollState()) style = AppTheme.typography.labelMedium,
.padding(12.dp)) { fontWeight = FontWeight.SemiBold,
Text( color = AppTheme.colors.onSurface
release.body, )
style = AppTheme.typography.bodySmall, Box(
color = AppTheme.colors.onSurfaceVariant) modifier = Modifier
} .fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp)
) {
Text(
release.body,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant
)
} }
} }
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(
onClick = { selectedRelease?.let { onInstall(it) } },
enabled = selectedRelease != null && !isLoadingReleases
) {
Text(stringResource(Res.string.plugin_action_install))
}
}
} }
} }

View File

@ -17,39 +17,47 @@
package dev.krtirtho.spotube.modules.plugin.components package dev.krtirtho.spotube.modules.plugin.components
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.composeunstyled.Text import okio.Path
import compose.icons.FeatherIcons
import compose.icons.feathericons.ArrowRight
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.plugin.PluginCapability import dev.krtirtho.spotube.modules.plugin.PluginCapability
import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxEye import dev.krtirtho.spotube.resources.iconsax.IconsaxEye
import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare
import dev.krtirtho.spotube.resources.iconsax.PhosphorDatabaseDuotone import dev.krtirtho.spotube.resources.iconsax.PhosphorDatabaseDuotone
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import okio.Path
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumSeparator
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_cancel
import spotube.composeapp.generated.resources.common_close
import spotube.composeapp.generated.resources.plugin_permissions_api_diff import spotube.composeapp.generated.resources.plugin_permissions_api_diff
import spotube.composeapp.generated.resources.plugin_permissions_author_version
import spotube.composeapp.generated.resources.plugin_permissions_capability_network_desc import spotube.composeapp.generated.resources.plugin_permissions_capability_network_desc
import spotube.composeapp.generated.resources.plugin_permissions_capability_network_title import spotube.composeapp.generated.resources.plugin_permissions_capability_network_title
import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_desc import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_desc
@ -62,6 +70,8 @@ import spotube.composeapp.generated.resources.plugin_permissions_none
import spotube.composeapp.generated.resources.plugin_permissions_requested_title import spotube.composeapp.generated.resources.plugin_permissions_requested_title
import spotube.composeapp.generated.resources.plugin_permissions_supplied_label import spotube.composeapp.generated.resources.plugin_permissions_supplied_label
import spotube.composeapp.generated.resources.plugin_version_label import spotube.composeapp.generated.resources.plugin_version_label
import spotube.composeapp.generated.resources.common_cancel
import spotube.composeapp.generated.resources.common_close
@Composable @Composable
fun PluginPermissionDialog( fun PluginPermissionDialog(
@ -73,80 +83,129 @@ fun PluginPermissionDialog(
logoPath: Path? = null, logoPath: Path? = null,
onConfirm: (() -> Unit)? = null, onConfirm: (() -> Unit)? = null,
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
AdaptiveDialogBottomSheet( MillenniumDialog(
onDismiss = onDismiss, visible = true,
title = { PluginDialogTitle(title) }, onDismissRequest = onDismiss,
dialogTitle = title, title = title) {
actions = { Row(
PluginDialogActions( verticalAlignment = Alignment.CenterVertically,
secondaryLabel = stringResource( horizontalArrangement = Arrangement.spacedBy(14.dp)
if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), ) {
onSecondary = onDismiss, Box(
primaryLabel = confirmLabel, modifier = Modifier
onPrimary = onConfirm) .size(48.dp)
}) { .clip(RoundedCornerShape(12.dp))
PluginDialogBody { .background(AppTheme.colors.primary.copy(alpha = 0.12f)),
PluginPanel { contentAlignment = Alignment.Center
Row( ) {
modifier = Modifier.fillMaxWidth().padding(14.dp), PluginLogo(
verticalAlignment = Alignment.CenterVertically, plugin = pluginInfo,
horizontalArrangement = Arrangement.spacedBy(14.dp)) { logoPath = logoPath,
PluginLogoTile(plugin = pluginInfo, logoPath = logoPath) contentDescription = pluginInfo.name,
Column( modifier = Modifier.fillMaxSize(),
modifier = Modifier.weight(1f), fallback = {
verticalArrangement = Arrangement.spacedBy(6.dp)) { MillenniumIcon(
Text( imageVector = Iconsax.IconsaxBoxAdd,
pluginInfo.name, contentDescription = null,
style = AppTheme.typography.titleMedium, tint = AppTheme.colors.primary,
fontWeight = FontWeight.SemiBold, modifier = Modifier.size(24.dp)
maxLines = 1, )
overflow = TextOverflow.Ellipsis) })
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PluginPill(pluginInfo.author, icon = Iconsax.User)
PluginPill(
stringResource(Res.string.plugin_version_label, pluginInfo.version),
tint = AppTheme.colors.primary)
}
}
}
if (pluginInfo.description.isNotBlank()) {
Text(
pluginInfo.description,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis)
}
} }
Column(modifier = Modifier.weight(1f)) {
PluginCallout(text = message, isWarning = confirmLabel == null) Text(
pluginInfo.name,
existingPlugin?.let { style = AppTheme.typography.titleMedium,
InstalledComparisonPanel(installedPlugin = it, incomingPlugin = pluginInfo) fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
} }
}
PluginSectionLabel( Row(
stringResource(Res.string.plugin_permissions_requested_title), verticalAlignment = Alignment.CenterVertically,
badge = pluginInfo.capabilities.size.takeIf { it > 0 }?.toString()) horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
MillenniumIcon(
Iconsax.User,
contentDescription = null,
modifier = Modifier.size(13.dp),
tint = AppTheme.colors.onSurfaceVariant
)
Text(
pluginInfo.author,
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
Text(
stringResource(
Res.string.plugin_permissions_author_version,
stringResource(Res.string.plugin_version_label, pluginInfo.version)
),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
}
PluginPanel { if (pluginInfo.description.isNotBlank()) {
if (pluginInfo.capabilities.isEmpty()) { Text(
Row( pluginInfo.description,
modifier = Modifier.fillMaxWidth().padding(14.dp), style = AppTheme.typography.bodySmall,
verticalAlignment = Alignment.CenterVertically, color = AppTheme.colors.onSurfaceVariant,
horizontalArrangement = Arrangement.spacedBy(12.dp)) { maxLines = 3,
PluginIconTile(Iconsax.IconsaxCheckCircle) overflow = TextOverflow.Ellipsis
Text( )
stringResource(Res.string.plugin_permissions_none), }
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) MillenniumCard(contentPadding = PaddingValues(14.dp)) {
} Text(
} else { message,
pluginInfo.capabilities.forEachIndexed { index, capability -> style = AppTheme.typography.bodySmall,
if (index > 0) MillenniumSeparator() color = AppTheme.colors.onSurface
CapabilityRow(capability) )
}
existingPlugin?.let {
InstalledComparisonCard(
installedPlugin = it,
incomingPlugin = pluginInfo
)
}
Text(
stringResource(Res.string.plugin_permissions_requested_title),
style = AppTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface
)
if (pluginInfo.capabilities.isEmpty()) {
Text(
stringResource(Res.string.plugin_permissions_none),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant
)
} else {
pluginInfo.capabilities.forEach { capability ->
CapabilityRow(capability)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) {
Text(
if (confirmLabel == null) {
stringResource(Res.string.common_close)
} else {
stringResource(Res.string.common_cancel)
} }
)
}
if (confirmLabel != null && onConfirm != null) {
MillenniumButton(onClick = onConfirm) {
Text(confirmLabel)
} }
} }
} }
@ -154,58 +213,57 @@ fun PluginPermissionDialog(
} }
@Composable @Composable
private fun InstalledComparisonPanel( private fun InstalledComparisonCard(
installedPlugin: PluginEntry, installedPlugin: PluginEntry,
incomingPlugin: PluginEntry) { incomingPlugin: PluginEntry) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { MillenniumCard(
PluginSectionLabel(stringResource(Res.string.plugin_permissions_compare_title)) modifier = Modifier.fillMaxWidth(),
PluginPanel { contentPadding = PaddingValues(14.dp)) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(14.dp), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)
Row( ) {
modifier = Modifier.fillMaxWidth(), Text(
verticalAlignment = Alignment.CenterVertically, stringResource(Res.string.plugin_permissions_compare_title),
horizontalArrangement = Arrangement.SpaceEvenly) { style = AppTheme.typography.labelMedium,
VersionStat( fontWeight = FontWeight.SemiBold)
label = stringResource(Res.string.plugin_permissions_installed_label), Row(
value = installedPlugin.version) modifier = Modifier.fillMaxWidth(),
MillenniumIcon( horizontalArrangement = Arrangement.SpaceBetween
imageVector = FeatherIcons.ArrowRight, ) {
contentDescription = null, VersionStat(
modifier = Modifier.size(18.dp), label = stringResource(Res.string.plugin_permissions_installed_label),
tint = AppTheme.colors.onSurfaceVariant) value = installedPlugin.version)
VersionStat( VersionStat(
label = stringResource(Res.string.plugin_permissions_supplied_label), label = stringResource(Res.string.plugin_permissions_supplied_label),
value = incomingPlugin.version, value = incomingPlugin.version)
highlighted = true)
}
Text(
stringResource(
Res.string.plugin_permissions_api_diff,
installedPlugin.apiVersion,
incomingPlugin.apiVersion),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
} }
Text(
stringResource(
Res.string.plugin_permissions_api_diff,
installedPlugin.apiVersion,
incomingPlugin.apiVersion
),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
} }
} }
} }
@Composable @Composable
private fun VersionStat(label: String, value: String, highlighted: Boolean = false) { private fun VersionStat(label: String, value: String) {
Column( Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
label, label,
style = AppTheme.typography.labelSmall, style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant
)
Text( Text(
stringResource(Res.string.plugin_version_label, value), stringResource(Res.string.plugin_version_label, value),
style = AppTheme.typography.titleSmall, style = AppTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Medium
color = if (highlighted) AppTheme.colors.primary else AppTheme.colors.onSurface) )
} }
} }
@ -215,31 +273,54 @@ private fun CapabilityRow(capability: PluginCapability) {
PluginCapability.PERSISTENT_STORAGE -> Triple( PluginCapability.PERSISTENT_STORAGE -> Triple(
Iconsax.PhosphorDatabaseDuotone, Iconsax.PhosphorDatabaseDuotone,
stringResource(Res.string.plugin_permissions_capability_storage_title), stringResource(Res.string.plugin_permissions_capability_storage_title),
stringResource(Res.string.plugin_permissions_capability_storage_desc)) stringResource(Res.string.plugin_permissions_capability_storage_desc)
)
PluginCapability.NETWORK_REQUESTS -> Triple( PluginCapability.NETWORK_REQUESTS -> Triple(
Iconsax.IconsaxWifiSquare, Iconsax.IconsaxWifiSquare,
stringResource(Res.string.plugin_permissions_capability_network_title), stringResource(Res.string.plugin_permissions_capability_network_title),
stringResource(Res.string.plugin_permissions_capability_network_desc)) stringResource(Res.string.plugin_permissions_capability_network_desc)
)
PluginCapability.WEBVIEW -> Triple( PluginCapability.WEBVIEW -> Triple(
Iconsax.IconsaxEye, Iconsax.IconsaxEye,
stringResource(Res.string.plugin_permissions_capability_webview_title), stringResource(Res.string.plugin_permissions_capability_webview_title),
stringResource(Res.string.plugin_permissions_capability_webview_desc)) stringResource(Res.string.plugin_permissions_capability_webview_desc)
)
} }
Row( MillenniumCard(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(12.dp)) {
horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(
PluginIconTile(icon) modifier = Modifier.fillMaxWidth(),
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { verticalAlignment = Alignment.CenterVertically,
Text( horizontalArrangement = Arrangement.spacedBy(14.dp)
label, ) {
style = AppTheme.typography.bodyMedium, Box(
fontWeight = FontWeight.Medium) modifier = Modifier
Text( .size(34.dp)
description, .clip(CircleShape)
style = AppTheme.typography.labelSmall, .background(AppTheme.colors.errorContainer.copy(alpha = 0.6f)),
color = AppTheme.colors.onSurfaceVariant) contentAlignment = Alignment.Center
) {
MillenniumIcon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = AppTheme.colors.error
)
}
Column {
Text(
label,
style = AppTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
description,
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
}
} }
} }
} }

View File

@ -1,269 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin.guard
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.LoginRequiredDisplay
import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.theme.MillenniumTheme
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.no_metadata_plugin_action_install
import spotube.composeapp.generated.resources.no_metadata_plugin_action_local
import spotube.composeapp.generated.resources.no_metadata_plugin_local_hint
import spotube.composeapp.generated.resources.no_metadata_plugin_action_select
import spotube.composeapp.generated.resources.no_metadata_plugin_description_install
import spotube.composeapp.generated.resources.no_metadata_plugin_description_select
import spotube.composeapp.generated.resources.no_metadata_plugin_step_discover
import spotube.composeapp.generated.resources.no_metadata_plugin_step_install
import spotube.composeapp.generated.resources.no_metadata_plugin_step_select
import spotube.composeapp.generated.resources.no_metadata_plugin_title
/**
* Renders [content] only while a metadata plugin is selected. Otherwise shows a guided
* "no plugin" state with a call to action leading to the plugin manager, instead of letting
* the screen sit on an endless loading skeleton.
*
* @param backButton whether the placeholder's top bar shows a back button (for pushed screens).
* @param requireLogin also swap [content] for a friendly sign-in prompt while the plugin needs an
* account and the user isn't signed in (for screens that only make sense when logged in).
* @param showTopBar false when the host screen already draws its own app bar (e.g. Library tabs).
*/
@Composable
fun MetadataPluginGuard(
backButton: Boolean = false,
showTopBar: Boolean = true,
requireLogin: Boolean = false,
viewModel: MetadataPluginGuardViewModel = koinViewModel(),
authViewModel: MetadataAuthViewModel = koinViewModel(),
content: @Composable () -> Unit
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val authState by authViewModel.uiState.collectAsStateWithLifecycle()
when (val s = state) {
is MetadataPluginGuardState.Available ->
if (requireLogin && authState.loginRequired) {
Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
if (showTopBar) ApplicationMainBar(backButton = backButton)
Box(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = LocalAppShellBottomInset.current),
contentAlignment = Alignment.Center) {
LoginRequiredDisplay(
pluginName = authState.pluginName,
onLogin = authViewModel::login,
modifier = Modifier.widthIn(max = 420.dp))
}
}
} else {
content()
}
is MetadataPluginGuardState.Loading -> Box(
Modifier.fillMaxSize().background(MillenniumTheme.colors.background))
is MetadataPluginGuardState.Missing -> NoMetadataPluginScreen(
hasInstalledPlugins = s.hasInstalledPlugins,
backButton = backButton,
showTopBar = showTopBar,
onOpenPlugins = viewModel::openPlugins,
onOpenLocalTracks = viewModel::openLocalTracks)
}
}
@Composable
private fun NoMetadataPluginScreen(
hasInstalledPlugins: Boolean,
backButton: Boolean,
showTopBar: Boolean,
onOpenPlugins: () -> Unit,
onOpenLocalTracks: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MillenniumTheme.colors.background)) {
if (showTopBar) ApplicationMainBar(backButton = backButton)
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = LocalAppShellBottomInset.current),
contentAlignment = Alignment.Center) {
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)) {
HeroBadge()
Text(
text = stringResource(Res.string.no_metadata_plugin_title),
style = AppTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface,
textAlign = TextAlign.Center)
Text(
text = stringResource(
if (hasInstalledPlugins) Res.string.no_metadata_plugin_description_select
else Res.string.no_metadata_plugin_description_install),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center)
if (!hasInstalledPlugins) {
MillenniumCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Step(1, Res.string.no_metadata_plugin_step_discover)
Step(2, Res.string.no_metadata_plugin_step_install)
Step(3, Res.string.no_metadata_plugin_step_select)
}
}
}
MillenniumButton(
onClick = onOpenPlugins,
style = MillenniumButtonStyle.Primary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
if (hasInstalledPlugins) Iconsax.IconsaxCheckCircle else Iconsax.IconsaxAdd,
contentDescription = null,
modifier = Modifier.size(18.dp))
Text(
stringResource(
if (hasInstalledPlugins) Res.string.no_metadata_plugin_action_select
else Res.string.no_metadata_plugin_action_install))
}
}
MillenniumButton(
onClick = onOpenLocalTracks,
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
Iconsax.IconsaxMusicLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp))
Text(stringResource(Res.string.no_metadata_plugin_action_local))
}
}
Text(
text = stringResource(Res.string.no_metadata_plugin_local_hint),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center)
}
}
}
}
@Composable
private fun HeroBadge() {
val primary = AppTheme.colors.primary
Box(
modifier = Modifier
.size(104.dp)
.clip(CircleShape)
.background(Brush.radialGradient(listOf(primary.copy(alpha = 0.22f), primary.copy(alpha = 0f)))),
contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center) {
MillenniumIcon(
Iconsax.IconsaxBoxAdd,
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = primary)
}
}
}
@Composable
private fun Step(index: Int, label: StringResource) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
.background(AppTheme.colors.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center) {
Text(
text = index.toString(),
style = AppTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.primary)
}
Text(
text = stringResource(label),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurface)
}
}

View File

@ -1,75 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin.guard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
sealed interface MetadataPluginGuardState {
data object Loading : MetadataPluginGuardState
data object Available : MetadataPluginGuardState
/** No metadata plugin is selected. [hasInstalledPlugins] tells whether the user only needs
* to pick one (true) or has to install one first (false). */
data class Missing(val hasInstalledPlugins: Boolean) : MetadataPluginGuardState
}
class MetadataPluginGuardViewModel(
pluginManager: PluginManager,
private val navigationCommands: NavigationCommands,
private val libraryState: LibraryState
) : ViewModel() {
val uiState: StateFlow<MetadataPluginGuardState> = pluginManager.state
.map { state ->
when (state) {
is PluginManagerStates.Loading -> MetadataPluginGuardState.Loading
is PluginManagerStates.Data ->
if (PluginAbility.METADATA in state.selectedPlugins) {
MetadataPluginGuardState.Available
} else {
MetadataPluginGuardState.Missing(
hasInstalledPlugins = state.plugins.any { PluginAbility.METADATA in it.abilities })
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MetadataPluginGuardState.Loading)
fun openPlugins() {
navigationCommands.navigateTo(Routes.Plugins)
}
/** Local tracks are a plugin-free player, so offer them as the way forward. */
fun openLocalTracks() {
libraryState.onTabSelected(LibraryTab.LocalTracks)
navigationCommands.navigateTo(Routes.Library)
}
}

View File

@ -18,10 +18,10 @@
package dev.krtirtho.spotube.modules.search package dev.krtirtho.spotube.modules.search
import org.spotube.millennium.composables.MillenniumMarqueeProgress
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumAutocomplete import org.spotube.millennium.composables.MillenniumAutocomplete
import org.spotube.millennium.composables.MillenniumText import org.spotube.millennium.composables.MillenniumText
@ -1155,7 +1155,7 @@ private fun SearchLoadingIndicator(
translationY = bounce translationY = bounce
}) })
} }
MillenniumMarqueeProgress( MillenniumIndeterminateProgress(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp) .padding(horizontal = 32.dp, vertical = 16.dp)

View File

@ -18,10 +18,10 @@
package dev.krtirtho.spotube.modules.settings package dev.krtirtho.spotube.modules.settings
import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumSidebarItem import org.spotube.millennium.composables.MillenniumSidebarItem
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
@ -288,7 +288,7 @@ private fun SettingsTabContent(
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
MillenniumCircularProgress(size = 40.dp) MillenniumIndeterminateProgress()
} }
} else { } else {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {

View File

@ -18,12 +18,6 @@
package dev.krtirtho.spotube.modules.settings.components package dev.krtirtho.spotube.modules.settings.components
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumDialogDescription
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
@ -244,30 +238,26 @@ internal fun TextInputSettingCard(
val normalizedValue = normalize(draft) val normalizedValue = normalize(draft)
val errorMessage = validate(normalizedValue) val errorMessage = validate(normalizedValue)
MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { MillenniumDialog(
MillenniumDialogHeader { visible = true,
MillenniumDialogTitle(dialogTitle) onDismissRequest = { isDialogOpen = false },
MillenniumDialogClose() title = dialogTitle
} ) {
MillenniumDialogBody { if (dialogDescription != null) {
if (dialogDescription != null) { Text(
MillenniumDialogDescription(dialogDescription) text = dialogDescription,
} style = AppTheme.typography.bodySmall,
MillenniumTextField( color = AppTheme.colors.onSurfaceVariant
state = dialogState,
label = title,
modifier = Modifier.fillMaxWidth(),
placeholder = placeholder,
error = errorMessage
) )
} }
MillenniumDialogFooter { MillenniumTextField(
MillenniumButton( state = dialogState,
onClick = { isDialogOpen = false }, label = title,
style = MillenniumButtonStyle.Secondary modifier = Modifier.fillMaxWidth(),
) { placeholder = placeholder,
Text(stringResource(Res.string.common_cancel)) error = errorMessage
} )
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton( MillenniumButton(
onClick = { onClick = {
if (errorMessage == null) { if (errorMessage == null) {
@ -279,6 +269,12 @@ internal fun TextInputSettingCard(
) { ) {
Text(stringResource(Res.string.common_save)) Text(stringResource(Res.string.common_save))
} }
MillenniumButton(
onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.common_cancel))
}
} }
} }
} }

View File

@ -18,12 +18,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher
import io.github.vinceglb.filekit.path
import org.spotube.millennium.composables.MillenniumDialog
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@ -261,24 +255,3 @@ private fun LocalMediaFolderEditorRow(
} }
} }
} }
/** Renders the "manage local folders" dialog (folder picker included) while [viewModel] has it open.
* Shared by Settings and the Local Tracks screen; callers trigger it via [LocalMediaFoldersViewModel.open]. */
@Composable
fun LocalMediaFoldersDialogHost(viewModel: LocalMediaFoldersViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val pickerLauncher =
rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) }
if (state.isOpen) {
MillenniumDialog(
visible = true,
onDismissRequest = viewModel::dismiss,
title = stringResource(Res.string.settings_local_media_folders_title)
) {
LocalMediaFoldersEditorContent(
state, { pickerLauncher.launch() }, viewModel::removeFolder,
viewModel::save, viewModel::dismiss
)
}
}
}

View File

@ -17,11 +17,6 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -156,12 +151,10 @@ private fun AppearanceSettingCard(
mutableStateOf(selectedPalette) mutableStateOf(selectedPalette)
} }
MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { MillenniumDialog(
MillenniumDialogHeader { visible = true,
MillenniumDialogTitle(stringResource(Res.string.settings_palette_dialog_title)) onDismissRequest = { isDialogOpen = false },
MillenniumDialogClose() title = stringResource(Res.string.settings_palette_dialog_title)) {
}
MillenniumDialogBody {
MillenniumText( MillenniumText(
stringResource(Res.string.settings_palette_dialog_description), stringResource(Res.string.settings_palette_dialog_description),
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant)
@ -181,13 +174,9 @@ private fun AppearanceSettingCard(
} }
PalettePreview(draftPalette) PalettePreview(draftPalette)
}
MillenniumDialogFooter { MillenniumSeparator()
MillenniumButton( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary) {
MillenniumText(stringResource(Res.string.common_cancel))
}
MillenniumButton( MillenniumButton(
onClick = { onClick = {
onSaved(draftPalette) onSaved(draftPalette)
@ -195,6 +184,11 @@ private fun AppearanceSettingCard(
}) { }) {
MillenniumText(stringResource(Res.string.common_save)) MillenniumText(stringResource(Res.string.common_save))
} }
MillenniumButton(
onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary) {
MillenniumText(stringResource(Res.string.common_cancel))
}
} }
} }
} }

View File

@ -20,6 +20,7 @@ package dev.krtirtho.spotube.modules.settings.sections
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
@ -30,7 +31,8 @@ import androidx.compose.runtime.getValue
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
@ -183,6 +185,8 @@ private fun LocalMediaFoldersSettingCard(
viewModel: LocalMediaFoldersViewModel = koinViewModel() viewModel: LocalMediaFoldersViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val pickerLauncher =
rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) }
val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) { val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) {
stringResource(Res.string.settings_local_media_folders_subtitle_empty) stringResource(Res.string.settings_local_media_folders_subtitle_empty)
} else stringResource( } else stringResource(
@ -210,5 +214,30 @@ private fun LocalMediaFoldersSettingCard(
onClick = viewModel::open onClick = viewModel::open
) )
LocalMediaFoldersDialogHost(viewModel) if (state.isOpen) {
LocalMediaFoldersDialog(
state = state,
onAddFolder = { pickerLauncher.launch() },
onRemoveFolder = viewModel::removeFolder,
onSave = viewModel::save,
onDismiss = viewModel::dismiss
)
}
}
@Composable
private fun LocalMediaFoldersDialog(
state: LocalMediaFoldersUiState,
onAddFolder: () -> Unit,
onRemoveFolder: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit
) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.settings_local_media_folders_title)
) {
LocalMediaFoldersEditorContent(state, onAddFolder, onRemoveFolder, onSave, onDismiss)
}
} }

View File

@ -39,8 +39,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -59,12 +62,14 @@ import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface import org.spotube.millennium.theme.millenniumSurface
import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent
import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.ui.graphics.SolidColor
import com.composeunstyled.Text import com.composeunstyled.Text
import com.composeunstyled.LocalTextStyle import com.composeunstyled.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -94,7 +99,6 @@ import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon
import dev.krtirtho.spotube.modules.downloads.DownloadStatus import dev.krtirtho.spotube.modules.downloads.DownloadStatus
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
import dev.krtirtho.spotube.modules.lyrics.SyncedLyricsList
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -285,8 +289,7 @@ fun AppExpandedPlayer(
if (playerOptionsUiState.isMoreOptionsSheetOpen) { if (playerOptionsUiState.isMoreOptionsSheetOpen) {
MillenniumModalBottomSheet( MillenniumModalBottomSheet(
state = moreOptionsSheetState, state = moreOptionsSheetState,
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp)
contentSpacing = 4.dp
) { ) {
// Grid of Options // Grid of Options
LazyVerticalGrid( LazyVerticalGrid(
@ -655,6 +658,7 @@ fun AppExpandedPlayer(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
audioPlayer = audioPlayer,
onExpand = onExpandLyrics onExpand = onExpandLyrics
) )
} }
@ -685,50 +689,105 @@ private fun rememberSharedAlbumArtModifier(
@Composable @Composable
private fun LyricsPreviewCard( private fun LyricsPreviewCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayer: AudioPlayerInterface,
onExpand: () -> Unit, onExpand: () -> Unit,
viewModel: LyricsViewModel = koinViewModel() viewModel: LyricsViewModel = koinViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val syncedLyrics = uiState.syncedLyrics ?: emptyList() val syncedLyrics = uiState.syncedLyrics ?: emptyList()
val isLoading = uiState.isLoading val isLoading = uiState.isLoading
val listState = rememberLazyListState()
val positionMillis by audioPlayer.positionFlow.collectAsState(initial = 0.milliseconds)
val currentIndex by remember {
derivedStateOf {
if (syncedLyrics.isEmpty()) return@derivedStateOf -1
var idx = 0
for (i in syncedLyrics.indices) {
if (syncedLyrics[i].time <= positionMillis.inWholeMilliseconds) {
idx = i
} else {
break
}
}
idx
}
}
LaunchedEffect(currentIndex) {
if (currentIndex >= 0 && syncedLyrics.isNotEmpty()) {
val centerIndex = currentIndex.coerceIn(0, syncedLyrics.lastIndex)
listState.animateScrollToItem(centerIndex, scrollOffset = -50)
}
}
if (syncedLyrics.isEmpty() || isLoading) return if (syncedLyrics.isEmpty() || isLoading) return
MillenniumCard( Box(
modifier = modifier.clickable(onClick = onExpand), modifier = modifier
contentPadding = PaddingValues(12.dp), .millenniumSurface(
contentSpacing = 8.dp, background = SolidColor(AppTheme.colors.surfaceContainerHighest),
shadowElevation = 2.dp shape = RoundedCornerShape(16.dp),
) { shadowElevation = 2.dp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.player_lyrics),
style = AppTheme.typography.labelMedium,
color = MillenniumTheme.colors.muted
) )
MillenniumIconButton( .clickable(onClick = onExpand)
onClick = onExpand, ) {
style = MillenniumIconButtonStyle.Quiet Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
MillenniumIcon( Text(
Iconsax.IconsaxArrowSquareUp, text = stringResource(Res.string.player_lyrics),
contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), style = AppTheme.typography.labelMedium,
tint = MillenniumTheme.colors.muted color = AppTheme.colors.onSurfaceVariant
) )
MillenniumIconButton(
onClick = onExpand,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxArrowSquareUp,
contentDescription = stringResource(Res.string.player_expand_lyrics_content_description),
tint = AppTheme.colors.onSurfaceVariant
)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
val displayLines = syncedLyrics.take(6)
itemsIndexed(displayLines) { index, line ->
val isCurrent =
index == currentIndex.coerceIn(0, displayLines.lastIndex.coerceAtLeast(0))
Text(
text = line.text.ifBlank { "..." },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
style = AppTheme.typography.bodySmall.copy(
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal,
color = if (isCurrent) {
AppTheme.colors.primary
} else {
AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f)
}
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
} }
} }
// Passive preview: no onLineClick, so lines never seek playback.
SyncedLyricsList(
lines = syncedLyrics,
currentIndex = uiState.currentIndex,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
)
} }
} }

View File

@ -18,10 +18,6 @@
package dev.krtirtho.spotube.modules.shell package dev.krtirtho.spotube.modules.shell
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMoon
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -440,6 +436,27 @@ fun AppLargePlayer(
onExpandedChange = { expanded -> onExpandedChange = { expanded ->
if (!expanded) playerOptionsViewModel.dismissMoreOptionsMenu() if (!expanded) playerOptionsViewModel.dismissMoreOptionsMenu()
}, },
items = listOf(goToAlbumLabel, sleepTimerLabel, trackDetailsLabel),
onItemClick = { label ->
when (label) {
goToAlbumLabel -> {
playerOptionsViewModel.dismissMoreOptionsMenu()
currentTrack?.album?.id?.let { albumId ->
navigationCommands.navigateTo(Routes.Album(albumId))
}
}
sleepTimerLabel -> {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showSleepTimerDialog()
}
trackDetailsLabel -> {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showTrackDetails()
}
}
},
side = AnchorSide.Top, side = AnchorSide.Top,
anchor = { anchor = {
MillenniumIconButton( MillenniumIconButton(
@ -454,31 +471,7 @@ fun AppLargePlayer(
contentDescription = stringResource(Res.string.player_more_options_content_description) contentDescription = stringResource(Res.string.player_more_options_content_description)
) )
} }
}) { })
MillenniumDropdownMenuItem(
label = goToAlbumLabel,
icon = Iconsax.IconsaxCd,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
currentTrack?.album?.id?.let { albumId ->
navigationCommands.navigateTo(Routes.Album(albumId))
}
})
MillenniumDropdownMenuItem(
label = sleepTimerLabel,
icon = Iconsax.IconsaxMoon,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showSleepTimerDialog()
})
MillenniumDropdownMenuItem(
label = trackDetailsLabel,
icon = Iconsax.IconsaxInformation,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showTrackDetails()
})
}
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton( MillenniumIconButton(

View File

@ -21,7 +21,6 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -167,11 +166,7 @@ fun AppSidebar(
} }
} }
} }
// Compact the footer chrome only (padding + gap between items); items keep their size. MillenniumSidebarFooter {
MillenniumSidebarFooter(
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp),
contentSpacing = 2.dp
) {
val supportLabel = stringResource(Res.string.support_button_label) val supportLabel = stringResource(Res.string.support_button_label)
MillenniumSidebarItem( MillenniumSidebarItem(
label = supportLabel, label = supportLabel,

View File

@ -18,16 +18,11 @@
package dev.krtirtho.spotube.modules.shell package dev.krtirtho.spotube.modules.shell
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumMarqueeProgress
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.millenniumSurface import org.spotube.millennium.theme.millenniumSurface
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
@ -133,12 +128,11 @@ private fun SleepTimerDialog(
onStart: () -> Unit, onStart: () -> Unit,
onCancelTimer: () -> Unit, onCancelTimer: () -> Unit,
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
MillenniumDialog(visible = true, onDismissRequest = onDismiss) { MillenniumDialog(
MillenniumDialogHeader { visible = true,
MillenniumDialogTitle(stringResource(Res.string.player_sleep_timer)) onDismissRequest = onDismiss,
MillenniumDialogClose() title = stringResource(Res.string.player_sleep_timer)) {
} Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
MillenniumDialogBody {
if (remainingMs != null) { if (remainingMs != null) {
Text( Text(
text = stringResource(Res.string.player_sleep_timer_remaining, formatRemainingTime(remainingMs)), text = stringResource(Res.string.player_sleep_timer_remaining, formatRemainingTime(remainingMs)),
@ -158,20 +152,21 @@ private fun SleepTimerDialog(
label = stringResource(Res.string.player_sleep_timer_minutes_label), label = stringResource(Res.string.player_sleep_timer_minutes_label),
placeholder = stringResource(Res.string.player_sleep_timer_minutes_placeholder), placeholder = stringResource(Res.string.player_sleep_timer_minutes_placeholder),
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth())
}
MillenniumDialogFooter { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (remainingMs != null) { if (remainingMs != null) {
MillenniumButton(
onClick = onCancelTimer,
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.player_sleep_timer_cancel)) }
}
MillenniumButton( MillenniumButton(
onClick = onCancelTimer, onClick = onDismiss,
style = MillenniumButtonStyle.Quiet) { Text(stringResource(Res.string.player_sleep_timer_cancel)) } style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) }
} MillenniumButton(
MillenniumButton( onClick = onStart,
onClick = onDismiss, enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) {
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } Text(stringResource(Res.string.player_sleep_timer_start))
MillenniumButton( }
onClick = onStart,
enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) {
Text(stringResource(Res.string.player_sleep_timer_start))
} }
} }
} }
@ -181,12 +176,11 @@ private fun SleepTimerDialog(
private fun TrackDetailsDialog( private fun TrackDetailsDialog(
details: PlayerTrackDetails, details: PlayerTrackDetails,
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
MillenniumDialog(visible = true, onDismissRequest = onDismiss) { MillenniumDialog(
MillenniumDialogHeader { visible = true,
MillenniumDialogTitle(stringResource(Res.string.player_track_details)) onDismissRequest = onDismiss,
MillenniumDialogClose() title = stringResource(Res.string.player_track_details)) {
} Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
MillenniumDialogBody(contentSpacing = 14.dp) {
Text( Text(
details.track.title, details.track.title,
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
@ -195,7 +189,8 @@ private fun TrackDetailsDialog(
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp),
horizontalArrangement = Arrangement.Center) { horizontalArrangement = Arrangement.Center) {
MillenniumMarqueeProgress(modifier = Modifier.fillMaxWidth()) MillenniumIndeterminateProgress(
modifier = Modifier.fillMaxWidth())
} }
} }
@ -320,8 +315,6 @@ private fun TrackDetailsDialog(
DetailRow(stringResource(Res.string.player_track_details_active_url), activeUrl) DetailRow(stringResource(Res.string.player_track_details_active_url), activeUrl)
} }
}
MillenniumDialogFooter {
MillenniumButton( MillenniumButton(
onClick = onDismiss, onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) }

View File

@ -18,8 +18,8 @@
package dev.krtirtho.spotube.modules.shell.alternative_track package dev.krtirtho.spotube.modules.shell.alternative_track
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -98,7 +98,8 @@ fun AlternativeTrackContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
MillenniumSpokeSpinner(size = 24.dp) MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = stringResource(Res.string.alternative_track_searching), text = stringResource(Res.string.alternative_track_searching),

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.modules.shell.player_queue package dev.krtirtho.spotube.modules.shell.player_queue
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -420,6 +419,11 @@ private fun QueueItemRow(
MillenniumDropdownMenu( MillenniumDropdownMenu(
expanded = showMenu, expanded = showMenu,
onExpandedChange = { showMenu = it }, onExpandedChange = { showMenu = it },
items = listOf(stringResource(Res.string.queue_remove_from_queue)),
onItemClick = {
onRemoveClick()
showMenu = false
},
anchor = { anchor = {
MillenniumIconButton( MillenniumIconButton(
onClick = { showMenu = true }, onClick = { showMenu = true },
@ -430,16 +434,7 @@ private fun QueueItemRow(
contentDescription = stringResource(Res.string.player_more_options_content_description) contentDescription = stringResource(Res.string.player_more_options_content_description)
) )
} }
}) { })
MillenniumDropdownMenuItem(
label = stringResource(Res.string.queue_remove_from_queue),
icon = Iconsax.IconsaxMusicSquareRemove,
destructive = true,
onClick = {
onRemoveClick()
showMenu = false
})
}
} }
} }
) )

View File

@ -99,8 +99,7 @@ internal fun BottomSheetContent(
} }
MillenniumModalBottomSheet( MillenniumModalBottomSheet(
state = sheetState, state = sheetState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
contentSpacing = 8.dp
) { ) {
content() content()
} }

View File

@ -17,12 +17,6 @@
package dev.krtirtho.spotube.modules.support package dev.krtirtho.spotube.modules.support
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogDescription
import org.spotube.millennium.composables.MillenniumDialogFooter
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -109,50 +103,49 @@ fun SupportDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
isReminder: Boolean = false) { isReminder: Boolean = false) {
if (!visible) return if (!visible) return
MillenniumDialog(visible = true, onDismissRequest = onDismissRequest) { MillenniumDialog(
MillenniumDialogHeader { visible = true,
MillenniumDialogTitle(stringResource(Res.string.support_dialog_title)) onDismissRequest = onDismissRequest,
MillenniumDialogClose() title = stringResource(Res.string.support_dialog_title)) {
Text(
stringResource(Res.string.support_dialog_message),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
if (isReminder) {
Text(
stringResource(Res.string.support_dialog_reminder_hint),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
} }
MillenniumDialogBody { donationOptions.forEach { option ->
MillenniumDialogDescription(stringResource(Res.string.support_dialog_message)) MillenniumButton(
if (isReminder) { onClick = {
Text( openDonation(option)
stringResource(Res.string.support_dialog_reminder_hint), onDismissRequest()
style = AppTheme.typography.bodySmall, },
color = AppTheme.colors.onSurfaceVariant) modifier = Modifier.fillMaxWidth(),
} style = MillenniumButtonStyle.Secondary) {
donationOptions.forEach { option -> Row(
MillenniumButton( verticalAlignment = Alignment.CenterVertically,
onClick = { horizontalArrangement = Arrangement.spacedBy(8.dp)) {
openDonation(option) MillenniumIcon(
onDismissRequest() imageVector = option.icon,
}, contentDescription = null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.size(20.dp),
style = MillenniumButtonStyle.Secondary) { tint = if (option.id == "patreon") {
Row( AppTheme.colors.onSurface
verticalAlignment = Alignment.CenterVertically, } else {
horizontalArrangement = Arrangement.spacedBy(8.dp)) { Color.Unspecified
MillenniumIcon( })
imageVector = option.icon, Text(stringResource(option.title))
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (option.id == "patreon") {
AppTheme.colors.onSurface
} else {
Color.Unspecified
})
Text(stringResource(option.title))
}
} }
} }
} }
MillenniumDialogFooter { MillenniumButton(
MillenniumButton( onClick = onDismissRequest,
onClick = onDismissRequest, modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Quiet) { style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.support_dialog_dismiss)) Text(stringResource(Res.string.support_dialog_dismiss))
}
} }
} }
} }

View File

@ -17,14 +17,19 @@
package dev.krtirtho.spotube.modules.update package dev.krtirtho.spotube.modules.update
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumAlertDialog
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.app_update_dialog_no_notes import spotube.composeapp.generated.resources.app_update_dialog_no_notes
import spotube.composeapp.generated.resources.app_update_dialog_title import spotube.composeapp.generated.resources.app_update_dialog_title
@ -42,26 +47,28 @@ fun AppUpdateDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onIgnore: () -> Unit, onIgnore: () -> Unit,
onUpdate: () -> Unit) { onUpdate: () -> Unit) {
MillenniumAlertDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.app_update_dialog_title, update.tag)) }, title = stringResource(Res.string.app_update_dialog_title, update.tag)) {
content = { if (update.releaseNotesMarkdown.isNotBlank()) {
if (update.releaseNotesMarkdown.isNotBlank()) { SelectionContainer {
SelectionContainer { Markdown(
Markdown(content = update.releaseNotesMarkdown) content = update.releaseNotesMarkdown,
} modifier = Modifier.padding(bottom = 8.dp))
} else {
Text(stringResource(Res.string.app_update_dialog_no_notes))
} }
}) { } else {
MillenniumButton( Text(stringResource(Res.string.app_update_dialog_no_notes))
onClick = onIgnore,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.update_action_ignore))
} }
MillenniumButton(onClick = onUpdate) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(Res.string.update_action_update)) MillenniumButton(
onClick = onIgnore,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.update_action_ignore))
}
MillenniumButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
} }
} }
} }

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.modules.update package dev.krtirtho.spotube.modules.update
import org.spotube.millennium.composables.MillenniumAlertDialog
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -31,6 +30,7 @@ import com.mikepenz.markdown.m3.Markdown
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_update_dialog_current_version import spotube.composeapp.generated.resources.plugin_update_dialog_current_version
import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes
@ -50,35 +50,33 @@ fun PluginUpdateDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onIgnore: () -> Unit, onIgnore: () -> Unit,
onUpdate: () -> Unit) { onUpdate: () -> Unit) {
MillenniumAlertDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = stringResource(
Text( Res.string.plugin_update_dialog_title,
stringResource( update.pluginName,
Res.string.plugin_update_dialog_title, update.info.latestVersion)) {
update.pluginName, Text(
update.info.latestVersion)) text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion),
}, color = AppTheme.colors.onSurfaceVariant,
description = { modifier = Modifier.padding(bottom = 8.dp))
Text(stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion)) if (update.info.releaseNotes.isNotBlank()) {
}, SelectionContainer {
content = { Markdown(content = update.info.releaseNotes)
if (update.info.releaseNotes.isNotBlank()) {
SelectionContainer {
Markdown(content = update.info.releaseNotes)
}
} else {
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
} }
}) { } else {
MillenniumButton( Text(stringResource(Res.string.plugin_update_dialog_no_notes))
onClick = onIgnore,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.update_action_ignore))
} }
MillenniumButton(onClick = onUpdate) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(Res.string.update_action_update)) MillenniumButton(
onClick = onIgnore,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.update_action_ignore))
}
MillenniumButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
} }
} }
} }

View File

@ -26,19 +26,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** NewPipe reports durations in seconds (-1 when unknown). */
private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0
actual class NewPipeService { actual class NewPipeService {
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> { actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extractor = YouTube.getSearchExtractor( val extractor = YouTube.getSearchExtractor(
query, query,
mutableListOf( mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS),
when (kind) {
SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS
}),
"" ""
) )
extractor.fetchPage() extractor.fetchPage()
@ -50,7 +43,7 @@ actual class NewPipeService {
title = item.name, title = item.name,
url = item.url, url = item.url,
uploader = item.uploaderName ?: "Unknown", uploader = item.uploaderName ?: "Unknown",
durationMs = secondsToMs(item.duration), durationMs = item.duration,
thumbnailUrl = item.thumbnails.first().url, thumbnailUrl = item.thumbnails.first().url,
id = Url(item.url).parameters["v"] id = Url(item.url).parameters["v"]
?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}") ?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}")
@ -70,7 +63,7 @@ actual class NewPipeService {
title = extractor.name ?: "Unknown", title = extractor.name ?: "Unknown",
url = extractor.url ?: "https://www.youtube.com/watch?v=$id", url = extractor.url ?: "https://www.youtube.com/watch?v=$id",
uploader = extractor.uploaderName, uploader = extractor.uploaderName,
durationMs = secondsToMs(extractor.length), durationMs = extractor.length,
thumbnailUrl = extractor.thumbnails.first().url, thumbnailUrl = extractor.thumbnails.first().url,
audioStreams = extractor.audioStreams() audioStreams = extractor.audioStreams()
.filter { stream -> stream.isUrl() && stream.format != null } .filter { stream -> stream.isUrl() && stream.format != null }