Compare commits

..

No commits in common. "d95e1aad706027bece02703afac9216a2d3fad0b" and "b6615bdbc8109d11d951f5a9df72a376569e12af" have entirely different histories.

25 changed files with 638 additions and 648 deletions

View File

@ -30,7 +30,6 @@
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
## UI component patterns ## UI component patterns
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. - **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain. - Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain.

View File

@ -259,16 +259,12 @@ class DeviceAudioPlayerQueue(
return return
} }
val queue = queueFlow.value val queue = queueFlow.value
// Using separate current index entry as the current track's source url has changed so it won't match
val index = queue.indexOfFirst { entry -> val index = queue.indexOfFirst { entry ->
when (entry) { when {
is QueueEntry.StreamingTrack if current is QueueEntry.StreamingTrack -> entry is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack ->
entry.track.id == current.track.id entry.track.id == current.track.id
entry is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack ->
is QueueEntry.LocalTrack if current is QueueEntry.LocalTrack ->
entry.url == current.url && entry.name == current.name entry.url == current.url && entry.name == current.name
else -> false else -> false
} }
} }

View File

@ -27,7 +27,6 @@ import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.navigation.navigationModule import dev.krtirtho.spotube.core.navigation.navigationModule
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.LocalServer import dev.krtirtho.spotube.core.server.LocalServer
import dev.krtirtho.spotube.core.server.MatchedTracksRepository import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository import dev.krtirtho.spotube.core.server.StreamingUrlRepository
@ -131,8 +130,6 @@ val sharedModules = module {
playbackHelper = get(), playbackHelper = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
) )
} }
@ -144,9 +141,6 @@ val sharedModules = module {
playbackHelper = get(), playbackHelper = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(), blacklistRepository = get(),
libraryRepository = get(),
shareService = get(),
downloadManager = get(),
) )
} }
@ -160,8 +154,6 @@ val sharedModules = module {
savedTracksRepository = get(), savedTracksRepository = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
) )
} }
@ -180,8 +172,6 @@ val sharedModules = module {
libraryRepository = get(), libraryRepository = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
) )
} }
@ -200,7 +190,6 @@ val sharedModules = module {
} }
singleOf(::MatchedTracksRepository) singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository) singleOf(::StreamingUrlRepository)
singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository) singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions { singleOf(::LocalServer) withOptions {
createdAtStart() createdAtStart()

View File

@ -109,6 +109,9 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }
@ -120,6 +123,9 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }
@ -132,6 +138,9 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }
@ -146,6 +155,9 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(), navigationCommands = get(),
) )
} }

View File

@ -29,7 +29,6 @@ class AlternativeTracksRepository(
private val matchedTracksRepository: MatchedTracksRepository, private val matchedTracksRepository: MatchedTracksRepository,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent { ) : KoinComponent {
private val logger by injectLogger<AlternativeTracksRepository>() private val logger by injectLogger<AlternativeTracksRepository>()
@ -79,7 +78,6 @@ class AlternativeTracksRepository(
matchedTracksRepository.saveTrackSource(track, basic) matchedTracksRepository.saveTrackSource(track, basic)
streamingUrlRepository.invalidateCachedStreamUrl(trackId) streamingUrlRepository.invalidateCachedStreamUrl(trackId)
streamingUrlRepository.invalidateCachedAlternatives(trackId) streamingUrlRepository.invalidateCachedAlternatives(trackId)
cacheManager.invalidateCacheEntry(trackId)
audioPlayerQueue.reloadCurrent() audioPlayerQueue.reloadCurrent()
logger.d { "Alternative source selection complete for track $trackId" } logger.d { "Alternative source selection complete for track $trackId" }
} }

View File

@ -19,7 +19,6 @@ package dev.krtirtho.spotube.core.server
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.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.Headers import io.ktor.http.Headers
import io.ktor.http.HeadersBuilder import io.ktor.http.HeadersBuilder
@ -41,12 +40,12 @@ import okio.buffer
import okio.use import okio.use
@Serializable @Serializable
data class CacheIndex( internal data class CacheIndex(
val entries: List<CacheEntry> = emptyList() val entries: List<CacheEntry> = emptyList()
) )
@Serializable @Serializable
data class CacheEntry( internal data class CacheEntry(
val trackId: String, val trackId: String,
val filename: String, val filename: String,
val sizeBytes: Long, val sizeBytes: Long,
@ -54,26 +53,18 @@ data class CacheEntry(
val contentType: String = "application/octet-stream", val contentType: String = "application/octet-stream",
) )
class CacheManager( internal class CacheManager(
private val paths: Paths, private val paths: Paths,
private val settingsRepository: SettingsRepository, private val resolveCacheFolder: () -> String?,
private val resolveSizeLimitMB: () -> Long,
private val fileSystem: FileSystem = FileSystem.SYSTEM,
private val cacheMutex: Mutex = Mutex(),
private val json: Json = Json { ignoreUnknownKeys = true },
) { ) {
private val fileSystem: FileSystem = FileSystem.SYSTEM
private val cacheMutex: Mutex = Mutex()
private val json: Json = Json { ignoreUnknownKeys = true }
companion object { companion object {
private val cacheIndexFileName = "cache_index.json".toPath() private val cacheIndexFileName = "cache_index.json".toPath()
} }
private fun resolveCacheFolder(): String? {
return settingsRepository.userSettings.value.cacheFolder
}
private fun resolveSizeLimitMB(): Long {
return settingsRepository.userSettings.value.cacheSizeLimitMB
}
fun resolveCacheDir(): Path { fun resolveCacheDir(): Path {
val folder = resolveCacheFolder() val folder = resolveCacheFolder()
return if (!folder.isNullOrBlank()) { return if (!folder.isNullOrBlank()) {
@ -110,18 +101,6 @@ class CacheManager(
return if (fileSystem.exists(filePath)) filePath to entry else null return if (fileSystem.exists(filePath)) filePath to entry else null
} }
suspend fun invalidateCacheEntry(trackId: String) {
cacheMutex.withLock {
val index = readCacheIndex()
val entry = index.entries.firstOrNull { it.trackId == trackId } ?: return
val filePath = resolveCacheDir() / entry.filename.toPath()
if (fileSystem.exists(filePath)) {
fileSystem.delete(filePath)
}
writeCacheIndex(index.copy(entries = index.entries.filter { it.trackId != trackId }))
}
}
fun resolveCacheFilename(track: MetadataTrack, contentType: String?): String { fun resolveCacheFilename(track: MetadataTrack, contentType: String?): String {
val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart() val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart()
val title = track.title.sanitizeFilenamePart() val title = track.title.sanitizeFilenamePart()

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.server
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
@ -51,10 +52,10 @@ import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
class LocalServer( class LocalServer(
private val paths: Paths,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent { ) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
@ -75,6 +76,14 @@ class LocalServer(
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
private val cachedCacheEnabled = MutableStateFlow(false) private val cachedCacheEnabled = MutableStateFlow(false)
private val cachedCacheFolder = MutableStateFlow<String?>(null)
private val cachedCacheSizeLimitMB = MutableStateFlow(0L)
private val cacheManager = CacheManager(
paths = paths,
resolveCacheFolder = { cachedCacheFolder.value },
resolveSizeLimitMB = { cachedCacheSizeLimitMB.value },
)
private val streamProxy by lazy { private val streamProxy by lazy {
StreamProxy( StreamProxy(
@ -85,6 +94,7 @@ class LocalServer(
isCachingEnabled = { cachedCacheEnabled.value }, isCachingEnabled = { cachedCacheEnabled.value },
activePort = { activePort.value }, activePort = { activePort.value },
scope = scope, scope = scope,
logger = logger,
) )
} }
@ -108,6 +118,8 @@ class LocalServer(
.collect { settings -> .collect { settings ->
if (settings != null) { if (settings != null) {
cachedCacheEnabled.value = settings.enableMusicCaching cachedCacheEnabled.value = settings.enableMusicCaching
cachedCacheFolder.value = settings.cacheFolder
cachedCacheSizeLimitMB.value = settings.cacheSizeLimitMB
} }
} }
} }

View File

@ -21,7 +21,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
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.audioplayer.AudioPlayerQueue 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 io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
@ -52,7 +51,6 @@ import okio.Path.Companion.toPath
import okio.SYSTEM import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import org.koin.core.component.KoinComponent
internal class StreamProxy( internal class StreamProxy(
private val httpClient: HttpClient, private val httpClient: HttpClient,
@ -62,9 +60,8 @@ internal class StreamProxy(
private val isCachingEnabled: () -> Boolean, private val isCachingEnabled: () -> Boolean,
private val activePort: () -> Int?, private val activePort: () -> Int?,
private val scope: CoroutineScope, private val scope: CoroutineScope,
): KoinComponent { private val logger: co.touchlab.kermit.Logger,
val logger by injectLogger<StreamProxy>() ) {
companion object { companion object {
private const val HOST = "127.0.0.1" private const val HOST = "127.0.0.1"
} }

View File

@ -74,23 +74,6 @@ data class TrackOptionsState(
val isBlacklisted: Boolean = false, val isBlacklisted: Boolean = false,
) )
data class TrackOptionsContext(
val currentTrackId: String? = null,
val queueTrackIds: Set<String> = emptySet(),
val savedTrackIds: Set<String> = emptySet(),
val blacklistedTrackIds: Set<String> = emptySet(),
val blacklistedArtistIds: Set<String> = emptySet(),
val forceFavorite: Boolean = false,
) {
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
isInQueue = track.id in queueTrackIds,
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = forceFavorite || track.id in savedTrackIds,
isBlacklisted = track.id in blacklistedTrackIds ||
track.artists.any { it.id in blacklistedArtistIds },
)
}
@Composable @Composable
fun TrackOptions( fun TrackOptions(
track: MetadataTrack, track: MetadataTrack,

View File

@ -40,21 +40,12 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic {
var confidence = 0f var confidence = 0f
if (track.title.lowercase() in video.title.lowercase()) confidence += 0.3f
val isTitleMatch = track.title.lowercase() in video.title.lowercase() if (track.artists.any { it.name.lowercase() in video.title.lowercase() }) confidence += 0.3f
val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() } if ((track.album != null && track.album!!.title.lowercase() in video.title.lowercase()) || video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase()
val isAlbumInTitle = ?: "")
track.album != null && track.album!!.title.lowercase() in video.title.lowercase() ) confidence += 0.2f
val isUploaderMatch = if (video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000)) confidence += 0.2f // Duration within ±30 seconds
video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "")
val isDurationMatch =
video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds
if (isTitleMatch) confidence += 0.3f
if (isUploaderMatch) confidence += 0.3f
if (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds
if (isArtistInTitle) confidence += 0.1f
if (isAlbumInTitle) confidence += 0.1f
return AudioSource.Basic( return AudioSource.Basic(
id = video.id, id = video.id,
@ -75,14 +66,19 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
override val supportedQualities: List<AudioFormat> = listOf( override val supportedQualities: List<AudioFormat> = listOf(
AudioFormat( AudioFormat(
codec = "opus", container = "webm", qualities = listOf( codec = "opus",
container = "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000), AudioQuality.Lossy(bitrate = 256_000),
) )
), AudioFormat( ),
codec = "aac", container = "mp4", qualities = listOf( AudioFormat(
codec = "aac",
container = "mp4",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
@ -93,9 +89,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> { override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
val isYouTubeUrl = val isYouTubeUrl =
track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains( track.externalUri != null &&
"youtu.be" (track.externalUri!!.contains("youtube.com") ||
)) track.externalUri!!.contains("youtu.be"))
val isYouTubeID = track.id.matches(youtubeIDRegex) val isYouTubeID = track.id.matches(youtubeIDRegex)
@ -136,14 +132,16 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
val searchQuery ="${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim() val searchQuery ="${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim()
logger.i { "Searching for video with query: $searchQuery" } logger.i { "Searching for video with query: $searchQuery" }
val videos = newPipeService.searchVideos(isrcCode ?: searchQuery) val videos =
newPipeService.searchVideos(isrcCode ?: searchQuery)
logger.i { "Found ${videos.size} videos with query: $searchQuery" } logger.i { "Found ${videos.size} videos with query: $searchQuery" }
var sources = videos.map { transformVideo(track, it) } var sources = videos.map { transformVideo(track, it) }
if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) { if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) {
logger.i { "ISRC search failed, fallback to title search" } logger.i { "ISRC search failed, fallback to title search" }
val videos = newPipeService.searchVideos(searchQuery) val videos =
newPipeService.searchVideos(searchQuery)
sources = videos.map { transformVideo(track, it) } sources = videos.map { transformVideo(track, it) }
} }

View File

@ -18,15 +18,33 @@
package dev.krtirtho.spotube.modules.album package dev.krtirtho.spotube.modules.album
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable @Composable
fun AlbumScreen( fun AlbumScreen(
@ -34,15 +52,59 @@ fun AlbumScreen(
viewModel: AlbumViewModel, viewModel: AlbumViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
val dataState = state as? AlbumScreenState.Data val dataState = state as? AlbumScreenState.Data
val album = dataState?.album val album = dataState?.album
@ -80,24 +142,40 @@ fun AlbumScreen(
tracks = dataState?.tracks ?: emptyList(), tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore, isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = trackOptionsContext.currentTrackId, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playAlbumFromTrack, onTrackClick = viewModel::playAlbumFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage, onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = viewModel::handleTrackOptionsAction, onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor, trackOptionsState = ::getTrackOptionsState,
onBulkDownload = viewModel::downloadTracks, onBulkDownload = { tracks ->
onBulkAddToQueue = viewModel::addTracksToQueue, downloadsViewModel.downloadTracks(tracks)
onBulkPlayNext = viewModel::playTracksNext, },
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker, onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = viewModel::addTracksToPlaylist, onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
}, },
) )

View File

@ -26,25 +26,19 @@ 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 dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -95,8 +89,6 @@ class AlbumViewModel(
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>() private val logger by injectLogger<AlbumViewModel>()
@ -105,37 +97,12 @@ class AlbumViewModel(
val savedAlbumIds val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow get() = libraryRepository.savedAlbumIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow() val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -144,7 +111,6 @@ class AlbumViewModel(
.distinctUntilChanged() .distinctUntilChanged()
.collect { .collect {
loadInitialData() loadInitialData()
loadCurrentUser()
} }
} }
blacklistRepository.blacklistedTracks blacklistRepository.blacklistedTracks
@ -155,14 +121,6 @@ class AlbumViewModel(
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
private suspend fun loadCurrentUser() {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
private suspend fun loadInitialData() = runCatching { private suspend fun loadInitialData() = runCatching {
_state.value = AlbumScreenState.Loading _state.value = AlbumScreenState.Loading
val albumInfo = repository.getAlbumInfo(albumId) val albumInfo = repository.getAlbumInfo(albumId)
@ -241,10 +199,20 @@ class AlbumViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) } }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) if (queueIndex >= 0) {
audioPlayerQueue.removeFromQueue(queue[queueIndex])
}
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
val newQueue = audioPlayerQueue.getQueue()
val newIndex = newQueue.indexOfFirst { e ->
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}
if (newIndex > 0) {
audioPlayerQueue.move(newIndex, 0)
}
} }
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
@ -267,48 +235,12 @@ class AlbumViewModel(
} }
} }
is TrackOptionsAction.Download -> downloadManager.enqueue(track) is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> { is TrackOptionsAction.Share -> {}
val uri = track.externalUri?.takeIf { it.isNotBlank() } is TrackOptionsAction.AddToPlaylist -> {}
if (uri != null) {
shareService.share(uri, track.title)
} }
} }
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
} }
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {

View File

@ -45,6 +45,10 @@ import androidx.compose.material3.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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -59,12 +63,14 @@ import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -74,9 +80,13 @@ import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.TrackList import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard 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.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
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
@ -85,6 +95,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -92,16 +103,58 @@ fun ArtistScreen(
viewModel: ArtistViewModel, viewModel: ArtistViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) }
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
Scaffold( Scaffold(
topBar = { ApplicationMainBar() } topBar = { ApplicationMainBar() }
@ -156,8 +209,8 @@ fun ArtistScreen(
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playTopTracksFromTrack, onTrackClick = viewModel::playTopTracksFromTrack,
onTrackOptionsAction = viewModel::handleTrackOptionsAction, onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor, trackOptionsState = ::getTrackOptionsState,
onArtistClick = { trackArtist -> onArtistClick = { trackArtist ->
navigationCommands.navigateTo( navigationCommands.navigateTo(
Routes.Artist( Routes.Artist(
@ -175,10 +228,13 @@ fun ArtistScreen(
onLoadNextPage = {}, onLoadNextPage = {},
simplified = true, simplified = true,
scrollable = false, scrollable = false,
onBulkDownload = viewModel::downloadTracks, onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = viewModel::addTracksToQueue, onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
onBulkPlayNext = viewModel::playTracksNext, onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
) )
} }
@ -236,8 +292,15 @@ fun ArtistScreen(
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker, onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = viewModel::addTracksToPlaylist, onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
} }
} }

View File

@ -27,21 +27,15 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue 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 dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -71,8 +65,6 @@ class ArtistViewModel(
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>() private val logger by injectLogger<ArtistViewModel>()
@ -85,37 +77,12 @@ class ArtistViewModel(
val savedTrackIds val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow get() = savedTracksRepository.savedTracksIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow() val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init { init {
viewModelScope.launch { viewModelScope.launch {
libraryRepository.savedArtistIdsFlow.collect { libraryRepository.savedArtistIdsFlow.collect {
@ -131,20 +98,9 @@ class ArtistViewModel(
blacklistRepository.blacklistedArtists blacklistRepository.blacklistedArtists
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() } .onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
.launchIn(viewModelScope) .launchIn(viewModelScope)
loadCurrentUser()
loadOverview() loadOverview()
} }
private fun loadCurrentUser() {
viewModelScope.launch {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
}
fun refresh() { fun refresh() {
repository.invalidateCaches() repository.invalidateCaches()
loadOverview() loadOverview()
@ -329,10 +285,22 @@ class ArtistViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) } }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) if (queueIndex >= 0) {
val entry = queue[queueIndex]
audioPlayerQueue.removeFromQueue(entry)
}
val entry = QueueEntry.StreamingTrack(track = track, url = "")
audioPlayerQueue.addToQueue(entry)
val newQueue = audioPlayerQueue.getQueue()
val newIndex = newQueue.indexOfFirst { e ->
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}
if (newIndex >= 0) {
audioPlayerQueue.move(newIndex, 0)
}
} }
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
@ -351,48 +319,12 @@ class ArtistViewModel(
savedTracksRepository.saveTracks(listOf(track.id)) savedTracksRepository.saveTracks(listOf(track.id))
} }
} }
is TrackOptionsAction.Download -> downloadManager.enqueue(track) is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> { is TrackOptionsAction.Share -> {}
val uri = track.externalUri?.takeIf { it.isNotBlank() } is TrackOptionsAction.AddToPlaylist -> {}
if (uri != null) {
shareService.share(uri, track.title)
} }
} }
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
} }
private fun loadOverview() { private fun loadOverview() {

View File

@ -27,22 +27,35 @@ import androidx.compose.runtime.Composable
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
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
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 kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable @Composable
fun PlaylistScreen( fun PlaylistScreen(
@ -50,17 +63,40 @@ fun PlaylistScreen(
viewModel: PlaylistViewModel, viewModel: PlaylistViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
var showEditPlaylist by remember { mutableStateOf(false) } var showEditPlaylist by remember { mutableStateOf(false) }
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var showAddTracksDialog by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
val dataState = state as? PlaylistScreenState.Data val dataState = state as? PlaylistScreenState.Data
val playlist = dataState?.playlist val playlist = dataState?.playlist
@ -72,6 +108,23 @@ fun PlaylistScreen(
val isPlaying = currentCollectionEntry?.id == playlistId && val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
val errorMessage = (state as? PlaylistScreenState.Error)?.message val errorMessage = (state as? PlaylistScreenState.Error)?.message
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
@ -115,18 +168,27 @@ fun PlaylistScreen(
tracks = dataState?.tracks ?: emptyList(), tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore, isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = trackOptionsContext.currentTrackId, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playPlaylistFromTrack, onTrackClick = viewModel::playPlaylistFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage, onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = viewModel::handleTrackOptionsAction, onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor, trackOptionsState = ::getTrackOptionsState,
onBulkDownload = viewModel::downloadTracks, onBulkDownload = { tracks ->
onBulkAddToQueue = viewModel::addTracksToQueue, downloadsViewModel.downloadTracks(tracks)
onBulkPlayNext = viewModel::playTracksNext, },
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
footerContent = footerContent, footerContent = footerContent,
trailingContent = { trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
@ -157,8 +219,15 @@ fun PlaylistScreen(
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker, onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = viewModel::addTracksToPlaylist, onPlaylistSelected = { selectedPlaylistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
selectedPlaylistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
AddTracksToPlaylistDialog( AddTracksToPlaylistDialog(

View File

@ -26,26 +26,20 @@ 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 dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -96,8 +90,6 @@ class PlaylistViewModel(
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<PlaylistViewModel>() private val logger by injectLogger<PlaylistViewModel>()
@ -115,28 +107,6 @@ class PlaylistViewModel(
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -274,10 +244,20 @@ class PlaylistViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) } }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) if (queueIndex >= 0) {
audioPlayerQueue.removeFromQueue(queue[queueIndex])
}
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
val newQueue = audioPlayerQueue.getQueue()
val newIndex = newQueue.indexOfFirst { e ->
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}
if (newIndex > 0) {
audioPlayerQueue.move(newIndex, 0)
}
} }
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
@ -300,48 +280,12 @@ class PlaylistViewModel(
} }
} }
is TrackOptionsAction.Download -> downloadManager.enqueue(track) is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> { is TrackOptionsAction.Share -> {}
val uri = track.externalUri?.takeIf { it.isNotBlank() } is TrackOptionsAction.AddToPlaylist -> {}
if (uri != null) {
shareService.share(uri, track.title)
} }
} }
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
} }
fun isTrackBlacklisted(track: MetadataTrack): Boolean { fun isTrackBlacklisted(track: MetadataTrack): Boolean {

View File

@ -18,16 +18,34 @@
package dev.krtirtho.spotube.modules.saved_tracks package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.liked_tracks import spotube.composeapp.generated.resources.liked_tracks
@ -36,14 +54,56 @@ fun SavedTracksScreen(
viewModel: SavedTracksViewModel, viewModel: SavedTracksViewModel,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = true,
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
val dataState = state as? SavedTracksScreenState.Data val dataState = state as? SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks && val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
@ -73,24 +133,40 @@ fun SavedTracksScreen(
tracks = dataState?.tracks ?: emptyList(), tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null, hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore, isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = trackOptionsContext.currentTrackId, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playSavedTracksFromTrack, onTrackClick = viewModel::playSavedTracksFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage, onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) }, onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) }, onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = viewModel::handleTrackOptionsAction, onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor, trackOptionsState = ::getTrackOptionsState,
onBulkDownload = viewModel::downloadTracks, onBulkDownload = { tracks ->
onBulkAddToQueue = viewModel::addTracksToQueue, downloadsViewModel.downloadTracks(tracks)
onBulkPlayNext = viewModel::playTracksNext, },
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker, onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = viewModel::addTracksToPlaylist, onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
) )
}, },
) )

View File

@ -34,15 +34,10 @@ 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 dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -51,7 +46,6 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -94,9 +88,6 @@ class SavedTracksViewModel(
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val libraryRepository: LibraryRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<SavedTracksViewModel>() private val logger by injectLogger<SavedTracksViewModel>()
@ -105,36 +96,12 @@ class SavedTracksViewModel(
val savedTrackIdsFlow: StateFlow<Set<String>> val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow get() = repository.savedTracksIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow() val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet()) private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow() val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
forceFavorite = true,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext(forceFavorite = true))
init { init {
viewModelScope.launch { viewModelScope.launch {
combine( combine(
@ -145,7 +112,6 @@ class SavedTracksViewModel(
repository.savedTracksIdsFlow repository.savedTracksIdsFlow
) { _, _ -> }.collect { ) { _, _ -> }.collect {
loadInitialData() loadInitialData()
loadCurrentUser()
} }
} }
blacklistRepository.blacklistedTracks blacklistRepository.blacklistedTracks
@ -156,14 +122,6 @@ class SavedTracksViewModel(
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
private suspend fun loadCurrentUser() {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
private suspend fun loadInitialData() { private suspend fun loadInitialData() {
runCatching { runCatching {
val tracksResult = repository.getSavedTracks() val tracksResult = repository.getSavedTracks()
@ -224,10 +182,20 @@ class SavedTracksViewModel(
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) } }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) if (queueIndex >= 0) {
audioPlayerQueue.removeFromQueue(queue[queueIndex])
}
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
val newQueue = audioPlayerQueue.getQueue()
val newIndex = newQueue.indexOfFirst { e ->
(e as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}
if (newIndex > 0) {
audioPlayerQueue.move(newIndex, 0)
}
} }
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
@ -242,48 +210,12 @@ class SavedTracksViewModel(
} }
is TrackOptionsAction.ToggleFavorite -> {} is TrackOptionsAction.ToggleFavorite -> {}
is TrackOptionsAction.Download -> downloadManager.enqueue(track) is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track) is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> { is TrackOptionsAction.Share -> {}
val uri = track.externalUri?.takeIf { it.isNotBlank() } is TrackOptionsAction.AddToPlaylist -> {}
if (uri != null) {
shareService.share(uri, track.title)
} }
} }
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
} }
fun isTrackBlacklisted(track: MetadataTrack): Boolean { fun isTrackBlacklisted(track: MetadataTrack): Boolean {

View File

@ -171,10 +171,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> { is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
}?.let { audioPlayerQueue.removeFromQueue(it) } }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) if (queueIndex >= 0) {
audioPlayerQueue.removeFromQueue(queue[queueIndex])
}
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
val newQueue = audioPlayerQueue.getQueue()
val newIndex = newQueue.indexOfFirst { e ->
(e as? QueueEntry.StreamingTrack)?.track?.id == track.id
}
if (newIndex > 0) {
audioPlayerQueue.move(newIndex, 0)
}
} }
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {

View File

@ -20,10 +20,6 @@ package dev.krtirtho.spotube.modules.settings
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import dev.krtirtho.spotube.core.db.Database import dev.krtirtho.spotube.core.db.Database
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -32,20 +28,14 @@ class SettingsRepository(private val database: Database) {
private val SETTINGS_KEY = stringPreferencesKey("user_settings") private val SETTINGS_KEY = stringPreferencesKey("user_settings")
} }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val userSettings: Flow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY] val json = prefs[SETTINGS_KEY]
if (json != null) { if (json != null) {
Json.decodeFromString<UserSettings>(json as String) Json.decodeFromString<UserSettings>(json as String)
} else { } else {
UserSettings() // Default value UserSettings() // Default value
} }
}.stateIn( }
scope,
started = SharingStarted.Eagerly,
initialValue = UserSettings() // Default value
)
suspend fun updateSettings(newSettings: UserSettings) { suspend fun updateSettings(newSettings: UserSettings) {
database.settingsDataStore.edit { prefs -> database.settingsDataStore.edit { prefs ->

View File

@ -32,7 +32,11 @@ interface SettingsProvider {
class SettingsViewModel( class SettingsViewModel(
private val repository: SettingsRepository private val repository: SettingsRepository
) : ViewModel(), SettingsProvider { ) : ViewModel(), SettingsProvider {
override val settingsState = repository.userSettings override val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
null,
)
fun updateSettings(transform: UserSettings.() -> UserSettings) { fun updateSettings(transform: UserSettings.() -> UserSettings) {
viewModelScope.launch { viewModelScope.launch {

View File

@ -95,9 +95,8 @@ class AlternativeTrackContentViewModel(
} }
fun selectAlternative(source: AudioSource) { fun selectAlternative(source: AudioSource) {
val currentTrack = val currentTrack = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
(audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track if (currentTrack == null) return
?: return
viewModelScope.launch { viewModelScope.launch {
alternativeTracksRepository.selectAlternative(currentTrack, source) alternativeTracksRepository.selectAlternative(currentTrack, source)
activeSourceIdFlow.value = source.id activeSourceIdFlow.value = source.id

View File

@ -19,6 +19,9 @@ package dev.krtirtho.spotube.modules.shell.player_queue
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background 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.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
@ -26,6 +29,7 @@ import androidx.compose.foundation.layout.PaddingValues
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.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -40,54 +44,128 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.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.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
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 coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.rememberLogger
import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.base.highlight
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
private data class QueueItemUi(
val id: String,
val title: String,
val subtitle: String,
val durationMs: Long,
val isCurrent: Boolean,
val imageUrl: String?,
val originalIndex: Int,
)
@Composable @Composable
fun PlayerQueueContent( fun PlayerQueueContent(
viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(), viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val state by viewModel.queueContentUiState.collectAsState() val logger = rememberLogger("PlayerQueueContent")
val displayItems = state.displayItems val queueContentUiState by viewModel.queueContentUiState.collectAsState()
val filterQuery = state.filterQuery val queue = queueContentUiState.queue
val isFiltered = state.isFiltered val currentQueueEntry = queueContentUiState.currentQueueEntry
val filterQuery = queueContentUiState.filterQuery
val normalizedFilter = remember(filterQuery) { filterQuery.trim().lowercase() }
val currentIndex = remember(queue, currentQueueEntry) {
val current = currentQueueEntry ?: return@remember -1
queue.indexOfFirst { entry -> entry.matchesCurrent(current) }
}
val sourceItems = remember(queue, currentIndex) {
queue.mapIndexed { index, entry ->
val (title, subtitle, durationMs, imageUrl) = entry.toQueueDisplayData()
QueueItemUi(
id = "${entry.url}@$index",
title = title,
subtitle = subtitle,
durationMs = durationMs,
isCurrent = index == currentIndex,
imageUrl = imageUrl,
originalIndex = index,
)
}
}
val isFiltered = normalizedFilter.isNotBlank()
val displayList = remember { mutableStateListOf<QueueItemUi>() }
var moveParams by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var queueVersion by remember { mutableIntStateOf(0) }
LaunchedEffect(sourceItems, normalizedFilter) {
queueVersion++
displayList.clear()
val filtered = if (isFiltered) {
sourceItems.filter { item ->
item.title.lowercase().contains(normalizedFilter) ||
item.subtitle.lowercase().contains(normalizedFilter)
}
} else {
sourceItems
}
displayList.addAll(filtered)
}
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState( val reorderableLazyListState =
rememberReorderableLazyListState(
lazyListState, lazyListState,
onMove = { from, to -> onMove = { from, to ->
if (isFiltered) return@rememberReorderableLazyListState if (isFiltered) return@rememberReorderableLazyListState
viewModel.onMove(from.index, to.index) val item = displayList.removeAt(from.index)
displayList.add(to.index, item)
logger.i { "Moved item from ${from.index} to ${to.index}" }
moveParams = item.originalIndex to to.index
}, },
) )
fun finalizeReorder() {
moveParams?.let { (fromOriginal, toDisplay) ->
logger.i { "Finalizing move from $fromOriginal to $toDisplay (version $queueVersion)" }
viewModel.moveQueueItem(fromOriginal, toDisplay)
}
moveParams = null
}
Surface(modifier = modifier) { Surface(modifier = modifier) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -126,7 +204,7 @@ fun PlayerQueueContent(
} }
} }
if (displayItems.isEmpty()) { if (displayList.isEmpty()) {
Text( Text(
text = "No queue entries", text = "No queue entries",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -139,7 +217,7 @@ fun PlayerQueueContent(
state = lazyListState, state = lazyListState,
contentPadding = PaddingValues(bottom = 8.dp), contentPadding = PaddingValues(bottom = 8.dp),
) { ) {
items(displayItems, key = { it.id }) { item -> items(displayList, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow( QueueItemRow(
@ -147,8 +225,7 @@ fun PlayerQueueContent(
reorderScope = if (isFiltered) null else this, reorderScope = if (isFiltered) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
onDragStarted = { viewModel.onDragStart() }, onDragStopped = ::finalizeReorder,
onDragStopped = { viewModel.onDragStop() },
) )
} }
} }
@ -165,7 +242,6 @@ private fun QueueItemRow(
reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onRemoveClick: () -> Unit, onRemoveClick: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit, onDragStopped: () -> Unit,
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@ -187,12 +263,7 @@ private fun QueueItemRow(
.size(24.dp) .size(24.dp)
.then( .then(
if (reorderScope != null) { if (reorderScope != null) {
with(reorderScope) { with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) }
Modifier.draggableHandle(
onDragStarted = { onDragStarted() },
onDragStopped = onDragStopped,
)
}
} else { } else {
Modifier Modifier
}, },
@ -248,7 +319,7 @@ private fun QueueItemRow(
}, },
trailing = { trailing = {
Text( Text(
text = item.durationLabel, text = item.durationMs.toDurationString(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -285,3 +356,53 @@ private fun QueueItemRow(
} }
) )
} }
private fun QueueEntry.toQueueDisplayData(): Tuple4<String, String, Long, String?> {
return when (this) {
is QueueEntry.StreamingTrack -> {
val imageUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
Tuple4(
track.title,
track.artists.joinToString(", ") { it.name },
track.durationMs,
imageUrl,
)
}
is QueueEntry.LocalTrack -> Tuple4(
name,
artists.joinToString(", "),
duration,
null,
)
}
}
private data class Tuple4<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
return when {
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
this.track.id == current.track.id
}
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
this.url == current.url && this.name == current.name
}
else -> false
}
}
private fun Long.toDurationString(): String {
val totalSeconds = (this / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}

View File

@ -30,20 +30,10 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class QueueItemUi(
val id: String,
val title: String,
val subtitle: String,
val durationLabel: String,
val isCurrent: Boolean,
val imageUrl: String?,
val originalIndex: Int,
)
data class QueueContentUiState( data class QueueContentUiState(
val filterQuery: String = "", val filterQuery: String = "",
val displayItems: List<QueueItemUi> = emptyList(), val queue: List<QueueEntry> = emptyList(),
val isFiltered: Boolean = false, val currentQueueEntry: QueueEntry? = null,
) )
class PlayerQueueContentViewModel( class PlayerQueueContentViewModel(
@ -51,79 +41,18 @@ class PlayerQueueContentViewModel(
) : ViewModel() { ) : ViewModel() {
private val queueVisibilityFlow = MutableStateFlow(false) private val queueVisibilityFlow = MutableStateFlow(false)
private val queueFilterFlow = MutableStateFlow("") private val queueFilterFlow = MutableStateFlow("")
private val reorderBuffer = MutableStateFlow<List<QueueItemUi>?>(null)
private var moveFromOriginal: Int? = null
private var moveToDisplay: Int? = null
val isQueueVisible: StateFlow<Boolean> = queueVisibilityFlow.asStateFlow() val isQueueVisible: StateFlow<Boolean> = queueVisibilityFlow.asStateFlow()
private val computedItems: StateFlow<List<QueueItemUi>> = combine( val queueContentUiState: StateFlow<QueueContentUiState> = combine(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
) { queue, currentEntry ->
val currentIndex = if (currentEntry != null) {
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
} else {
-1
}
queue.mapIndexed { index, entry ->
val title: String
val subtitle: String
val durationMs: Long
val imageUrl: String?
when (entry) {
is QueueEntry.StreamingTrack -> {
title = entry.track.title
subtitle = entry.track.artists.joinToString(", ") { it.name }
durationMs = entry.track.durationMs
imageUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
}
is QueueEntry.LocalTrack -> {
title = entry.name
subtitle = entry.artists.joinToString(", ")
durationMs = entry.duration
imageUrl = null
}
}
QueueItemUi(
id = "${entry.url}@$index",
title = title,
subtitle = subtitle,
durationLabel = durationMs.toDurationLabel(),
isCurrent = index == currentIndex,
imageUrl = imageUrl,
originalIndex = index,
)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val queueContentUiState: StateFlow<QueueContentUiState> = combine(
computedItems,
reorderBuffer,
queueFilterFlow, queueFilterFlow,
) { items, buffer, filterQuery -> ) { queue, currentQueueEntry, filterQuery ->
val normalizedFilter = filterQuery.trim().lowercase()
val isFiltered = normalizedFilter.isNotBlank()
val filtered = if (isFiltered) {
items.filter { item ->
item.title.lowercase().contains(normalizedFilter) ||
item.subtitle.lowercase().contains(normalizedFilter)
}
} else {
items
}
QueueContentUiState( QueueContentUiState(
filterQuery = filterQuery, filterQuery = filterQuery,
displayItems = buffer ?: filtered, queue = queue,
isFiltered = isFiltered, currentQueueEntry = currentQueueEntry,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -143,6 +72,13 @@ class PlayerQueueContentViewModel(
queueFilterFlow.value = query queueFilterFlow.value = query
} }
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
viewModelScope.launch {
audioPlayerQueue.move(fromIndex, toIndex)
}
}
fun playQueueItem(index: Int) { fun playQueueItem(index: Int) {
if (index < 0) return if (index < 0) return
viewModelScope.launch { viewModelScope.launch {
@ -160,67 +96,9 @@ class PlayerQueueContentViewModel(
} }
} }
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
viewModelScope.launch {
audioPlayerQueue.move(fromIndex, toIndex)
}
}
fun clearQueue() { fun clearQueue() {
viewModelScope.launch { viewModelScope.launch {
audioPlayerQueue.clear() audioPlayerQueue.clear()
} }
} }
fun onDragStart() {
if (reorderBuffer.value != null) return
val currentItems = queueContentUiState.value.displayItems
reorderBuffer.value = currentItems.toList()
}
fun onMove(from: Int, to: Int) {
val buffer = reorderBuffer.value ?: return
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
val item = buffer[from]
val newList = buffer.toMutableList().apply {
removeAt(from)
add(to, item)
}
reorderBuffer.value = newList
moveFromOriginal = item.originalIndex
moveToDisplay = to
}
fun onDragStop() {
val fromOriginal = moveFromOriginal
val toDisplay = moveToDisplay
if (fromOriginal != null && toDisplay != null) {
moveQueueItem(fromOriginal, toDisplay)
}
moveFromOriginal = null
moveToDisplay = null
reorderBuffer.value = null
}
}
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
return when {
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
this.track.id == current.track.id
}
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
this.url == current.url && this.name == current.name
}
else -> false
}
}
private fun Long.toDurationLabel(): String {
val totalSeconds = (this / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
} }

View File

@ -490,7 +490,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (disposed || index !in currentPlaylist.indices) return if (disposed || index !in currentPlaylist.indices) return
currentIndex = index currentIndex = index
_currentMediaItem.tryEmit(currentPlaylist[index]) _currentMediaItem.tryEmit(currentPlaylist[index])
pendingNextIndex = index
mediaListPlayer.controls().play(index) mediaListPlayer.controls().play(index)
} }
} }