mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-06 19:24:01 +00:00
Compare commits
11 Commits
2bef59ee76
...
7743d6d4c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7743d6d4c3 | ||
|
|
77a9cef3a1 | ||
|
|
c9d16a25c6 | ||
|
|
da394d40ea | ||
|
|
09d77211b9 | ||
|
|
61dd89f3ed | ||
|
|
ba62ff03c3 | ||
|
|
83115ea99a | ||
|
|
59f1b82368 | ||
|
|
c57e33fe0d | ||
|
|
daf54b74f4 |
@ -336,6 +336,14 @@ 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)
|
||||||
|
|||||||
@ -26,12 +26,19 @@ 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): List<VideoSearchResult> {
|
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val extractor = YouTube.getSearchExtractor(
|
val extractor = YouTube.getSearchExtractor(
|
||||||
query,
|
query,
|
||||||
mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS),
|
mutableListOf(
|
||||||
|
when (kind) {
|
||||||
|
SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
|
||||||
|
SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS
|
||||||
|
}),
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
extractor.fetchPage()
|
extractor.fetchPage()
|
||||||
@ -45,7 +52,7 @@ actual class NewPipeService {
|
|||||||
title = item.name,
|
title = item.name,
|
||||||
url = item.url,
|
url = item.url,
|
||||||
uploader = item.uploaderName,
|
uploader = item.uploaderName,
|
||||||
durationMs = item.duration,
|
durationMs = secondsToMs(item.duration),
|
||||||
thumbnailUrl = item.thumbnails.first().url)
|
thumbnailUrl = item.thumbnails.first().url)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@ -101,7 +108,7 @@ actual class NewPipeService {
|
|||||||
title = extractor.name,
|
title = extractor.name,
|
||||||
url = extractor.url,
|
url = extractor.url,
|
||||||
uploader = extractor.uploaderName,
|
uploader = extractor.uploaderName,
|
||||||
durationMs = extractor.length,
|
durationMs = secondsToMs(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 {
|
||||||
|
|||||||
@ -84,6 +84,8 @@ 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)
|
||||||
@ -143,10 +145,6 @@ 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)
|
||||||
}
|
}
|
||||||
@ -260,8 +258,37 @@ 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) {
|
||||||
exoPlayer.shuffleModeEnabled = enabled
|
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return@withContext
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,6 +300,10 @@ 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
|
||||||
@ -305,6 +336,7 @@ 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())
|
||||||
@ -317,6 +349,8 @@ 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
|
||||||
@ -331,6 +365,7 @@ 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
|
||||||
@ -346,6 +381,9 @@ 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
|
||||||
@ -398,6 +436,7 @@ 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)
|
||||||
|
|||||||
@ -68,6 +68,8 @@
|
|||||||
<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>
|
||||||
@ -263,10 +265,24 @@
|
|||||||
<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>
|
||||||
@ -560,6 +576,7 @@
|
|||||||
<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>
|
||||||
@ -613,6 +630,12 @@
|
|||||||
<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>
|
||||||
|
|||||||
@ -54,6 +54,7 @@ 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
|
||||||
@ -70,6 +71,7 @@ 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
|
||||||
@ -115,6 +117,7 @@ val sharedModules = module {
|
|||||||
// Home
|
// Home
|
||||||
singleOf(::HomeScreenRepository)
|
singleOf(::HomeScreenRepository)
|
||||||
viewModelOf(::HomeScreenViewModel)
|
viewModelOf(::HomeScreenViewModel)
|
||||||
|
viewModelOf(::MetadataAuthViewModel)
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
singleOf(::SearchRepository)
|
singleOf(::SearchRepository)
|
||||||
@ -138,6 +141,7 @@ val sharedModules = module {
|
|||||||
bind<ScrobblePluginSource>()
|
bind<ScrobblePluginSource>()
|
||||||
}
|
}
|
||||||
viewModelOf(::PluginViewModel)
|
viewModelOf(::PluginViewModel)
|
||||||
|
viewModelOf(::MetadataPluginGuardViewModel)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||||
|
|||||||
@ -29,6 +29,7 @@ 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
|
||||||
@ -101,12 +102,17 @@ sealed interface Routes : NavKey {
|
|||||||
val navigationModule = module {
|
val navigationModule = module {
|
||||||
singleOf(::NavigationCommands)
|
singleOf(::NavigationCommands)
|
||||||
navigation<Routes.Home> {
|
navigation<Routes.Home> {
|
||||||
HomeScreen(viewModel = koinViewModel())
|
MetadataPluginGuard {
|
||||||
|
HomeScreen(viewModel = koinViewModel())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
navigation<Routes.Search> {
|
navigation<Routes.Search> {
|
||||||
SearchScreen()
|
MetadataPluginGuard {
|
||||||
|
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> {
|
||||||
@ -117,49 +123,57 @@ val navigationModule = module {
|
|||||||
}
|
}
|
||||||
navigation<Routes.WebView> {}
|
navigation<Routes.WebView> {}
|
||||||
navigation<Routes.Playlist> {
|
navigation<Routes.Playlist> {
|
||||||
PlaylistScreen(
|
MetadataPluginGuard(backButton = true) {
|
||||||
playlistId = it.playlistId,
|
PlaylistScreen(
|
||||||
viewModel = koinViewModel(
|
playlistId = it.playlistId,
|
||||||
key = it.playlistId,
|
viewModel = koinViewModel(
|
||||||
parameters = { parametersOf(it.playlistId) }
|
key = it.playlistId,
|
||||||
),
|
parameters = { parametersOf(it.playlistId) }
|
||||||
audioPlayerQueue = get(),
|
),
|
||||||
audioPlayer = get(),
|
audioPlayerQueue = get(),
|
||||||
navigationCommands = get())
|
audioPlayer = get(),
|
||||||
|
navigationCommands = get())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
navigation<Routes.Artist> {
|
navigation<Routes.Artist> {
|
||||||
ArtistScreen(
|
MetadataPluginGuard(backButton = true) {
|
||||||
viewModel = koinViewModel(
|
ArtistScreen(
|
||||||
key = it.artistId,
|
viewModel = koinViewModel(
|
||||||
parameters = { parametersOf(it.artistId) }
|
key = it.artistId,
|
||||||
),
|
parameters = { parametersOf(it.artistId) }
|
||||||
audioPlayerQueue = get(),
|
),
|
||||||
audioPlayer = get(),
|
audioPlayerQueue = get(),
|
||||||
navigationCommands = get())
|
audioPlayer = get(),
|
||||||
|
navigationCommands = get())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
navigation<Routes.Album> {
|
navigation<Routes.Album> {
|
||||||
AlbumScreen(
|
MetadataPluginGuard(backButton = true) {
|
||||||
albumId = it.albumId,
|
AlbumScreen(
|
||||||
viewModel = koinViewModel(
|
albumId = it.albumId,
|
||||||
key = it.albumId,
|
viewModel = koinViewModel(
|
||||||
parameters = { parametersOf(it.albumId) }
|
key = it.albumId,
|
||||||
),
|
parameters = { parametersOf(it.albumId) }
|
||||||
audioPlayerQueue = get(),
|
),
|
||||||
audioPlayer = get(),
|
audioPlayerQueue = get(),
|
||||||
navigationCommands = get())
|
audioPlayer = get(),
|
||||||
|
navigationCommands = get())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
navigation<Routes.Lyrics> {
|
navigation<Routes.Lyrics> {
|
||||||
LyricsScreen(viewModel = koinViewModel())
|
LyricsScreen(viewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
navigation<Routes.SavedTracks> {
|
navigation<Routes.SavedTracks> {
|
||||||
SavedTracksScreen(
|
MetadataPluginGuard(backButton = true) {
|
||||||
viewModel = koinViewModel(
|
SavedTracksScreen(
|
||||||
key = SAVED_TRACKS_COLLECTION_ID,
|
viewModel = koinViewModel(
|
||||||
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
|
key = SAVED_TRACKS_COLLECTION_ID,
|
||||||
),
|
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
|
||||||
audioPlayerQueue = get(),
|
),
|
||||||
audioPlayer = get(),
|
audioPlayerQueue = get(),
|
||||||
navigationCommands = get())
|
audioPlayer = get(),
|
||||||
|
navigationCommands = get())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
navigation<Routes.Blacklist> {
|
navigation<Routes.Blacklist> {
|
||||||
BlacklistScreen()
|
BlacklistScreen()
|
||||||
|
|||||||
@ -17,7 +17,15 @@
|
|||||||
|
|
||||||
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): List<VideoSearchResult>
|
suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult>
|
||||||
suspend fun getVideoInfo(id: String): VideoInfo
|
suspend fun getVideoInfo(id: String): VideoInfo
|
||||||
}
|
}
|
||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -34,7 +35,6 @@ 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,21 +56,25 @@ fun ConnectionRequestDialogHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingRequest?.let { request ->
|
pendingRequest?.let { request ->
|
||||||
MillenniumDialog(
|
MillenniumAlertDialog(
|
||||||
visible = true,
|
visible = true,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
title = stringResource(Res.string.remote_request_title)) {
|
title = { Text(stringResource(Res.string.remote_request_title)) },
|
||||||
Text(
|
description = {
|
||||||
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)
|
) {
|
||||||
Row(
|
MillenniumButton(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
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)
|
||||||
@ -86,15 +90,6 @@ 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,12 +157,12 @@ class CacheManager(
|
|||||||
suspend fun commitCacheEntry(
|
suspend fun commitCacheEntry(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
allBytes: ByteArray,
|
sizeBytes: Long,
|
||||||
contentType: String) {
|
contentType: String) {
|
||||||
val entry = CacheEntry(
|
val entry = CacheEntry(
|
||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
sizeBytes = allBytes.size.toLong(),
|
sizeBytes = sizeBytes,
|
||||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||||
contentType = contentType)
|
contentType = contentType)
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ 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
|
||||||
@ -38,16 +39,19 @@ 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.readRemaining
|
import io.ktor.utils.io.readAvailable
|
||||||
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
|
||||||
@ -127,8 +131,11 @@ 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.w { "Timeout waiting for cache of track $trackId, falling through to upstream" }
|
logger.d { "No cache for track $trackId after waiting, falling through to upstream" }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -260,18 +267,25 @@ internal class StreamProxy(
|
|||||||
) {
|
) {
|
||||||
logger.d { "Proxying segment for track $trackId" }
|
logger.d { "Proxying segment for track $trackId" }
|
||||||
|
|
||||||
val response = runCatching {
|
runCatching {
|
||||||
httpClient.request(segmentUrl) {
|
httpClient.prepareRequest(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" }
|
||||||
call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment")
|
runCatching { 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}" }
|
||||||
@ -305,48 +319,65 @@ 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) {
|
||||||
val response = runCatching {
|
var retryUrl: String? = null
|
||||||
httpClient.request(currentUrl) {
|
val failure = runCatching {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}.getOrElse { throwable ->
|
}.exceptionOrNull()
|
||||||
logger.w(throwable) { "Failed to fetch upstream stream for track $trackId" }
|
|
||||||
call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream")
|
if (failure != null) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.value < 400) {
|
val next = retryUrl ?: return
|
||||||
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 = refreshedUrl
|
currentUrl = next
|
||||||
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) {
|
||||||
@ -380,7 +411,9 @@ internal class StreamProxy(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val doCache = requestMethod == HttpMethod.Get && isCachingEnabled()
|
// 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() &&
|
||||||
|
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])
|
||||||
@ -388,6 +421,7 @@ 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() {
|
||||||
@ -402,26 +436,38 @@ 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 packet = upstream.bodyAsChannel().readRemaining()
|
val source = upstream.bodyAsChannel()
|
||||||
val allBytes = packet.readByteArray()
|
val buffer = ByteArray(64 * 1024)
|
||||||
channel.writeFully(allBytes)
|
|
||||||
fileSystem.sink(cacheFile).buffer().use { sink ->
|
fileSystem.sink(cacheFile).buffer().use { sink ->
|
||||||
sink.write(allBytes)
|
while (true) {
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cacheManager.commitCacheEntry(
|
completed = expectedLength == null || expectedLength == totalBytes
|
||||||
trackId = trackIdForCache,
|
if (completed) {
|
||||||
filename = cacheFilename,
|
cacheManager.commitCacheEntry(
|
||||||
allBytes = allBytes,
|
trackId = trackIdForCache,
|
||||||
contentType = cacheContentType)
|
filename = cacheFilename,
|
||||||
scope.launch {
|
sizeBytes = totalBytes,
|
||||||
cacheManager.evictIfNeeded()
|
contentType = cacheContentType)
|
||||||
|
scope.launch {
|
||||||
|
cacheManager.evictIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} finally {
|
||||||
if (fileSystem.exists(cacheFile)) {
|
// Player disconnected or stream was incomplete: drop the partial file
|
||||||
|
if (!completed && fileSystem.exists(cacheFile)) {
|
||||||
fileSystem.delete(cacheFile)
|
fileSystem.delete(cacheFile)
|
||||||
}
|
}
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
upstream.bodyAsChannel().copyTo(channel)
|
upstream.bodyAsChannel().copyTo(channel)
|
||||||
@ -487,6 +533,16 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@ -17,18 +17,185 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.tools.user_agents
|
package dev.krtirtho.spotube.core.tools.user_agents
|
||||||
|
|
||||||
object UserAgents {
|
import co.touchlab.kermit.Logger
|
||||||
private val agents = listOf(
|
import dev.krtirtho.spotube.PlatformType
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
"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",
|
import dev.krtirtho.spotube.getPlatform
|
||||||
"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",
|
import io.ktor.client.HttpClient
|
||||||
"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",
|
import io.ktor.client.engine.cio.CIO
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.7; rv:68.0) Gecko/20100101 Firefox/68.0 Waterfox/56.6.2021.2816",
|
import io.ktor.client.request.get
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X10.14; rv:65.0) Gecko/20100101 Firefox/65.0 Waterfox/56.2.7",
|
import io.ktor.client.statement.bodyAsText
|
||||||
"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",
|
import io.ktor.http.isSuccess
|
||||||
"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",
|
import kotlinx.coroutines.CoroutineScope
|
||||||
"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"
|
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
|
||||||
|
|
||||||
fun random(): String = agents.random()
|
/**
|
||||||
|
* 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 {
|
||||||
|
private const val SOURCE_URL =
|
||||||
|
"https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json"
|
||||||
|
private const val CACHE_FILE_NAME = "user_agents_cache.json"
|
||||||
|
private const val CACHE_TTL_MS = 24L * 60 * 60 * 1000 // 1 day
|
||||||
|
|
||||||
|
private val logger = Logger.withTag("UserAgents")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val refreshMutex = Mutex()
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,15 @@ 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
|
||||||
@ -31,7 +40,6 @@ 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
|
||||||
@ -50,6 +58,9 @@ 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) {
|
||||||
@ -59,14 +70,19 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
MillenniumDialog(
|
MillenniumDialog(
|
||||||
visible = true,
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = "",
|
modifier = modifier,
|
||||||
modifier = modifier) {
|
paneTitle = dialogTitle ?: "") {
|
||||||
title?.let { it() }
|
MillenniumDialogHeader {
|
||||||
content()
|
if (dialogTitle != null) {
|
||||||
actions?.let {
|
MillenniumDialogTitle(text = dialogTitle, textStyle = AppTheme.typography.titleMedium)
|
||||||
MillenniumSeparator()
|
} else {
|
||||||
it()
|
MillenniumDialogTitle { title?.invoke() }
|
||||||
|
}
|
||||||
|
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)
|
||||||
@ -77,10 +93,7 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
}
|
}
|
||||||
MillenniumModalBottomSheet(state = sheetState) {
|
MillenniumModalBottomSheet(state = sheetState) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(top = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.Start) {
|
horizontalAlignment = Alignment.Start) {
|
||||||
title?.let {
|
title?.let {
|
||||||
Row(
|
Row(
|
||||||
@ -99,7 +112,13 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
content()
|
Column(
|
||||||
|
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()
|
||||||
@ -107,7 +126,6 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
it()
|
it()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,18 @@ 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
|
||||||
@ -30,6 +41,7 @@ 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
|
||||||
@ -41,6 +53,7 @@ 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
|
||||||
@ -57,6 +70,14 @@ 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
|
||||||
@ -67,6 +88,7 @@ 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(
|
||||||
@ -99,17 +121,13 @@ fun AdaptiveDropdownBottomSheet(
|
|||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
MillenniumDropdownMenu(
|
DesktopDropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onExpandedChange = { expanded = it },
|
onExpandedChange = { expanded = it },
|
||||||
items = items.map { it.label },
|
items = items,
|
||||||
onItemClick = { label ->
|
menuMinWidth = menuMinWidth,
|
||||||
items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
|
filter = filter,
|
||||||
expanded = false
|
trigger = { trigger { expanded = true } })
|
||||||
},
|
|
||||||
minimumWidth = menuMinWidth,
|
|
||||||
anchor = { trigger { expanded = true } }
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
trigger { expanded = true }
|
trigger { expanded = true }
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
@ -134,6 +152,7 @@ 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()) {
|
||||||
@ -147,7 +166,10 @@ private fun AdaptiveBottomSheetContent(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MillenniumModalBottomSheet(state = sheetState) {
|
MillenniumModalBottomSheet(
|
||||||
|
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()
|
||||||
@ -175,69 +197,134 @@ private fun AdaptiveBottomSheetContent(
|
|||||||
if (item.dividerBefore) {
|
if (item.dividerBefore) {
|
||||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
}
|
}
|
||||||
MillenniumListTile(
|
AdaptiveMenuTile(
|
||||||
title = {
|
item = item,
|
||||||
MillenniumText(
|
hasSelection = hasSelection,
|
||||||
item.label,
|
hasLeading = hasLeading,
|
||||||
style = AppTheme.typography.bodyLarge,
|
textStyle = AppTheme.typography.bodyLarge,
|
||||||
color = if (item.enabled) {
|
minimumHeight = 56.dp,
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,11 @@
|
|||||||
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
|
||||||
@ -45,6 +50,9 @@ 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
|
||||||
@ -72,7 +80,16 @@ 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
|
||||||
|
|
||||||
@ -191,19 +208,11 @@ private fun ErrorLogDialog(
|
|||||||
onDismiss: () -> Unit) {
|
onDismiss: () -> Unit) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
MillenniumDialog(
|
MillenniumDialog(visible = true, onDismissRequest = onDismiss) {
|
||||||
visible = true,
|
MillenniumDialogHeader {
|
||||||
onDismissRequest = onDismiss,
|
MillenniumDialogTitle(
|
||||||
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),
|
||||||
style = AppTheme.typography.titleLarge,
|
textStyle = AppTheme.typography.titleMedium)
|
||||||
fontWeight = FontWeight.Bold)
|
|
||||||
|
|
||||||
MillenniumIconButton(
|
MillenniumIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||||
@ -213,47 +222,38 @@ 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()
|
||||||
}
|
}
|
||||||
Box(
|
MillenniumDialogBody(scrollable = false) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.height(400.dp)
|
.fillMaxWidth()
|
||||||
.clip(AppTheme.shapes.small)
|
.height(400.dp)
|
||||||
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
.clip(AppTheme.shapes.small)
|
||||||
SelectionContainer {
|
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
||||||
Text(
|
SelectionContainer {
|
||||||
text = errorMessage,
|
Text(
|
||||||
style = AppTheme.typography.bodySmall.copy(
|
text = errorMessage,
|
||||||
fontFamily = FontFamily.Monospace),
|
style = AppTheme.typography.bodySmall.copy(
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
fontFamily = FontFamily.Monospace),
|
||||||
modifier = Modifier
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
.fillMaxSize()
|
modifier = Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.fillMaxSize()
|
||||||
.padding(12.dp))
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(12.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
MillenniumDialogFooter {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End) {
|
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
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() {
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,7 +28,9 @@ 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
|
||||||
|
|
||||||
@ -143,7 +145,10 @@ fun TrackOptionsBottomSheet(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MillenniumModalBottomSheet(state = sheetState) {
|
MillenniumModalBottomSheet(
|
||||||
|
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)
|
||||||
|
|
||||||
@ -184,7 +189,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,
|
||||||
|
|||||||
@ -42,6 +42,8 @@ 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 }
|
||||||
|
|
||||||
@ -80,11 +82,16 @@ fun SkeletonTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Modifier.shimmerApply(
|
fun Modifier.shimmerApply(
|
||||||
color: Color = Color.LightGray.copy(alpha = 0.5f) // Adjust alpha/color to match your theme
|
color: Color? = null // defaults to Millennium's recessed skeleton fill
|
||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
val isTreeLoading = LocalSkeletonLoading.current
|
val isTreeLoading = LocalSkeletonLoading.current
|
||||||
|
val skeletonBrush = millenniumBevelBrush(MillenniumTheme.colors, sunken = true)
|
||||||
|
|
||||||
if (isTreeLoading) this.background(color) else this
|
when {
|
||||||
|
!isTreeLoading -> this
|
||||||
|
color != null -> this.background(color)
|
||||||
|
else -> this.background(skeletonBrush)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
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
|
||||||
@ -35,9 +36,11 @@ 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
|
||||||
@ -218,14 +221,16 @@ 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()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -241,6 +246,23 @@ 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
|
||||||
|
|||||||
@ -21,6 +21,10 @@ 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
|
||||||
@ -37,7 +41,10 @@ import kotlinx.coroutines.IO
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class ISRCProviders {
|
class ISRCProviders : KoinComponent {
|
||||||
|
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)
|
||||||
@ -57,6 +64,17 @@ class ISRCProviders {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
@ -78,20 +96,23 @@ class ISRCProviders {
|
|||||||
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.body<IFPISessionResponse>()
|
val body = res.decodeLogged<IFPISessionResponse>("ifpi-login")
|
||||||
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(
|
||||||
@ -113,6 +134,7 @@ class ISRCProviders {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
@ -123,17 +145,28 @@ class ISRCProviders {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.body<IFPIRecordingResponse>()
|
val body = res.decodeLogged<IFPIRecordingResponse>("ifpi")
|
||||||
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")
|
||||||
@ -141,39 +174,47 @@ class ISRCProviders {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.body<MusicGatewayResponse>()
|
val body = res.decodeLogged<MusicGatewayResponse>("musicGateway")
|
||||||
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 (item in results) {
|
for ((_, artists, externalIds, id, name) in results) {
|
||||||
val isExactTitle = item.name.equals(track.title, ignoreCase = true)
|
val isExactTitle = name.equals(track.title, ignoreCase = true)
|
||||||
val isArtistMatch = item.artists.any {
|
val isArtistMatch = artists.any {
|
||||||
it.name.equals(
|
it.name.equals(
|
||||||
track.artists.firstOrNull()?.name ?: "",
|
track.artists.firstOrNull()?.name ?: "",
|
||||||
ignoreCase = true
|
ignoreCase = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val isSameId = item.id == track.id
|
val isSameId = 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 = item.externalIds.isrc
|
isrcCode = 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) {
|
||||||
@ -185,16 +226,39 @@ class ISRCProviders {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.body<SoundplateResponse>()
|
val body = res.decodeLogged<SoundplateResponse>("soundplate")
|
||||||
|
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? {
|
||||||
return runCatching { soundplate(track) }.getOrNull()
|
logger.i { "[ISRC:auto] start: ${track.describe()}" }
|
||||||
?: runCatching { ifpi(track) }.getOrNull()
|
val providers = listOf<Pair<String, suspend (MetadataTrack) -> String?>>(
|
||||||
?: runCatching { musicGateway(track) }.getOrNull()
|
"soundplate" to ::soundplate,
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -25,6 +25,7 @@ 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
|
||||||
|
|
||||||
@ -34,27 +35,61 @@ 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 isTitleMatch = track.title.lowercase() in video.title.lowercase()
|
val videoTitle = normalize(video.title)
|
||||||
val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() }
|
val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true)
|
||||||
val isAlbumInTitle =
|
val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), ""))
|
||||||
track.album != null && track.album!!.title.lowercase() in video.title.lowercase()
|
val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() }
|
||||||
val isUploaderMatch =
|
val title = coreTitle(track.title)
|
||||||
video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "")
|
|
||||||
val isDurationMatch =
|
val videoTokens = videoTitle.split(' ').toSet()
|
||||||
video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds
|
val isTitleMatch = title.isNotEmpty() &&
|
||||||
|
(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 (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds
|
if (durationDiff <= 10_000) confidence += 0.2f
|
||||||
|
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,
|
||||||
@ -125,25 +160,85 @@ 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" }
|
||||||
|
|
||||||
val isrcCode = track.isrcCode ?: isrcProvider.auto(track)
|
logger.i {
|
||||||
val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim()
|
"[Search] input track id=${track.id} title='${track.title}' artists=${track.artists.map { it.name }} " +
|
||||||
|
"album='${track.album?.title}' durationMs=${track.durationMs} trackIsrc=${track.isrcCode} " +
|
||||||
logger.i { "Searching for video with query: $searchQuery" }
|
"externalUri=${track.externalUri}"
|
||||||
val videos = newPipeService.searchVideos(isrcCode ?: searchQuery)
|
}
|
||||||
|
val rawIsrc = track.isrcCode ?: isrcProvider.auto(track)
|
||||||
logger.i { "Found ${videos.size} videos with query: $searchQuery" }
|
val isrcCode = rawIsrc
|
||||||
var sources = videos.map { transformVideo(track, it) }
|
?.trim()?.uppercase()?.replace("-", "")
|
||||||
|
?.takeIf { isrcRegex.matches(it) }
|
||||||
if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) {
|
logger.i {
|
||||||
logger.i { "ISRC search failed, fallback to title search" }
|
"[Search] isrc raw=$rawIsrc (from ${if (track.isrcCode != null) "track" else "providers"}) " +
|
||||||
val videos = newPipeService.searchVideos(searchQuery)
|
"validated=$isrcCode"
|
||||||
sources = videos.map { transformVideo(track, it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.i { "Found ${sources.size} sources with confidence >= 0.3f" }
|
val artistText = track.artists.joinToString(" ") { it.name }.trim()
|
||||||
|
val firstArtist = track.artists.firstOrNull()?.name?.trim().orEmpty()
|
||||||
|
val plainTitle = track.title.replace(Regex("[(\\[].*?[)\\]]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||||
|
|
||||||
// Return 5 highest to lowest confidence
|
// Ordered from most to least trusted. The ISRC is only a hint: a text search for it on
|
||||||
return sources.sortedByDescending { it.confidence }.take(5)
|
// YouTube Music often returns unrelated songs, so every step is scored against the track
|
||||||
|
// 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> {
|
||||||
|
|||||||
@ -84,7 +84,6 @@ 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
|
||||||
@ -143,8 +142,7 @@ 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 {
|
||||||
|
|||||||
@ -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,10 +103,9 @@ 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) {
|
||||||
MillenniumIndeterminateProgress(
|
MillenniumSpokeSpinner(
|
||||||
modifier = Modifier
|
size = 24.dp,
|
||||||
.size(24.dp)
|
modifier = Modifier.padding(end = 8.dp))
|
||||||
.padding(end = 8.dp))
|
|
||||||
} else {
|
} else {
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxRefreshRight,
|
imageVector = Iconsax.IconsaxRefreshRight,
|
||||||
@ -177,8 +176,7 @@ fun DevicesScreen(
|
|||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
MillenniumIndeterminateProgress(
|
MillenniumSpokeSpinner(size = 24.dp)
|
||||||
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)
|
||||||
@ -250,8 +248,7 @@ private fun DeviceRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
if (isConnecting) {
|
if (isConnecting) {
|
||||||
MillenniumIndeterminateProgress(
|
MillenniumSpokeSpinner(size = 24.dp)
|
||||||
modifier = Modifier.size(24.dp))
|
|
||||||
} else {
|
} else {
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||||
|
|||||||
@ -18,6 +18,11 @@
|
|||||||
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
|
||||||
@ -82,10 +87,12 @@ fun PlayDestinationPickerHost() {
|
|||||||
PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next)
|
PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next)
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumDialog(
|
MillenniumDialog(visible = true, onDismissRequest = controller::dismissPicker) {
|
||||||
visible = true,
|
MillenniumDialogHeader {
|
||||||
onDismissRequest = controller::dismissPicker,
|
MillenniumDialogTitle(stringResource(Res.string.devices_destination_title))
|
||||||
title = stringResource(Res.string.devices_destination_title)) {
|
MillenniumDialogClose()
|
||||||
|
}
|
||||||
|
MillenniumDialogBody {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@ -138,6 +145,9 @@ fun PlayDestinationPickerHost() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MillenniumDialogFooter {
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
onClick = controller::dismissPicker,
|
onClick = controller::dismissPicker,
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
style = MillenniumButtonStyle.Quiet) {
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
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
|
||||||
@ -604,11 +606,6 @@ 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 },
|
||||||
@ -620,7 +617,16 @@ 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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
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
|
||||||
@ -42,7 +43,6 @@ 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,9 +225,10 @@ internal fun DownloadListRow(
|
|||||||
},
|
},
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
if (item.status == DownloadStatus.Downloading) MillenniumProgress(
|
if (item.status == DownloadStatus.Downloading) MillenniumStripedProgress(
|
||||||
item.progress,
|
progress = item.progress,
|
||||||
Modifier.fillMaxWidth().height(4.dp)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
height = 6.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MillenniumIconButton(
|
MillenniumIconButton(
|
||||||
|
|||||||
@ -17,6 +17,9 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -421,25 +424,27 @@ 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) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -47,27 +48,23 @@ fun PlayDestinationPicker(
|
|||||||
onSuggestToJam: () -> Unit) {
|
onSuggestToJam: () -> Unit) {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
|
||||||
MillenniumDialog(
|
MillenniumAlertDialog(
|
||||||
visible = true,
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = stringResource(Res.string.jam_destination_title)) {
|
title = { Text(stringResource(Res.string.jam_destination_title)) },
|
||||||
Text(
|
description = { Text(stringResource(Res.string.jam_destination_message)) }) {
|
||||||
text = stringResource(Res.string.jam_destination_message),
|
MillenniumButton(
|
||||||
style = AppTheme.typography.bodyMedium)
|
onClick = onDismiss,
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
style = MillenniumButtonStyle.Quiet) {
|
||||||
MillenniumButton(
|
Text(stringResource(Res.string.common_cancel))
|
||||||
onClick = onDismiss,
|
}
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
MillenniumButton(
|
||||||
Text(stringResource(Res.string.common_cancel))
|
onClick = onPlayLocally,
|
||||||
}
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
MillenniumButton(
|
Text(stringResource(Res.string.jam_destination_play_here))
|
||||||
onClick = onPlayLocally,
|
}
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
MillenniumButton(onClick = onSuggestToJam) {
|
||||||
Text(stringResource(Res.string.jam_destination_play_here))
|
Text(stringResource(Res.string.jam_destination_suggest))
|
||||||
}
|
|
||||||
MillenniumButton(onClick = onSuggestToJam) {
|
|
||||||
Text(stringResource(Res.string.jam_destination_suggest))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,7 @@ 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
|
||||||
@ -116,14 +117,20 @@ 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 -> LibraryPlaylistsScreen(
|
LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
|
||||||
showCreatePlaylist = state.showCreatePlaylist,
|
LibraryPlaylistsScreen(
|
||||||
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
|
showCreatePlaylist = state.showCreatePlaylist,
|
||||||
viewMode = viewMode
|
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
|
||||||
)
|
viewMode = viewMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode)
|
LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
|
||||||
LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode)
|
LibraryAlbumsScreen(viewMode = viewMode)
|
||||||
|
}
|
||||||
|
LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
|
||||||
|
LibraryArtistsScreen(viewMode = viewMode)
|
||||||
|
}
|
||||||
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
|
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
|
||||||
LibraryTab.Downloads -> DownloadsScreen()
|
LibraryTab.Downloads -> DownloadsScreen()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
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
|
||||||
@ -36,6 +38,9 @@ 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
|
||||||
@ -48,7 +53,6 @@ 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
|
||||||
@ -58,7 +62,8 @@ 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()
|
||||||
@ -66,8 +71,10 @@ 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
|
||||||
@ -78,14 +85,15 @@ 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)
|
LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder, onManageFolders = onManageFolders)
|
||||||
when {
|
when {
|
||||||
!state.canAccessMedia -> MediaEmptyState(
|
!state.canAccessMedia -> MediaEmptyState(
|
||||||
Iconsax.IconsaxFolderOpen,
|
Iconsax.IconsaxFolderOpen,
|
||||||
@ -106,7 +114,7 @@ internal fun LocalTracksContent(
|
|||||||
Modifier.fillMaxSize().padding(bottom = inset),
|
Modifier.fillMaxSize().padding(bottom = inset),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
MillenniumIndeterminateProgress()
|
MillenniumCircularProgress(size = 40.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
|
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
|
||||||
@ -143,16 +151,35 @@ internal fun LocalTracksContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> MediaEmptyState(
|
else -> {
|
||||||
Iconsax.IconsaxFolderOpen,
|
val noFolders = state.currentFolderPath == null && state.query.isBlank()
|
||||||
state.error ?: stringResource(
|
MediaEmptyState(
|
||||||
when {
|
Iconsax.IconsaxFolderOpen,
|
||||||
state.query.isNotBlank() -> Res.string.library_local_no_results
|
state.error ?: stringResource(
|
||||||
state.currentFolderPath == null -> Res.string.library_local_empty_folders
|
when {
|
||||||
else -> Res.string.library_local_empty_tracks
|
state.query.isNotBlank() -> Res.string.library_local_no_results
|
||||||
}
|
state.currentFolderPath == null -> Res.string.library_local_empty_folders
|
||||||
), Modifier.padding(bottom = inset)
|
else -> Res.string.library_local_empty_tracks
|
||||||
)
|
}
|
||||||
|
), 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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -163,7 +190,8 @@ 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(
|
||||||
@ -237,7 +265,17 @@ 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) MillenniumIndeterminateProgress(Modifier.size(14.dp))
|
if (state.isRefreshing) MillenniumSpokeSpinner(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
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
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
|
||||||
@ -154,34 +155,25 @@ fun AddToPlaylistPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLargeScreen) {
|
val pickerTitle = stringResource(Res.string.library_add_to_playlist_title)
|
||||||
MillenniumDialog(
|
AdaptiveDialogBottomSheet(
|
||||||
visible = true,
|
onDismiss = onDismiss,
|
||||||
onDismissRequest = onDismiss,
|
modifier = modifier,
|
||||||
title = stringResource(Res.string.library_add_to_playlist_title),
|
dialogTitle = pickerTitle,
|
||||||
modifier = modifier) {
|
title = { Text(pickerTitle, style = AppTheme.typography.headlineSmall) },
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(400.dp)) {
|
actions = {
|
||||||
content()
|
Row(
|
||||||
}
|
modifier = Modifier.fillMaxWidth(),
|
||||||
MillenniumButton(
|
horizontalArrangement = Arrangement.End) {
|
||||||
onClick = onDismiss,
|
MillenniumButton(
|
||||||
style = MillenniumButtonStyle.Secondary) {
|
onClick = onDismiss,
|
||||||
Text(stringResource(Res.string.common_cancel))
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
}
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -162,90 +163,42 @@ fun PlaylistFormSheet(
|
|||||||
|
|
||||||
val handleImagePick = { imagePicker.launch() }
|
val handleImagePick = { imagePicker.launch() }
|
||||||
|
|
||||||
if (isLargeScreen) {
|
val formTitle = stringResource(
|
||||||
MillenniumDialog(
|
if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title)
|
||||||
visible = true,
|
AdaptiveDialogBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismiss = onDismiss,
|
||||||
title = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title),
|
modifier = modifier,
|
||||||
modifier = modifier
|
dialogTitle = formTitle,
|
||||||
) {
|
title = { Text(formTitle, style = AppTheme.typography.headlineSmall) },
|
||||||
PlaylistFormFields(
|
actions = {
|
||||||
nameState = nameState,
|
Row(
|
||||||
descriptionState = descriptionState,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
isPublic = isPublic,
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) {
|
||||||
onIsPublicChange = { isPublic = it },
|
MillenniumButton(
|
||||||
isCollaborating = isCollaborating,
|
onClick = onDismiss,
|
||||||
onIsCollaboratingChange = { isCollaborating = it },
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
imageBase64 = imageBase64,
|
Text(stringResource(Res.string.common_cancel))
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}) {
|
||||||
} else {
|
PlaylistFormFields(
|
||||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
nameState = nameState,
|
||||||
MillenniumModalBottomSheet(state = sheetState) {
|
descriptionState = descriptionState,
|
||||||
Column(
|
isPublic = isPublic,
|
||||||
modifier = Modifier
|
onIsPublicChange = { isPublic = it },
|
||||||
.fillMaxWidth()
|
isCollaborating = isCollaborating,
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
onIsCollaboratingChange = { isCollaborating = it },
|
||||||
) {
|
imageBase64 = imageBase64,
|
||||||
Text(
|
imagePreviewUrl = imagePreviewUrl,
|
||||||
text = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title),
|
onPickImage = handleImagePick)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,52 +18,56 @@
|
|||||||
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.MillenniumIndeterminateProgress
|
import org.spotube.millennium.composables.MillenniumSegmentedButton
|
||||||
|
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.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
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
|
||||||
@ -80,96 +84,87 @@ 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 positionMillis by viewModel.positionMillisFlow.collectAsState()
|
val colors = MillenniumTheme.colors
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
BoxWithConstraints(
|
||||||
Row(
|
modifier = modifier
|
||||||
modifier = Modifier
|
.fillMaxSize()
|
||||||
.fillMaxWidth()
|
.background(
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
Brush.verticalGradient(
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
0f to colors.accent.copy(alpha = 0.14f),
|
||||||
if (onClose != null) {
|
0.55f to colors.background,
|
||||||
MillenniumIconButton(
|
1f to colors.background
|
||||||
onClick = onClose,
|
)
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
)
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
val isWide = maxWidth >= 840.dp
|
||||||
imageVector = Iconsax.IconsaxArrowDown4,
|
|
||||||
contentDescription = stringResource(Res.string.common_close)
|
Column(Modifier.fillMaxSize()) {
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ApplicationBackButton()
|
|
||||||
}
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(50.dp))
|
.fillMaxWidth()
|
||||||
.background(AppTheme.colors.surfaceVariant)
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
.padding(3.dp),
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(3.dp)
|
Box(Modifier.width(48.dp), contentAlignment = Alignment.CenterStart) {
|
||||||
) {
|
if (onClose != null) {
|
||||||
LyricsModeTab(
|
MillenniumIconButton(
|
||||||
label = stringResource(Res.string.lyrics_mode_synced),
|
onClick = onClose,
|
||||||
isSelected = uiState.mode == LyricType.SYNCED,
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
onClick = { viewModel.setMode(LyricType.SYNCED) }
|
) {
|
||||||
)
|
MillenniumIcon(
|
||||||
LyricsModeTab(
|
imageVector = Iconsax.IconsaxArrowDown4,
|
||||||
label = stringResource(Res.string.lyrics_mode_plain),
|
contentDescription = stringResource(Res.string.common_close)
|
||||||
isSelected = uiState.mode == LyricType.STATIC,
|
)
|
||||||
onClick = { viewModel.setMode(LyricType.STATIC) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
} 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))
|
||||||
|
}
|
||||||
|
|
||||||
uiState.mode == LyricType.STATIC -> {
|
if (isWide) {
|
||||||
PlainLyricsContent(plainLyrics = uiState.plainLyrics ?: "")
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 40.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(40.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TrackPane(uiState, Modifier.weight(0.38f))
|
||||||
|
Column(Modifier.weight(0.62f).fillMaxHeight()) {
|
||||||
|
LyricsBody(uiState, viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CompactTrackHeader(
|
||||||
|
uiState,
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
LyricsBody(uiState, viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,151 +172,175 @@ fun LyricsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LyricsModeTab(
|
private fun ColumnScope.LyricsBody(uiState: LyricsUiState, viewModel: LyricsViewModel) {
|
||||||
label: String,
|
when {
|
||||||
isSelected: Boolean,
|
uiState.isLoading -> CenteredMessage {
|
||||||
onClick: () -> Unit) {
|
MillenniumBarsLoader(size = 48.dp)
|
||||||
Box(
|
}
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(50.dp))
|
uiState.error != null -> CenteredMessage {
|
||||||
.background(
|
Text(
|
||||||
if (isSelected) {
|
text = uiState.error ?: stringResource(Res.string.common_unknown_error),
|
||||||
AppTheme.colors.primary
|
style = AppTheme.typography.bodyLarge,
|
||||||
} else {
|
color = MillenniumTheme.colors.danger,
|
||||||
Color.Transparent
|
textAlign = TextAlign.Center
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.clickable(onClick = onClick)
|
}
|
||||||
.padding(horizontal = 18.dp, vertical = 7.dp)
|
|
||||||
) {
|
uiState.mode == LyricType.SYNCED -> SyncedLyricsContent(
|
||||||
Text(
|
syncedLyrics = uiState.syncedLyrics ?: emptyList(),
|
||||||
text = label,
|
currentIndex = uiState.currentIndex,
|
||||||
style = AppTheme.typography.labelLarge,
|
onLineClick = viewModel::seekToLine
|
||||||
fontWeight = FontWeight.SemiBold,
|
)
|
||||||
color = if (isSelected) {
|
|
||||||
AppTheme.colors.onPrimary
|
uiState.mode == LyricType.STATIC -> PlainLyricsContent(
|
||||||
} else {
|
plainLyrics = uiState.plainLyrics ?: ""
|
||||||
AppTheme.colors.onSurfaceVariant
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SyncedLyricsContent(
|
private fun CoverArt(url: String, size: Dp, radius: Dp, elevation: Dp, modifier: Modifier = Modifier) {
|
||||||
syncedLyrics: List<LyricsLine>,
|
val colors = MillenniumTheme.colors
|
||||||
currentIndex: Int,
|
Box(
|
||||||
onScrollToIndex: (Int) -> Unit) {
|
modifier = modifier
|
||||||
if (syncedLyrics.isEmpty()) {
|
.size(size)
|
||||||
Box(
|
.millenniumSurface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
background = millenniumPanelBrush(colors),
|
||||||
contentAlignment = Alignment.Center
|
shape = RoundedCornerShape(radius),
|
||||||
) {
|
borderColor = colors.border,
|
||||||
Text(
|
borderWidth = 1.dp,
|
||||||
text = stringResource(Res.string.lyrics_no_synced),
|
shadowElevation = elevation
|
||||||
style = AppTheme.typography.bodyLarge,
|
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
LaunchedEffect(currentIndex) {
|
|
||||||
if (currentIndex >= 0) {
|
|
||||||
listState.animateScrollToItem(
|
|
||||||
index = currentIndex.coerceIn(0, syncedLyrics.lastIndex),
|
|
||||||
scrollOffset = -100
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(vertical = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(syncedLyrics) { index, line ->
|
AsyncImage(
|
||||||
val isCurrentLine = index == currentIndex
|
model = url.takeIf { it.isNotBlank() },
|
||||||
Row(
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxWidth()
|
contentScale = ContentScale.Crop
|
||||||
.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
|
@Composable
|
||||||
private fun PlainLyricsContent(
|
private fun TrackPane(uiState: LyricsUiState, modifier: Modifier = Modifier) {
|
||||||
plainLyrics: String) {
|
Column(
|
||||||
if (plainLyrics.isBlank()) {
|
modifier = modifier.fillMaxHeight(),
|
||||||
Box(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.fillMaxSize(),
|
verticalArrangement = Arrangement.Center
|
||||||
contentAlignment = Alignment.Center
|
) {
|
||||||
) {
|
CoverArt(
|
||||||
|
url = uiState.coverUrl,
|
||||||
|
size = 280.dp,
|
||||||
|
radius = 24.dp,
|
||||||
|
elevation = 14.dp
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = uiState.trackTitle,
|
||||||
|
style = AppTheme.typography.headlineSmall,
|
||||||
|
color = MillenniumTheme.colors.text,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
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
|
||||||
|
private fun CompactTrackHeader(uiState: LyricsUiState, modifier: Modifier = Modifier) {
|
||||||
|
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(
|
||||||
text = stringResource(Res.string.lyrics_no_plain),
|
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>,
|
||||||
|
currentIndex: Int,
|
||||||
|
onLineClick: (LyricsLine) -> Unit) {
|
||||||
|
if (syncedLyrics.isEmpty()) {
|
||||||
|
CenteredMessage {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.lyrics_no_synced),
|
||||||
style = AppTheme.typography.bodyLarge,
|
style = AppTheme.typography.bodyLarge,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
color = MillenniumTheme.colors.muted,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
SyncedLyricsList(
|
||||||
|
lines = syncedLyrics,
|
||||||
|
currentIndex = currentIndex,
|
||||||
|
onLineClick = onLineClick,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.PlainLyricsContent(
|
||||||
|
plainLyrics: String) {
|
||||||
|
if (plainLyrics.isBlank()) {
|
||||||
|
CenteredMessage {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.lyrics_no_plain),
|
||||||
|
style = AppTheme.typography.bodyLarge,
|
||||||
|
color = MillenniumTheme.colors.muted,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp)
|
||||||
|
) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
text = plainLyrics,
|
text = plainLyrics,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
style = AppTheme.typography.titleMedium.copy(lineHeight = 34.sp),
|
||||||
.padding(horizontal = 16.dp),
|
color = MillenniumTheme.colors.text,
|
||||||
style = AppTheme.typography.bodyLarge.copy(lineHeight = 30.sp),
|
|
||||||
color = AppTheme.colors.onSurface,
|
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,13 +25,16 @@ 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 {
|
||||||
@ -44,22 +47,32 @@ 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,
|
||||||
audioPlayer: AudioPlayerInterface,
|
private val 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) }
|
||||||
}
|
}
|
||||||
@ -106,6 +119,17 @@ 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()
|
||||||
}
|
}
|
||||||
@ -113,17 +137,15 @@ class LyricsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentLyricIndex(positionMillis: Long): Int {
|
fun seekToLine(line: LyricsLine) {
|
||||||
val lyrics = _uiState.value.syncedLyrics ?: emptyList()
|
viewModelScope.launch {
|
||||||
if (lyrics.isEmpty()) return -1
|
audioPlayer.seekTo(line.time.milliseconds)
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -18,13 +18,15 @@
|
|||||||
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
|
||||||
@ -169,7 +171,7 @@ fun AddTracksToPlaylistDialog(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
contentAlignment = Alignment.Center) {
|
contentAlignment = Alignment.Center) {
|
||||||
MillenniumIndeterminateProgress()
|
MillenniumCircularProgress(size = 40.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasSearched && searchResults.isEmpty() -> {
|
hasSearched && searchResults.isEmpty() -> {
|
||||||
@ -209,33 +211,23 @@ fun AddTracksToPlaylistDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLargeScreen) {
|
val dialogTitle = stringResource(Res.string.playlist_add_tracks_title)
|
||||||
MillenniumDialog(
|
AdaptiveDialogBottomSheet(
|
||||||
visible = true,
|
onDismiss = onDismiss,
|
||||||
onDismissRequest = onDismiss,
|
modifier = modifier,
|
||||||
title = stringResource(Res.string.playlist_add_tracks_title),
|
dialogTitle = dialogTitle,
|
||||||
modifier = modifier) {
|
title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) },
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(500.dp)) {
|
actions = {
|
||||||
content()
|
Row(
|
||||||
}
|
modifier = Modifier.fillMaxWidth(),
|
||||||
MillenniumButton(onClick = onDismiss) {
|
horizontalArrangement = Arrangement.End) {
|
||||||
Text(stringResource(Res.string.playlist_add_tracks_done))
|
MillenniumButton(onClick = onDismiss) {
|
||||||
}
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,8 +289,7 @@ private fun TrackSearchResultRow(
|
|||||||
modifier = Modifier.size(24.dp))
|
modifier = Modifier.size(24.dp))
|
||||||
}
|
}
|
||||||
isAdding -> {
|
isAdding -> {
|
||||||
MillenniumIndeterminateProgress(
|
MillenniumSpokeSpinner(size = 24.dp)
|
||||||
modifier = Modifier.size(24.dp))
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* 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" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,13 +18,15 @@
|
|||||||
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
|
||||||
@ -85,6 +87,22 @@ 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
|
||||||
@ -267,7 +285,7 @@ internal fun PluginScreenContent(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) }
|
) { MillenniumCircularProgress(size = 40.dp) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is PluginUiState.Data -> {
|
is PluginUiState.Data -> {
|
||||||
@ -492,7 +510,7 @@ internal fun PluginScreenContent(
|
|||||||
Modifier.fillMaxWidth().padding(16.dp),
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
MillenniumIndeterminateProgress(Modifier.widthIn(max = 120.dp))
|
MillenniumDotsLoader()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -524,7 +542,7 @@ internal fun PluginScreenContent(
|
|||||||
.padding(vertical = 16.dp),
|
.padding(vertical = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 120.dp))
|
MillenniumDotsLoader()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -606,24 +624,17 @@ private fun InstallSheet(
|
|||||||
AdaptiveDialogBottomSheet(
|
AdaptiveDialogBottomSheet(
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
title = {
|
title = {
|
||||||
Row(
|
PluginDialogTitle(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
title = stringResource(Res.string.plugin_install_section_title),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
icon = Iconsax.IconsaxBoxAdd)
|
||||||
) {
|
},
|
||||||
MillenniumIcon(
|
dialogTitle = stringResource(Res.string.plugin_install_section_title)) {
|
||||||
Iconsax.IconsaxImportArrow2Bulk,
|
PluginDialogBody {
|
||||||
contentDescription = null,
|
Text(
|
||||||
modifier = Modifier.size(18.dp),
|
stringResource(Res.string.plugin_install_sheet_subtitle),
|
||||||
tint = AppTheme.colors.primary
|
style = AppTheme.typography.bodySmall,
|
||||||
)
|
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) {
|
||||||
@ -636,51 +647,82 @@ private fun InstallSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
PluginPanel {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Column(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
Row(
|
||||||
MillenniumTextField(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
state = urlState,
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
label = stringResource(Res.string.plugin_section_url_title),
|
PluginIconTile(Iconsax.IconsaxLink)
|
||||||
modifier = Modifier.weight(1f),
|
Text(
|
||||||
placeholder = stringResource(Res.string.plugin_url_placeholder),
|
stringResource(Res.string.plugin_section_url_title),
|
||||||
error = urlError?.text())
|
style = AppTheme.typography.titleSmall,
|
||||||
MillenniumIconButton(
|
fontWeight = FontWeight.SemiBold)
|
||||||
onClick = onSubmitUrl,
|
}
|
||||||
enabled = !isLoadingUrl,
|
MillenniumTextField(
|
||||||
style = MillenniumIconButtonStyle.Secondary) {
|
state = urlState,
|
||||||
if (isLoadingUrl) {
|
label = "",
|
||||||
MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp))
|
modifier = Modifier.fillMaxWidth(),
|
||||||
} else {
|
placeholder = stringResource(Res.string.plugin_url_placeholder),
|
||||||
MillenniumIcon(
|
error = urlError?.text())
|
||||||
Iconsax.IconsaxImportArrow2Bulk,
|
MillenniumButton(
|
||||||
contentDescription = stringResource(Res.string.plugin_action_download))
|
onClick = onSubmitUrl,
|
||||||
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumSeparator()
|
Row(
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(Res.string.plugin_section_file_title),
|
|
||||||
style = AppTheme.typography.labelLarge,
|
|
||||||
color = AppTheme.colors.onSurfaceVariant
|
|
||||||
)
|
|
||||||
MillenniumButton(
|
|
||||||
onClick = onPickFile,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MillenniumButtonStyle.Secondary) {
|
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)) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.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(
|
||||||
Iconsax.IconsaxExportArrowBulk,
|
FeatherIcons.ChevronRight,
|
||||||
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(18.dp),
|
||||||
Text(stringResource(Res.string.plugin_action_install_from_file))
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -692,84 +734,82 @@ private fun PluginInfoDialog(
|
|||||||
plugin: PluginEntry,
|
plugin: PluginEntry,
|
||||||
logoPath: Path?,
|
logoPath: Path?,
|
||||||
onDismiss: () -> Unit) {
|
onDismiss: () -> Unit) {
|
||||||
MillenniumDialog(
|
AdaptiveDialogBottomSheet(
|
||||||
visible = true,
|
onDismiss = onDismiss,
|
||||||
onDismissRequest = onDismiss,
|
title = { PluginDialogTitle(stringResource(Res.string.plugin_info_dialog_title)) },
|
||||||
title = plugin.name) {
|
dialogTitle = stringResource(Res.string.plugin_info_dialog_title),
|
||||||
Box(
|
actions = {
|
||||||
modifier = Modifier
|
PluginDialogActions(
|
||||||
.size(48.dp)
|
secondaryLabel = stringResource(Res.string.common_close),
|
||||||
.clip(RoundedCornerShape(12.dp))
|
onSecondary = onDismiss)
|
||||||
.background(AppTheme.colors.primary.copy(alpha = 0.12f))
|
}) {
|
||||||
) {
|
PluginDialogBody {
|
||||||
PluginLogo(
|
PluginPanel {
|
||||||
plugin = plugin,
|
Row(
|
||||||
logoPath = logoPath,
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
contentDescription = plugin.name,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxSize(),
|
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
fallback = {
|
PluginLogoTile(plugin = plugin, logoPath = logoPath)
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Column(
|
||||||
MillenniumIcon(
|
modifier = Modifier.weight(1f),
|
||||||
Iconsax.IconsaxBox,
|
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
contentDescription = null,
|
Text(
|
||||||
modifier = Modifier.size(24.dp),
|
plugin.name,
|
||||||
tint = AppTheme.colors.primary
|
style = AppTheme.typography.titleMedium,
|
||||||
)
|
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()) {
|
||||||
Column(
|
Text(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
plugin.description,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
|
||||||
) {
|
style = AppTheme.typography.bodySmall,
|
||||||
if (plugin.description.isNotBlank()) {
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugin.contact.isNotBlank()) {
|
PluginPanel {
|
||||||
DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact)
|
Column(
|
||||||
}
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
if (plugin.bugs.isNotBlank()) {
|
DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion)
|
||||||
ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) {
|
if (plugin.license.isNotBlank()) {
|
||||||
openUrlInBrowser(plugin.bugs)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -779,27 +819,30 @@ private fun SupportDialog(
|
|||||||
supportText: String?,
|
supportText: String?,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
onDismiss: () -> Unit) {
|
onDismiss: () -> Unit) {
|
||||||
MillenniumDialog(
|
AdaptiveDialogBottomSheet(
|
||||||
visible = true,
|
onDismiss = onDismiss,
|
||||||
onDismissRequest = onDismiss,
|
title = { PluginDialogTitle(stringResource(Res.string.plugin_support_dialog_title, plugin.name)) },
|
||||||
title = stringResource(Res.string.plugin_support_dialog_title, plugin.name)) {
|
dialogTitle = stringResource(Res.string.plugin_support_dialog_title, plugin.name),
|
||||||
if (isLoading) {
|
actions = {
|
||||||
Box(
|
PluginDialogActions(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
|
secondaryLabel = stringResource(Res.string.common_close),
|
||||||
contentAlignment = Alignment.Center
|
onSecondary = onDismiss)
|
||||||
) {
|
}) {
|
||||||
MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp))
|
PluginDialogBody {
|
||||||
}
|
if (isLoading) {
|
||||||
} else {
|
Box(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
|
||||||
supportText ?: "",
|
contentAlignment = Alignment.Center) {
|
||||||
style = AppTheme.typography.bodyMedium,
|
MillenniumCircularProgress(size = 36.dp)
|
||||||
color = AppTheme.colors.onSurface
|
}
|
||||||
)
|
} else {
|
||||||
}
|
PluginPanel {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Text(
|
||||||
MillenniumButton(onClick = onDismiss) {
|
supportText ?: "",
|
||||||
Text(stringResource(Res.string.common_close))
|
modifier = Modifier.padding(14.dp),
|
||||||
|
style = AppTheme.typography.bodyMedium,
|
||||||
|
color = AppTheme.colors.onSurface)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -972,7 +1015,7 @@ private fun DiscoverRepoCard(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
if (isInstalling) {
|
if (isInstalling) {
|
||||||
MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp))
|
MillenniumSpokeSpinner(size = 16.dp)
|
||||||
} else {
|
} else {
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
Iconsax.IconsaxAdd,
|
Iconsax.IconsaxAdd,
|
||||||
|
|||||||
@ -0,0 +1,249 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.plugin.components
|
package dev.krtirtho.spotube.modules.plugin.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import org.spotube.millennium.composables.MillenniumCircularProgress
|
||||||
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,10 +32,6 @@ 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
|
||||||
@ -52,19 +48,22 @@ 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 dev.krtirtho.spotube.modules.plugin.GitHubRepo
|
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.GitHubRelease
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.GitHubRepo
|
||||||
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
|
||||||
@ -88,21 +87,24 @@ fun PluginInstallDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumDialog(
|
AdaptiveDialogBottomSheet(
|
||||||
visible = true,
|
onDismiss = onDismiss,
|
||||||
onDismissRequest = onDismiss,
|
title = { PluginDialogTitle(stringResource(Res.string.plugin_action_install)) },
|
||||||
title = repo.fullName) {
|
dialogTitle = stringResource(Res.string.plugin_action_install),
|
||||||
Row(
|
actions = {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
PluginDialogActions(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
secondaryLabel = stringResource(Res.string.common_cancel),
|
||||||
) {
|
onSecondary = onDismiss,
|
||||||
Box(
|
primaryLabel = stringResource(Res.string.plugin_action_install),
|
||||||
modifier = Modifier
|
onPrimary = { selectedRelease?.let(onInstall) },
|
||||||
.size(48.dp)
|
primaryEnabled = selectedRelease != null && !isLoadingReleases)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
}) {
|
||||||
.background(AppTheme.colors.primary.copy(alpha = 0.12f))
|
PluginDialogBody {
|
||||||
) {
|
PluginPanel {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Row(
|
||||||
|
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)
|
||||||
@ -110,131 +112,99 @@ fun PluginInstallDialog(
|
|||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = repo.owner.login,
|
contentDescription = repo.owner.login,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)))
|
||||||
)
|
Column(
|
||||||
}
|
modifier = Modifier.weight(1f),
|
||||||
}
|
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Text(
|
||||||
Text(
|
repo.fullName,
|
||||||
repo.fullName,
|
style = AppTheme.typography.titleMedium,
|
||||||
style = AppTheme.typography.titleMedium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontWeight = FontWeight.SemiBold,
|
maxLines = 1,
|
||||||
maxLines = 1,
|
overflow = TextOverflow.Ellipsis)
|
||||||
overflow = TextOverflow.Ellipsis
|
if (!repo.description.isNullOrBlank()) {
|
||||||
)
|
|
||||||
if (!repo.description.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
repo.description,
|
|
||||||
style = AppTheme.typography.bodySmall,
|
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoadingReleases) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().height(120.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp))
|
|
||||||
}
|
|
||||||
} 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(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
selectedRelease?.tagName
|
repo.description,
|
||||||
?: stringResource(Res.string.plugin_install_select_release_placeholder),
|
|
||||||
style = AppTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = Iconsax.IconsaxArrowDown4,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
selectedRelease?.let { release ->
|
|
||||||
if (release.prerelease) {
|
|
||||||
Text(
|
|
||||||
stringResource(Res.string.plugin_install_prerelease),
|
|
||||||
style = AppTheme.typography.labelSmall,
|
|
||||||
color = AppTheme.colors.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!release.body.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
stringResource(Res.string.plugin_install_release_notes),
|
|
||||||
style = AppTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = AppTheme.colors.onSurface
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
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,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = AppTheme.colors.onSurfaceVariant
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
)
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
if (isLoadingReleases) {
|
||||||
MillenniumButton(
|
Box(
|
||||||
onClick = { selectedRelease?.let { onInstall(it) } },
|
modifier = Modifier.fillMaxWidth().height(96.dp),
|
||||||
enabled = selectedRelease != null && !isLoadingReleases
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
MillenniumCircularProgress(size = 36.dp)
|
||||||
Text(stringResource(Res.string.plugin_action_install))
|
}
|
||||||
|
} else if (releases.isEmpty()) {
|
||||||
|
PluginCallout(stringResource(Res.string.plugin_install_no_releases), isWarning = true)
|
||||||
|
} else {
|
||||||
|
PluginSectionLabel(stringResource(Res.string.plugin_install_select_release_label))
|
||||||
|
MillenniumDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
anchor = {
|
||||||
|
MillenniumButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(
|
||||||
|
selectedRelease?.tagName
|
||||||
|
?: stringResource(Res.string.plugin_install_select_release_placeholder),
|
||||||
|
style = AppTheme.typography.bodyMedium)
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = Iconsax.IconsaxArrowDown4,
|
||||||
|
contentDescription = null,
|
||||||
|
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 ->
|
||||||
|
if (release.prerelease) {
|
||||||
|
PluginCallout(stringResource(Res.string.plugin_install_prerelease), isWarning = true)
|
||||||
|
}
|
||||||
|
if (!release.body.isNullOrBlank()) {
|
||||||
|
PluginSectionLabel(stringResource(Res.string.plugin_install_release_notes))
|
||||||
|
PluginPanel {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 200.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
release.body,
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,47 +17,39 @@
|
|||||||
|
|
||||||
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 okio.Path
|
import com.composeunstyled.Text
|
||||||
|
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.IconsaxBoxAdd
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
|
||||||
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
|
||||||
@ -70,8 +62,6 @@ 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(
|
||||||
@ -83,129 +73,80 @@ fun PluginPermissionDialog(
|
|||||||
logoPath: Path? = null,
|
logoPath: Path? = null,
|
||||||
onConfirm: (() -> Unit)? = null,
|
onConfirm: (() -> Unit)? = null,
|
||||||
onDismiss: () -> Unit) {
|
onDismiss: () -> Unit) {
|
||||||
MillenniumDialog(
|
AdaptiveDialogBottomSheet(
|
||||||
visible = true,
|
onDismiss = onDismiss,
|
||||||
onDismissRequest = onDismiss,
|
title = { PluginDialogTitle(title) },
|
||||||
title = title) {
|
dialogTitle = title,
|
||||||
Row(
|
actions = {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
PluginDialogActions(
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
secondaryLabel = stringResource(
|
||||||
) {
|
if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel),
|
||||||
Box(
|
onSecondary = onDismiss,
|
||||||
modifier = Modifier
|
primaryLabel = confirmLabel,
|
||||||
.size(48.dp)
|
onPrimary = onConfirm)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
}) {
|
||||||
.background(AppTheme.colors.primary.copy(alpha = 0.12f)),
|
PluginDialogBody {
|
||||||
contentAlignment = Alignment.Center
|
PluginPanel {
|
||||||
) {
|
Row(
|
||||||
PluginLogo(
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
plugin = pluginInfo,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
logoPath = logoPath,
|
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
contentDescription = pluginInfo.name,
|
PluginLogoTile(plugin = pluginInfo, logoPath = logoPath)
|
||||||
modifier = Modifier.fillMaxSize(),
|
Column(
|
||||||
fallback = {
|
modifier = Modifier.weight(1f),
|
||||||
MillenniumIcon(
|
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
imageVector = Iconsax.IconsaxBoxAdd,
|
Text(
|
||||||
contentDescription = null,
|
pluginInfo.name,
|
||||||
tint = AppTheme.colors.primary,
|
style = AppTheme.typography.titleMedium,
|
||||||
modifier = Modifier.size(24.dp)
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
maxLines = 1,
|
||||||
})
|
overflow = TextOverflow.Ellipsis)
|
||||||
}
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
PluginPill(pluginInfo.author, icon = Iconsax.User)
|
||||||
Text(
|
PluginPill(
|
||||||
pluginInfo.name,
|
stringResource(Res.string.plugin_version_label, pluginInfo.version),
|
||||||
style = AppTheme.typography.titleMedium,
|
tint = AppTheme.colors.primary)
|
||||||
fontWeight = FontWeight.SemiBold,
|
}
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pluginInfo.description.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
pluginInfo.description,
|
|
||||||
style = AppTheme.typography.bodySmall,
|
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MillenniumCard(contentPadding = PaddingValues(14.dp)) {
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style = AppTheme.typography.bodySmall,
|
|
||||||
color = AppTheme.colors.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (confirmLabel != null && onConfirm != null) {
|
|
||||||
MillenniumButton(onClick = onConfirm) {
|
PluginCallout(text = message, isWarning = confirmLabel == null)
|
||||||
Text(confirmLabel)
|
|
||||||
|
existingPlugin?.let {
|
||||||
|
InstalledComparisonPanel(installedPlugin = it, incomingPlugin = pluginInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginSectionLabel(
|
||||||
|
stringResource(Res.string.plugin_permissions_requested_title),
|
||||||
|
badge = pluginInfo.capabilities.size.takeIf { it > 0 }?.toString())
|
||||||
|
|
||||||
|
PluginPanel {
|
||||||
|
if (pluginInfo.capabilities.isEmpty()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
PluginIconTile(Iconsax.IconsaxCheckCircle)
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.plugin_permissions_none),
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pluginInfo.capabilities.forEachIndexed { index, capability ->
|
||||||
|
if (index > 0) MillenniumSeparator()
|
||||||
|
CapabilityRow(capability)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,57 +154,58 @@ fun PluginPermissionDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InstalledComparisonCard(
|
private fun InstalledComparisonPanel(
|
||||||
installedPlugin: PluginEntry,
|
installedPlugin: PluginEntry,
|
||||||
incomingPlugin: PluginEntry) {
|
incomingPlugin: PluginEntry) {
|
||||||
MillenniumCard(
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
PluginSectionLabel(stringResource(Res.string.plugin_permissions_compare_title))
|
||||||
contentPadding = PaddingValues(14.dp)) {
|
PluginPanel {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
) {
|
Row(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
stringResource(Res.string.plugin_permissions_compare_title),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = AppTheme.typography.labelMedium,
|
horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
fontWeight = FontWeight.SemiBold)
|
VersionStat(
|
||||||
Row(
|
label = stringResource(Res.string.plugin_permissions_installed_label),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
value = installedPlugin.version)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
MillenniumIcon(
|
||||||
) {
|
imageVector = FeatherIcons.ArrowRight,
|
||||||
VersionStat(
|
contentDescription = null,
|
||||||
label = stringResource(Res.string.plugin_permissions_installed_label),
|
modifier = Modifier.size(18.dp),
|
||||||
value = installedPlugin.version)
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
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) {
|
private fun VersionStat(label: String, value: String, highlighted: Boolean = false) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(
|
||||||
|
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.bodyMedium,
|
style = AppTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
color = if (highlighted) AppTheme.colors.primary else AppTheme.colors.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,54 +215,31 @@ 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))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumCard(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
contentPadding = PaddingValues(12.dp)) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Row(
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
PluginIconTile(icon)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
Text(
|
||||||
) {
|
label,
|
||||||
Box(
|
style = AppTheme.typography.bodyMedium,
|
||||||
modifier = Modifier
|
fontWeight = FontWeight.Medium)
|
||||||
.size(34.dp)
|
Text(
|
||||||
.clip(CircleShape)
|
description,
|
||||||
.background(AppTheme.colors.errorContainer.copy(alpha = 0.6f)),
|
style = AppTheme.typography.labelSmall,
|
||||||
contentAlignment = Alignment.Center
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
) {
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,269 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
MillenniumIndeterminateProgress(
|
MillenniumMarqueeProgress(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 32.dp, vertical = 16.dp)
|
.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||||
|
|||||||
@ -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) {
|
||||||
MillenniumIndeterminateProgress()
|
MillenniumCircularProgress(size = 40.dp)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|||||||
@ -18,6 +18,12 @@
|
|||||||
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
|
||||||
@ -238,26 +244,30 @@ internal fun TextInputSettingCard(
|
|||||||
val normalizedValue = normalize(draft)
|
val normalizedValue = normalize(draft)
|
||||||
val errorMessage = validate(normalizedValue)
|
val errorMessage = validate(normalizedValue)
|
||||||
|
|
||||||
MillenniumDialog(
|
MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) {
|
||||||
visible = true,
|
MillenniumDialogHeader {
|
||||||
onDismissRequest = { isDialogOpen = false },
|
MillenniumDialogTitle(dialogTitle)
|
||||||
title = dialogTitle
|
MillenniumDialogClose()
|
||||||
) {
|
}
|
||||||
if (dialogDescription != null) {
|
MillenniumDialogBody {
|
||||||
Text(
|
if (dialogDescription != null) {
|
||||||
text = dialogDescription,
|
MillenniumDialogDescription(dialogDescription)
|
||||||
style = AppTheme.typography.bodySmall,
|
}
|
||||||
color = AppTheme.colors.onSurfaceVariant
|
MillenniumTextField(
|
||||||
|
state = dialogState,
|
||||||
|
label = title,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = placeholder,
|
||||||
|
error = errorMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MillenniumTextField(
|
MillenniumDialogFooter {
|
||||||
state = dialogState,
|
MillenniumButton(
|
||||||
label = title,
|
onClick = { isDialogOpen = false },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
style = MillenniumButtonStyle.Secondary
|
||||||
placeholder = placeholder,
|
) {
|
||||||
error = errorMessage
|
Text(stringResource(Res.string.common_cancel))
|
||||||
)
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (errorMessage == null) {
|
if (errorMessage == null) {
|
||||||
@ -269,12 +279,6 @@ 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,12 @@ 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
|
||||||
@ -255,3 +261,24 @@ 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -17,6 +17,11 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -151,10 +156,12 @@ private fun AppearanceSettingCard(
|
|||||||
mutableStateOf(selectedPalette)
|
mutableStateOf(selectedPalette)
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumDialog(
|
MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) {
|
||||||
visible = true,
|
MillenniumDialogHeader {
|
||||||
onDismissRequest = { isDialogOpen = false },
|
MillenniumDialogTitle(stringResource(Res.string.settings_palette_dialog_title))
|
||||||
title = stringResource(Res.string.settings_palette_dialog_title)) {
|
MillenniumDialogClose()
|
||||||
|
}
|
||||||
|
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)
|
||||||
@ -174,9 +181,13 @@ private fun AppearanceSettingCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
PalettePreview(draftPalette)
|
PalettePreview(draftPalette)
|
||||||
|
}
|
||||||
MillenniumSeparator()
|
MillenniumDialogFooter {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
MillenniumButton(
|
||||||
|
onClick = { isDialogOpen = false },
|
||||||
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
|
MillenniumText(stringResource(Res.string.common_cancel))
|
||||||
|
}
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
onSaved(draftPalette)
|
onSaved(draftPalette)
|
||||||
@ -184,11 +195,6 @@ 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,6 @@ 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
|
||||||
@ -31,8 +30,7 @@ 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.LocalMediaFoldersUiState
|
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost
|
||||||
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
|
||||||
@ -185,8 +183,6 @@ 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(
|
||||||
@ -214,30 +210,5 @@ private fun LocalMediaFoldersSettingCard(
|
|||||||
onClick = viewModel::open
|
onClick = viewModel::open
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.isOpen) {
|
LocalMediaFoldersDialogHost(viewModel)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,11 +39,8 @@ 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
|
||||||
@ -62,14 +59,12 @@ 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
|
||||||
@ -99,6 +94,7 @@ 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
|
||||||
@ -289,7 +285,8 @@ 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(
|
||||||
@ -658,7 +655,6 @@ fun AppExpandedPlayer(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp),
|
||||||
audioPlayer = audioPlayer,
|
|
||||||
onExpand = onExpandLyrics
|
onExpand = onExpandLyrics
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -689,105 +685,50 @@ 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
|
||||||
|
|
||||||
Box(
|
MillenniumCard(
|
||||||
modifier = modifier
|
modifier = modifier.clickable(onClick = onExpand),
|
||||||
.millenniumSurface(
|
contentPadding = PaddingValues(12.dp),
|
||||||
background = SolidColor(AppTheme.colors.surfaceContainerHighest),
|
contentSpacing = 8.dp,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shadowElevation = 2.dp
|
||||||
shadowElevation = 2.dp
|
|
||||||
)
|
|
||||||
.clickable(onClick = onExpand)
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Row(
|
||||||
Row(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
verticalAlignment = Alignment.CenterVertically
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.player_lyrics),
|
||||||
|
style = AppTheme.typography.labelMedium,
|
||||||
|
color = MillenniumTheme.colors.muted
|
||||||
|
)
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onExpand,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
) {
|
) {
|
||||||
Text(
|
MillenniumIcon(
|
||||||
text = stringResource(Res.string.player_lyrics),
|
Iconsax.IconsaxArrowSquareUp,
|
||||||
style = AppTheme.typography.labelMedium,
|
contentDescription = stringResource(Res.string.player_expand_lyrics_content_description),
|
||||||
color = AppTheme.colors.onSurfaceVariant
|
tint = MillenniumTheme.colors.muted
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@
|
|||||||
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
|
||||||
@ -436,27 +440,6 @@ 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(
|
||||||
@ -471,7 +454,31 @@ 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(
|
||||||
|
|||||||
@ -21,6 +21,7 @@ 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
|
||||||
@ -166,7 +167,11 @@ fun AppSidebar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MillenniumSidebarFooter {
|
// Compact the footer chrome only (padding + gap between items); items keep their size.
|
||||||
|
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,
|
||||||
|
|||||||
@ -18,11 +18,16 @@
|
|||||||
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
|
||||||
@ -128,11 +133,12 @@ private fun SleepTimerDialog(
|
|||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
onCancelTimer: () -> Unit,
|
onCancelTimer: () -> Unit,
|
||||||
onDismiss: () -> Unit) {
|
onDismiss: () -> Unit) {
|
||||||
MillenniumDialog(
|
MillenniumDialog(visible = true, onDismissRequest = onDismiss) {
|
||||||
visible = true,
|
MillenniumDialogHeader {
|
||||||
onDismissRequest = onDismiss,
|
MillenniumDialogTitle(stringResource(Res.string.player_sleep_timer))
|
||||||
title = stringResource(Res.string.player_sleep_timer)) {
|
MillenniumDialogClose()
|
||||||
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)),
|
||||||
@ -152,21 +158,20 @@ 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())
|
||||||
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
MillenniumDialogFooter {
|
||||||
if (remainingMs != null) {
|
if (remainingMs != null) {
|
||||||
MillenniumButton(
|
|
||||||
onClick = onCancelTimer,
|
|
||||||
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.player_sleep_timer_cancel)) }
|
|
||||||
}
|
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
onClick = onDismiss,
|
onClick = onCancelTimer,
|
||||||
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) }
|
style = MillenniumButtonStyle.Quiet) { Text(stringResource(Res.string.player_sleep_timer_cancel)) }
|
||||||
MillenniumButton(
|
}
|
||||||
onClick = onStart,
|
MillenniumButton(
|
||||||
enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) {
|
onClick = onDismiss,
|
||||||
Text(stringResource(Res.string.player_sleep_timer_start))
|
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) }
|
||||||
}
|
MillenniumButton(
|
||||||
|
onClick = onStart,
|
||||||
|
enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) {
|
||||||
|
Text(stringResource(Res.string.player_sleep_timer_start))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,11 +181,12 @@ private fun SleepTimerDialog(
|
|||||||
private fun TrackDetailsDialog(
|
private fun TrackDetailsDialog(
|
||||||
details: PlayerTrackDetails,
|
details: PlayerTrackDetails,
|
||||||
onDismiss: () -> Unit) {
|
onDismiss: () -> Unit) {
|
||||||
MillenniumDialog(
|
MillenniumDialog(visible = true, onDismissRequest = onDismiss) {
|
||||||
visible = true,
|
MillenniumDialogHeader {
|
||||||
onDismissRequest = onDismiss,
|
MillenniumDialogTitle(stringResource(Res.string.player_track_details))
|
||||||
title = stringResource(Res.string.player_track_details)) {
|
MillenniumDialogClose()
|
||||||
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,
|
||||||
@ -189,8 +195,7 @@ 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) {
|
||||||
MillenniumIndeterminateProgress(
|
MillenniumMarqueeProgress(modifier = Modifier.fillMaxWidth())
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,6 +320,8 @@ 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)) }
|
||||||
|
|||||||
@ -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,8 +98,7 @@ fun AlternativeTrackContent(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
MillenniumIndeterminateProgress(
|
MillenniumSpokeSpinner(size = 24.dp)
|
||||||
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),
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
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
|
||||||
@ -419,11 +420,6 @@ 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 },
|
||||||
@ -434,7 +430,16 @@ 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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -99,7 +99,8 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,12 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -103,49 +109,50 @@ fun SupportDialog(
|
|||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
isReminder: Boolean = false) {
|
isReminder: Boolean = false) {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
MillenniumDialog(
|
MillenniumDialog(visible = true, onDismissRequest = onDismissRequest) {
|
||||||
visible = true,
|
MillenniumDialogHeader {
|
||||||
onDismissRequest = onDismissRequest,
|
MillenniumDialogTitle(stringResource(Res.string.support_dialog_title))
|
||||||
title = stringResource(Res.string.support_dialog_title)) {
|
MillenniumDialogClose()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
donationOptions.forEach { option ->
|
MillenniumDialogBody {
|
||||||
MillenniumButton(
|
MillenniumDialogDescription(stringResource(Res.string.support_dialog_message))
|
||||||
onClick = {
|
if (isReminder) {
|
||||||
openDonation(option)
|
Text(
|
||||||
onDismissRequest()
|
stringResource(Res.string.support_dialog_reminder_hint),
|
||||||
},
|
style = AppTheme.typography.bodySmall,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
style = MillenniumButtonStyle.Secondary) {
|
}
|
||||||
Row(
|
donationOptions.forEach { option ->
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
MillenniumButton(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
onClick = {
|
||||||
MillenniumIcon(
|
openDonation(option)
|
||||||
imageVector = option.icon,
|
onDismissRequest()
|
||||||
contentDescription = null,
|
},
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tint = if (option.id == "patreon") {
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
AppTheme.colors.onSurface
|
Row(
|
||||||
} else {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Color.Unspecified
|
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
})
|
MillenniumIcon(
|
||||||
Text(stringResource(option.title))
|
imageVector = option.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = if (option.id == "patreon") {
|
||||||
|
AppTheme.colors.onSurface
|
||||||
|
} else {
|
||||||
|
Color.Unspecified
|
||||||
|
})
|
||||||
|
Text(stringResource(option.title))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MillenniumButton(
|
MillenniumDialogFooter {
|
||||||
onClick = onDismissRequest,
|
MillenniumButton(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = onDismissRequest,
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
style = MillenniumButtonStyle.Quiet) {
|
||||||
Text(stringResource(Res.string.support_dialog_dismiss))
|
Text(stringResource(Res.string.support_dialog_dismiss))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,19 +17,14 @@
|
|||||||
|
|
||||||
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 com.composeunstyled.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import com.composeunstyled.Text
|
||||||
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
|
||||||
@ -47,28 +42,26 @@ fun AppUpdateDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onIgnore: () -> Unit,
|
onIgnore: () -> Unit,
|
||||||
onUpdate: () -> Unit) {
|
onUpdate: () -> Unit) {
|
||||||
MillenniumDialog(
|
MillenniumAlertDialog(
|
||||||
visible = true,
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = stringResource(Res.string.app_update_dialog_title, update.tag)) {
|
title = { Text(stringResource(Res.string.app_update_dialog_title, update.tag)) },
|
||||||
if (update.releaseNotesMarkdown.isNotBlank()) {
|
content = {
|
||||||
SelectionContainer {
|
if (update.releaseNotesMarkdown.isNotBlank()) {
|
||||||
Markdown(
|
SelectionContainer {
|
||||||
content = update.releaseNotesMarkdown,
|
Markdown(content = update.releaseNotesMarkdown)
|
||||||
modifier = Modifier.padding(bottom = 8.dp))
|
}
|
||||||
|
} else {
|
||||||
|
Text(stringResource(Res.string.app_update_dialog_no_notes))
|
||||||
}
|
}
|
||||||
} else {
|
}) {
|
||||||
Text(stringResource(Res.string.app_update_dialog_no_notes))
|
MillenniumButton(
|
||||||
|
onClick = onIgnore,
|
||||||
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
|
Text(stringResource(Res.string.update_action_ignore))
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
MillenniumButton(onClick = onUpdate) {
|
||||||
MillenniumButton(
|
Text(stringResource(Res.string.update_action_update))
|
||||||
onClick = onIgnore,
|
|
||||||
style = MillenniumButtonStyle.Secondary) {
|
|
||||||
Text(stringResource(Res.string.update_action_ignore))
|
|
||||||
}
|
|
||||||
MillenniumButton(onClick = onUpdate) {
|
|
||||||
Text(stringResource(Res.string.update_action_update))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -30,7 +31,6 @@ 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,33 +50,35 @@ fun PluginUpdateDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onIgnore: () -> Unit,
|
onIgnore: () -> Unit,
|
||||||
onUpdate: () -> Unit) {
|
onUpdate: () -> Unit) {
|
||||||
MillenniumDialog(
|
MillenniumAlertDialog(
|
||||||
visible = true,
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = stringResource(
|
title = {
|
||||||
Res.string.plugin_update_dialog_title,
|
Text(
|
||||||
update.pluginName,
|
stringResource(
|
||||||
update.info.latestVersion)) {
|
Res.string.plugin_update_dialog_title,
|
||||||
Text(
|
update.pluginName,
|
||||||
text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion),
|
update.info.latestVersion))
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
},
|
||||||
modifier = Modifier.padding(bottom = 8.dp))
|
description = {
|
||||||
if (update.info.releaseNotes.isNotBlank()) {
|
Text(stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion))
|
||||||
SelectionContainer {
|
},
|
||||||
Markdown(content = update.info.releaseNotes)
|
content = {
|
||||||
|
if (update.info.releaseNotes.isNotBlank()) {
|
||||||
|
SelectionContainer {
|
||||||
|
Markdown(content = update.info.releaseNotes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
|
||||||
}
|
}
|
||||||
} else {
|
}) {
|
||||||
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
|
MillenniumButton(
|
||||||
|
onClick = onIgnore,
|
||||||
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
|
Text(stringResource(Res.string.update_action_ignore))
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
MillenniumButton(onClick = onUpdate) {
|
||||||
MillenniumButton(
|
Text(stringResource(Res.string.update_action_update))
|
||||||
onClick = onIgnore,
|
|
||||||
style = MillenniumButtonStyle.Secondary) {
|
|
||||||
Text(stringResource(Res.string.update_action_ignore))
|
|
||||||
}
|
|
||||||
MillenniumButton(onClick = onUpdate) {
|
|
||||||
Text(stringResource(Res.string.update_action_update))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,12 +26,19 @@ 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): List<VideoSearchResult> {
|
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val extractor = YouTube.getSearchExtractor(
|
val extractor = YouTube.getSearchExtractor(
|
||||||
query,
|
query,
|
||||||
mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS),
|
mutableListOf(
|
||||||
|
when (kind) {
|
||||||
|
SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
|
||||||
|
SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS
|
||||||
|
}),
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
extractor.fetchPage()
|
extractor.fetchPage()
|
||||||
@ -43,7 +50,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 = item.duration,
|
durationMs = secondsToMs(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}")
|
||||||
@ -63,7 +70,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 = extractor.length,
|
durationMs = secondsToMs(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 }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user