mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
Compare commits
6 Commits
b6615bdbc8
...
d95e1aad70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d95e1aad70 | ||
|
|
19c1d0ec35 | ||
|
|
870195bdfd | ||
|
|
4ef256249e | ||
|
|
f4eac7dbd9 | ||
|
|
16e2a743a5 |
@ -30,6 +30,7 @@
|
|||||||
- `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.
|
||||||
|
|||||||
@ -259,12 +259,16 @@ 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 {
|
when (entry) {
|
||||||
entry is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack ->
|
is QueueEntry.StreamingTrack if 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ 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
|
||||||
@ -130,6 +131,8 @@ val sharedModules = module {
|
|||||||
playbackHelper = get(),
|
playbackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
|
shareService = get(),
|
||||||
|
downloadManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +144,9 @@ val sharedModules = module {
|
|||||||
playbackHelper = get(),
|
playbackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
|
libraryRepository = get(),
|
||||||
|
shareService = get(),
|
||||||
|
downloadManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,6 +160,8 @@ val sharedModules = module {
|
|||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
|
shareService = get(),
|
||||||
|
downloadManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,6 +180,8 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
|
shareService = get(),
|
||||||
|
downloadManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,6 +200,7 @@ 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()
|
||||||
|
|||||||
@ -109,9 +109,6 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
navigationCommands = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -123,9 +120,6 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
navigationCommands = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -138,9 +132,6 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
navigationCommands = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -155,9 +146,6 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
navigationCommands = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ 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>()
|
||||||
@ -78,6 +79,7 @@ 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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ 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
|
||||||
@ -40,12 +41,12 @@ import okio.buffer
|
|||||||
import okio.use
|
import okio.use
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class CacheIndex(
|
data class CacheIndex(
|
||||||
val entries: List<CacheEntry> = emptyList()
|
val entries: List<CacheEntry> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class CacheEntry(
|
data class CacheEntry(
|
||||||
val trackId: String,
|
val trackId: String,
|
||||||
val filename: String,
|
val filename: String,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
@ -53,18 +54,26 @@ internal data class CacheEntry(
|
|||||||
val contentType: String = "application/octet-stream",
|
val contentType: String = "application/octet-stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
internal class CacheManager(
|
class CacheManager(
|
||||||
private val paths: Paths,
|
private val paths: Paths,
|
||||||
private val resolveCacheFolder: () -> String?,
|
private val settingsRepository: SettingsRepository,
|
||||||
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()) {
|
||||||
@ -101,6 +110,18 @@ internal 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()
|
||||||
|
|||||||
@ -19,7 +19,6 @@ 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
|
||||||
@ -52,10 +51,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>()
|
||||||
@ -76,14 +75,6 @@ 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(
|
||||||
@ -94,7 +85,6 @@ class LocalServer(
|
|||||||
isCachingEnabled = { cachedCacheEnabled.value },
|
isCachingEnabled = { cachedCacheEnabled.value },
|
||||||
activePort = { activePort.value },
|
activePort = { activePort.value },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
logger = logger,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,8 +108,6 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ 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
|
||||||
@ -51,6 +52,7 @@ 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,
|
||||||
@ -60,8 +62,9 @@ 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,
|
||||||
private val logger: co.touchlab.kermit.Logger,
|
): KoinComponent {
|
||||||
) {
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,6 +74,23 @@ 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,
|
||||||
|
|||||||
@ -40,12 +40,21 @@ 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
|
|
||||||
if (track.artists.any { it.name.lowercase() in video.title.lowercase() }) confidence += 0.3f
|
val isTitleMatch = track.title.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 isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() }
|
||||||
?: "")
|
val isAlbumInTitle =
|
||||||
) confidence += 0.2f
|
track.album != null && track.album!!.title.lowercase() in video.title.lowercase()
|
||||||
if (video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000)) confidence += 0.2f // Duration within ±30 seconds
|
val isUploaderMatch =
|
||||||
|
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,
|
||||||
@ -66,19 +75,14 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
|
|
||||||
override val supportedQualities: List<AudioFormat> = listOf(
|
override val supportedQualities: List<AudioFormat> = listOf(
|
||||||
AudioFormat(
|
AudioFormat(
|
||||||
codec = "opus",
|
codec = "opus", container = "webm", qualities = listOf(
|
||||||
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(
|
||||||
AudioFormat(
|
codec = "aac", container = "mp4", qualities = listOf(
|
||||||
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),
|
||||||
@ -89,9 +93,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 != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains(
|
||||||
(track.externalUri!!.contains("youtube.com") ||
|
"youtu.be"
|
||||||
track.externalUri!!.contains("youtu.be"))
|
))
|
||||||
val isYouTubeID = track.id.matches(youtubeIDRegex)
|
val isYouTubeID = track.id.matches(youtubeIDRegex)
|
||||||
|
|
||||||
|
|
||||||
@ -132,16 +136,14 @@ 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 =
|
val videos = newPipeService.searchVideos(isrcCode ?: searchQuery)
|
||||||
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 =
|
val videos = newPipeService.searchVideos(searchQuery)
|
||||||
newPipeService.searchVideos(searchQuery)
|
|
||||||
sources = videos.map { transformVideo(track, it) }
|
sources = videos.map { transformVideo(track, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,33 +18,15 @@
|
|||||||
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(
|
||||||
@ -52,59 +34,15 @@ 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 blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||||
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
|
||||||
@ -142,40 +80,24 @@ 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 = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
currentTrackId = trackOptionsContext.currentTrackId,
|
||||||
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 = ::handleTrackOptionsAction,
|
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
trackOptionsState = trackOptionsContext::stateFor,
|
||||||
onBulkDownload = { tracks ->
|
onBulkDownload = viewModel::downloadTracks,
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
},
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToQueue = { tracks ->
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
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 = { showAddToPlaylistPicker = false },
|
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||||
onPlaylistSelected = { playlistId ->
|
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||||
scope.launch {
|
|
||||||
libraryRepository.addTracksToPlaylist(
|
|
||||||
playlistId,
|
|
||||||
tracksToAddToPlaylist.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -26,19 +26,25 @@ 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
|
||||||
|
|
||||||
@ -89,6 +95,8 @@ 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>()
|
||||||
|
|
||||||
@ -97,12 +105,37 @@ 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
|
||||||
@ -111,6 +144,7 @@ class AlbumViewModel(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect {
|
.collect {
|
||||||
loadInitialData()
|
loadInitialData()
|
||||||
|
loadCurrentUser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blacklistRepository.blacklistedTracks
|
blacklistRepository.blacklistedTracks
|
||||||
@ -121,6 +155,14 @@ 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)
|
||||||
@ -199,20 +241,10 @@ class AlbumViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
val queueIndex = queue.indexOfFirst { entry ->
|
queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}
|
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||||
if (queueIndex >= 0) {
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
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 -> {
|
||||||
@ -235,12 +267,48 @@ class AlbumViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||||
|
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>) {
|
||||||
|
|||||||
@ -45,10 +45,6 @@ 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
|
||||||
@ -63,14 +59,12 @@ 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
|
||||||
@ -80,13 +74,9 @@ 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
|
||||||
@ -95,7 +85,6 @@ 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
|
||||||
@ -103,58 +92,16 @@ 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()
|
||||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
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() }
|
||||||
@ -209,8 +156,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 = ::handleTrackOptionsAction,
|
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
trackOptionsState = trackOptionsContext::stateFor,
|
||||||
onArtistClick = { trackArtist ->
|
onArtistClick = { trackArtist ->
|
||||||
navigationCommands.navigateTo(
|
navigationCommands.navigateTo(
|
||||||
Routes.Artist(
|
Routes.Artist(
|
||||||
@ -228,13 +175,10 @@ fun ArtistScreen(
|
|||||||
onLoadNextPage = {},
|
onLoadNextPage = {},
|
||||||
simplified = true,
|
simplified = true,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
|
onBulkDownload = viewModel::downloadTracks,
|
||||||
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = { tracks ->
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
tracksToAddToPlaylist = tracks
|
|
||||||
showAddToPlaylistPicker = true
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,15 +236,8 @@ fun ArtistScreen(
|
|||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
onDismiss = { showAddToPlaylistPicker = false },
|
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||||
onPlaylistSelected = { playlistId ->
|
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||||
scope.launch {
|
|
||||||
libraryRepository.addTracksToPlaylist(
|
|
||||||
playlistId,
|
|
||||||
tracksToAddToPlaylist.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,15 +27,21 @@ 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
|
||||||
|
|
||||||
@ -65,6 +71,8 @@ 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>()
|
||||||
|
|
||||||
@ -77,12 +85,37 @@ 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 {
|
||||||
@ -98,9 +131,20 @@ 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()
|
||||||
@ -285,22 +329,10 @@ class ArtistViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
val queueIndex = queue.indexOfFirst { entry ->
|
queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}
|
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||||
if (queueIndex >= 0) {
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
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 = ""))
|
||||||
@ -319,12 +351,48 @@ class ArtistViewModel(
|
|||||||
savedTracksRepository.saveTracks(listOf(track.id))
|
savedTracksRepository.saveTracks(listOf(track.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||||
|
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() {
|
||||||
|
|||||||
@ -27,35 +27,22 @@ 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(
|
||||||
@ -63,40 +50,17 @@ 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
|
||||||
@ -108,23 +72,6 @@ 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())
|
||||||
|
|
||||||
@ -168,27 +115,18 @@ 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 = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
currentTrackId = trackOptionsContext.currentTrackId,
|
||||||
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 = ::handleTrackOptionsAction,
|
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
trackOptionsState = trackOptionsContext::stateFor,
|
||||||
onBulkDownload = { tracks ->
|
onBulkDownload = viewModel::downloadTracks,
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
},
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToQueue = { tracks ->
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
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
|
||||||
@ -219,15 +157,8 @@ fun PlaylistScreen(
|
|||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
onDismiss = { showAddToPlaylistPicker = false },
|
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||||
onPlaylistSelected = { selectedPlaylistId ->
|
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||||
scope.launch {
|
|
||||||
libraryRepository.addTracksToPlaylist(
|
|
||||||
selectedPlaylistId,
|
|
||||||
tracksToAddToPlaylist.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
AddTracksToPlaylistDialog(
|
AddTracksToPlaylistDialog(
|
||||||
|
|||||||
@ -26,20 +26,26 @@ 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
|
||||||
|
|
||||||
@ -90,6 +96,8 @@ 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>()
|
||||||
|
|
||||||
@ -107,6 +115,28 @@ 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
|
||||||
@ -244,20 +274,10 @@ class PlaylistViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
val queueIndex = queue.indexOfFirst { entry ->
|
queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}
|
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||||
if (queueIndex >= 0) {
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
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 -> {
|
||||||
@ -280,12 +300,48 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||||
|
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 {
|
||||||
|
|||||||
@ -18,34 +18,16 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -54,56 +36,14 @@ 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 blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||||
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 &&
|
||||||
@ -133,40 +73,24 @@ 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 = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
currentTrackId = trackOptionsContext.currentTrackId,
|
||||||
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 = ::handleTrackOptionsAction,
|
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
trackOptionsState = trackOptionsContext::stateFor,
|
||||||
onBulkDownload = { tracks ->
|
onBulkDownload = viewModel::downloadTracks,
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
},
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToQueue = { tracks ->
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
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 = { showAddToPlaylistPicker = false },
|
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||||
onPlaylistSelected = { playlistId ->
|
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||||
scope.launch {
|
|
||||||
libraryRepository.addTracksToPlaylist(
|
|
||||||
playlistId,
|
|
||||||
tracksToAddToPlaylist.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -34,10 +34,15 @@ 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
|
||||||
@ -46,6 +51,7 @@ 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
|
||||||
@ -88,6 +94,9 @@ 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>()
|
||||||
|
|
||||||
@ -96,12 +105,36 @@ 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(
|
||||||
@ -112,6 +145,7 @@ class SavedTracksViewModel(
|
|||||||
repository.savedTracksIdsFlow
|
repository.savedTracksIdsFlow
|
||||||
) { _, _ -> }.collect {
|
) { _, _ -> }.collect {
|
||||||
loadInitialData()
|
loadInitialData()
|
||||||
|
loadCurrentUser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blacklistRepository.blacklistedTracks
|
blacklistRepository.blacklistedTracks
|
||||||
@ -122,6 +156,14 @@ 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()
|
||||||
@ -182,20 +224,10 @@ class SavedTracksViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
val queueIndex = queue.indexOfFirst { entry ->
|
queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}
|
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||||
if (queueIndex >= 0) {
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
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 -> {
|
||||||
@ -210,12 +242,48 @@ class SavedTracksViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.ToggleFavorite -> {}
|
is TrackOptionsAction.ToggleFavorite -> {}
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||||
|
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 {
|
||||||
|
|||||||
@ -171,20 +171,10 @@ 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()
|
||||||
val queueIndex = queue.indexOfFirst { entry ->
|
queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
||||||
}
|
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||||
if (queueIndex >= 0) {
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
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 -> {
|
||||||
|
|||||||
@ -20,6 +20,10 @@ 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
|
||||||
|
|
||||||
@ -28,14 +32,20 @@ class SettingsRepository(private val database: Database) {
|
|||||||
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
val userSettings: Flow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
|||||||
@ -32,11 +32,7 @@ interface SettingsProvider {
|
|||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val repository: SettingsRepository
|
private val repository: SettingsRepository
|
||||||
) : ViewModel(), SettingsProvider {
|
) : ViewModel(), SettingsProvider {
|
||||||
override val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
|
override val settingsState = repository.userSettings
|
||||||
viewModelScope,
|
|
||||||
SharingStarted.WhileSubscribed(5000),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun updateSettings(transform: UserSettings.() -> UserSettings) {
|
fun updateSettings(transform: UserSettings.() -> UserSettings) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@ -95,8 +95,9 @@ class AlternativeTrackContentViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectAlternative(source: AudioSource) {
|
fun selectAlternative(source: AudioSource) {
|
||||||
val currentTrack = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
|
val currentTrack =
|
||||||
if (currentTrack == null) return
|
(audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
|
||||||
|
?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
alternativeTracksRepository.selectAlternative(currentTrack, source)
|
alternativeTracksRepository.selectAlternative(currentTrack, source)
|
||||||
activeSourceIdFlow.value = source.id
|
activeSourceIdFlow.value = source.id
|
||||||
|
|||||||
@ -19,9 +19,6 @@ 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
|
||||||
@ -29,7 +26,6 @@ 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
|
||||||
@ -44,128 +40,54 @@ 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 logger = rememberLogger("PlayerQueueContent")
|
val state by viewModel.queueContentUiState.collectAsState()
|
||||||
val queueContentUiState by viewModel.queueContentUiState.collectAsState()
|
val displayItems = state.displayItems
|
||||||
val queue = queueContentUiState.queue
|
val filterQuery = state.filterQuery
|
||||||
val currentQueueEntry = queueContentUiState.currentQueueEntry
|
val isFiltered = state.isFiltered
|
||||||
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 =
|
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||||
rememberReorderableLazyListState(
|
|
||||||
lazyListState,
|
lazyListState,
|
||||||
onMove = { from, to ->
|
onMove = { from, to ->
|
||||||
if (isFiltered) return@rememberReorderableLazyListState
|
if (isFiltered) return@rememberReorderableLazyListState
|
||||||
val item = displayList.removeAt(from.index)
|
viewModel.onMove(from.index, to.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
|
||||||
@ -204,7 +126,7 @@ fun PlayerQueueContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayList.isEmpty()) {
|
if (displayItems.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "No queue entries",
|
text = "No queue entries",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@ -217,7 +139,7 @@ fun PlayerQueueContent(
|
|||||||
state = lazyListState,
|
state = lazyListState,
|
||||||
contentPadding = PaddingValues(bottom = 8.dp),
|
contentPadding = PaddingValues(bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
items(displayList, key = { item -> item.id }) { item ->
|
items(displayItems, key = { it.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(
|
||||||
@ -225,7 +147,8 @@ 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) },
|
||||||
onDragStopped = ::finalizeReorder,
|
onDragStarted = { viewModel.onDragStart() },
|
||||||
|
onDragStopped = { viewModel.onDragStop() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -242,6 +165,7 @@ 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) }
|
||||||
@ -263,7 +187,12 @@ private fun QueueItemRow(
|
|||||||
.size(24.dp)
|
.size(24.dp)
|
||||||
.then(
|
.then(
|
||||||
if (reorderScope != null) {
|
if (reorderScope != null) {
|
||||||
with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) }
|
with(reorderScope) {
|
||||||
|
Modifier.draggableHandle(
|
||||||
|
onDragStarted = { onDragStarted() },
|
||||||
|
onDragStopped = onDragStopped,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
@ -319,7 +248,7 @@ private fun QueueItemRow(
|
|||||||
},
|
},
|
||||||
trailing = {
|
trailing = {
|
||||||
Text(
|
Text(
|
||||||
text = item.durationMs.toDurationString(),
|
text = item.durationLabel,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@ -356,53 +285,3 @@ 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')}"
|
|
||||||
}
|
|
||||||
|
|||||||
@ -30,10 +30,20 @@ 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 queue: List<QueueEntry> = emptyList(),
|
val displayItems: List<QueueItemUi> = emptyList(),
|
||||||
val currentQueueEntry: QueueEntry? = null,
|
val isFiltered: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlayerQueueContentViewModel(
|
class PlayerQueueContentViewModel(
|
||||||
@ -41,18 +51,79 @@ 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()
|
||||||
|
|
||||||
val queueContentUiState: StateFlow<QueueContentUiState> = combine(
|
private val computedItems: StateFlow<List<QueueItemUi>> = 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,
|
||||||
) { queue, currentQueueEntry, filterQuery ->
|
) { items, buffer, 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,
|
||||||
queue = queue,
|
displayItems = buffer ?: filtered,
|
||||||
currentQueueEntry = currentQueueEntry,
|
isFiltered = isFiltered,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@ -72,13 +143,6 @@ 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 {
|
||||||
@ -96,9 +160,67 @@ 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')}"
|
||||||
}
|
}
|
||||||
@ -490,6 +490,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user