Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
b13d18ca9a
Merge 5e65508fed into a09a7c8976 2026-07-17 18:15:45 +00:00
51 changed files with 1196 additions and 2893 deletions

View File

@ -182,8 +182,14 @@ jobs:
- name: Make gradlew executable - name: Make gradlew executable
run: chmod +x gradlew run: chmod +x gradlew
- name: Package Linux - name: Package DEB
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm :composeApp:packageReleaseAppImage run: ./gradlew :composeApp:packageReleaseDeb
- name: Package RPM
run: ./gradlew :composeApp:packageReleaseRpm
- name: Package AppImage
run: ./gradlew :composeApp:packageReleaseAppImage
- name: Rename Linux artifacts - name: Rename Linux artifacts
run: | run: |

View File

@ -29,10 +29,7 @@
- `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system. - `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system.
- `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 ## Desktop JVM specifics
- **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.
- 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.
- JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`. - JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`.
- JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`. - JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`.

View File

@ -32,5 +32,4 @@ plugins {
alias(libs.plugins.vlcjBundler) apply false alias(libs.plugins.vlcjBundler) apply false
alias(libs.plugins.uniffi) apply false alias(libs.plugins.uniffi) apply false
alias(libs.plugins.cargo) apply false alias(libs.plugins.cargo) apply false
alias(libs.plugins.mokkery) apply false
} }

View File

@ -34,7 +34,6 @@ plugins {
alias(libs.plugins.vlcjBundler) alias(libs.plugins.vlcjBundler)
alias(libs.plugins.uniffi) alias(libs.plugins.uniffi)
alias(libs.plugins.cargo) alias(libs.plugins.cargo)
alias(libs.plugins.mokkery)
kotlin("plugin.atomicfu") version libs.versions.kotlin kotlin("plugin.atomicfu") version libs.versions.kotlin
} }

View File

@ -44,7 +44,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { actual class AudioPlayer actual constructor(context: Any) {
actual val context: Any = context actual val context: Any = context
@ -90,18 +90,18 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1) private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow() actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow() actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow() actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow() actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow() actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow() actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow() actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow() actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow() actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow() actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow() actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow() actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
private var lastPlaybackState = Player.STATE_IDLE private var lastPlaybackState = Player.STATE_IDLE
@ -204,27 +204,27 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
.build() .build()
} }
actual override suspend fun play() { actual suspend fun play() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
ensureServiceStarted() ensureServiceStarted()
exoPlayer.play() exoPlayer.play()
} }
} }
actual override suspend fun pause() { actual suspend fun pause() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
exoPlayer.pause() exoPlayer.pause()
} }
} }
actual override suspend fun stop() { actual suspend fun stop() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
exoPlayer.stop() exoPlayer.stop()
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
} }
} }
actual override suspend fun seekTo(position: Duration) { actual suspend fun seekTo(position: Duration) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration) val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration)
exoPlayer.seekTo(targetMs) exoPlayer.seekTo(targetMs)
@ -232,7 +232,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun loop(state: LoopState) { actual suspend fun loop(state: LoopState) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val repeatMode = when (state) { val repeatMode = when (state) {
LoopState.NONE -> Player.REPEAT_MODE_OFF LoopState.NONE -> Player.REPEAT_MODE_OFF
@ -244,14 +244,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun shuffle(enabled: Boolean) { actual suspend fun shuffle(enabled: Boolean) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
exoPlayer.shuffleModeEnabled = enabled exoPlayer.shuffleModeEnabled = enabled
_shuffleMode.tryEmit(enabled) _shuffleMode.tryEmit(enabled)
} }
} }
actual override suspend fun load( actual suspend fun load(
playlist: List<MediaItem>, playlist: List<MediaItem>,
autoPlay: Boolean, autoPlay: Boolean,
startPosition: Int startPosition: Int
@ -288,7 +288,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun addMediaItem(mediaItem: MediaItem) { actual suspend fun addMediaItem(mediaItem: MediaItem) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
currentPlaylist.add(mediaItem) currentPlaylist.add(mediaItem)
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
@ -297,7 +297,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val currentIndex = exoPlayer.currentMediaItemIndex val currentIndex = exoPlayer.currentMediaItemIndex
val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size) val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size)
@ -313,7 +313,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun removeMediaItem(mediaItem: MediaItem) { actual suspend fun removeMediaItem(mediaItem: MediaItem) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val index = urlIndexMap[mediaItem.url] ?: return@withContext val index = urlIndexMap[mediaItem.url] ?: return@withContext
currentPlaylist.removeAt(index) currentPlaylist.removeAt(index)
@ -326,7 +326,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { actual suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return@withContext if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return@withContext
@ -342,19 +342,19 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun skipToNext() { actual suspend fun skipToNext() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
exoPlayer.seekToNextMediaItem() exoPlayer.seekToNextMediaItem()
} }
} }
actual override suspend fun skipToPrevious() { actual suspend fun skipToPrevious() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
exoPlayer.seekToPreviousMediaItem() exoPlayer.seekToPreviousMediaItem()
} }
} }
actual override suspend fun jumpTo(index: Int) { actual suspend fun jumpTo(index: Int) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (index in currentPlaylist.indices) { if (index in currentPlaylist.indices) {
exoPlayer.seekToDefaultPosition(index) exoPlayer.seekToDefaultPosition(index)
@ -362,7 +362,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun setVolume(volume: Float) { actual suspend fun setVolume(volume: Float) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val clamped = volume.coerceIn(0f, 1f) val clamped = volume.coerceIn(0f, 1f)
exoPlayer.setVolume(clamped) exoPlayer.setVolume(clamped)
@ -370,7 +370,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun setPlaybackSpeed(speed: Float) { actual suspend fun setPlaybackSpeed(speed: Float) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val clamped = speed.coerceIn(0.25f, 4f) val clamped = speed.coerceIn(0.25f, 4f)
exoPlayer.setPlaybackSpeed(clamped) exoPlayer.setPlaybackSpeed(clamped)
@ -378,9 +378,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override fun isDisposed(): Boolean = disposed actual fun isDisposed(): Boolean = disposed
actual override fun dispose() { actual fun dispose() {
disposed = true disposed = true
exoPlayer.release() exoPlayer.release()
currentPlaylist.clear() currentPlaylist.clear()

View File

@ -19,7 +19,6 @@ package dev.krtirtho.spotube.core.di
import android.content.Context import android.content.Context
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.share.AndroidShareService import dev.krtirtho.spotube.core.share.AndroidShareService
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
@ -30,7 +29,7 @@ import org.koin.dsl.module
actual val platformModules = module { actual val platformModules = module {
single { Paths(get()) } single { Paths(get()) }
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) } single { AudioPlayer(get<Context>()) }
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) } single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
single<ShareService> { AndroidShareService(get()) } single<ShareService> { AndroidShareService(get()) }
single { single {

View File

@ -126,7 +126,6 @@
<string name="settings_plugins_plugin_content_description">%1$s plugin</string> <string name="settings_plugins_plugin_content_description">%1$s plugin</string>
<string name="settings_plugins_clear_content_description">Clear</string> <string name="settings_plugins_clear_content_description">Clear</string>
<string name="plugin_configure_title">Configure</string>
<string name="plugin_screen_title">Plugin Manager</string> <string name="plugin_screen_title">Plugin Manager</string>
<string name="plugin_error_enter_url">Please enter a URL</string> <string name="plugin_error_enter_url">Please enter a URL</string>
<string name="plugin_error_url_scheme">URL must start with http:// or https://</string> <string name="plugin_error_url_scheme">URL must start with http:// or https://</string>
@ -146,8 +145,6 @@
<string name="plugin_action_remove">Remove plugin</string> <string name="plugin_action_remove">Remove plugin</string>
<string name="plugin_action_login">Login</string> <string name="plugin_action_login">Login</string>
<string name="plugin_action_logout">Logout</string> <string name="plugin_action_logout">Logout</string>
<string name="plugin_section_url_title">Download from URL</string>
<string name="plugin_section_file_title">Install from file</string>
<string name="plugin_permissions_author_version">• %1$s</string> <string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Permissions requested</string> <string name="plugin_permissions_requested_title">Permissions requested</string>

View File

@ -52,8 +52,11 @@ enum class PlayerState {
IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED
} }
interface AudioPlayerInterface { @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
// // Playback expect class AudioPlayer(context: Any) {
val context: Any // Optional context for platform-specific implementations (e.g., Android Context)
// Playback
suspend fun play() suspend fun play()
suspend fun pause() suspend fun pause()
suspend fun stop() suspend fun stop()
@ -62,7 +65,7 @@ interface AudioPlayerInterface {
suspend fun shuffle(enabled: Boolean) suspend fun shuffle(enabled: Boolean)
// Playlist management // Playlist management
suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean = true, startPosition: Int = 0)
suspend fun addMediaItem(mediaItem: MediaItem) suspend fun addMediaItem(mediaItem: MediaItem)
suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem)
suspend fun removeMediaItem(mediaItem: MediaItem) suspend fun removeMediaItem(mediaItem: MediaItem)
@ -92,43 +95,3 @@ interface AudioPlayerInterface {
fun isDisposed(): Boolean fun isDisposed(): Boolean
fun dispose() // Clean up resources when done. The player should not be used after this is called. fun dispose() // Clean up resources when done. The player should not be used after this is called.
} }
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect class AudioPlayer(context: Any) : AudioPlayerInterface {
val context: Any // Optional context for platform-specific implementations (e.g., Android Context)
override suspend fun play()
override suspend fun pause()
override suspend fun stop()
override suspend fun seekTo(position: Duration)
override suspend fun loop(state: LoopState)
override suspend fun shuffle(enabled: Boolean)
override suspend fun load(
playlist: List<MediaItem>,
autoPlay: Boolean,
startPosition: Int
)
override suspend fun addMediaItem(mediaItem: MediaItem)
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem)
override suspend fun removeMediaItem(mediaItem: MediaItem)
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int)
override suspend fun skipToNext()
override suspend fun skipToPrevious()
override suspend fun jumpTo(index: Int)
override val playerStateFlow: StateFlow<PlayerState>
override val currentMediaItemFlow: StateFlow<MediaItem?>
override val playlistFlow: StateFlow<List<MediaItem>>
override val durationFlow: StateFlow<Duration>
override val positionFlow: StateFlow<Duration>
override val bufferingPositionFlow: StateFlow<Duration>
override val loopStateFlow: StateFlow<LoopState>
override val shuffleModeFlow: StateFlow<Boolean>
override val playbackSpeedFlow: StateFlow<Float>
override val volumeFlow: StateFlow<Float>
override val completionFlow: Flow<Unit>
override val errorFlow: Flow<Throwable>
override suspend fun setVolume(volume: Float)
override suspend fun setPlaybackSpeed(speed: Float)
override fun isDisposed(): Boolean
override fun dispose()
}

View File

@ -24,18 +24,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
interface QueueStateRepository { class AudioPlayerQueueRepository(private val database: Database) {
suspend fun getPersistedState(): PersistedQueueState?
suspend fun saveState(state: PersistedQueueState)
suspend fun clearState()
}
class AudioPlayerQueueRepository(private val database: Database) : QueueStateRepository {
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
override suspend fun getPersistedState(): PersistedQueueState? { suspend fun getPersistedState(): PersistedQueueState? {
return database.audioPlayerQueueDataStore.data.map { preferences -> return database.audioPlayerQueueDataStore.data.map { preferences ->
val payload = preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] val payload = preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY]
payload?.let { payload?.let {
@ -45,13 +39,13 @@ class AudioPlayerQueueRepository(private val database: Database) : QueueStateRep
}.first() }.first()
} }
override suspend fun saveState(state: PersistedQueueState) { suspend fun saveState(state: PersistedQueueState) {
database.audioPlayerQueueDataStore.edit { preferences -> database.audioPlayerQueueDataStore.edit { preferences ->
preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] = json.encodeToString(state) preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] = json.encodeToString(state)
} }
} }
override suspend fun clearState() { suspend fun clearState() {
database.audioPlayerQueueDataStore.edit { preferences -> database.audioPlayerQueueDataStore.edit { preferences ->
preferences.remove(DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY) preferences.remove(DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY)
} }

View File

@ -19,8 +19,8 @@ package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
@ -30,6 +30,7 @@ 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
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -40,10 +41,10 @@ import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class DeviceAudioPlayerQueue( class DeviceAudioPlayerQueue(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayer,
private val settingsProvider: SettingsProvider, private val settingsViewModel: SettingsViewModel,
private val repository: QueueStateRepository, private val repository: AudioPlayerQueueRepository,
private val pluginProvider: PluginProvider, private val pluginManager: PluginManager,
) : AudioPlayerQueue, KoinComponent { ) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
@ -309,7 +310,7 @@ class DeviceAudioPlayerQueue(
private suspend fun handleQueueCompletion() { private suspend fun handleQueueCompletion() {
if (isFetchingRecommendations) return if (isFetchingRecommendations) return
val settings = settingsProvider.settingsState.value ?: return val settings = settingsViewModel.settingsState.value ?: return
if (!settings.enableEndlessPlayback) return if (!settings.enableEndlessPlayback) return
val queue = queueFlow.value val queue = queueFlow.value
@ -329,7 +330,7 @@ class DeviceAudioPlayerQueue(
isFetchingRecommendations = true isFetchingRecommendations = true
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" } logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run { val metadataService = pluginManager.selectedMetadataPlugin.value ?: run {
logger.w { "Endless playback: no metadata plugin available" } logger.w { "Endless playback: no metadata plugin available" }
isFetchingRecommendations = false isFetchingRecommendations = false
return return
@ -481,7 +482,7 @@ class DeviceAudioPlayerQueue(
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
val port: Int = val port: Int =
settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() settingsViewModel.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
val baseUrl = "http://127.0.0.1:$port" val baseUrl = "http://127.0.0.1:$port"
return when (protocol) { return when (protocol) {
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"

View File

@ -21,49 +21,45 @@ import co.touchlab.kermit.Logger
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
import dev.krtirtho.spotube.core.db.Database import dev.krtirtho.spotube.core.db.Database
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.navigation.navigationModule
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
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.album.AlbumViewModel
import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.artist.ArtistRepository
import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.artist.ArtistViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.album.AlbumViewModel
import dev.krtirtho.spotube.modules.home.HomeScreenRepository import dev.krtirtho.spotube.modules.home.HomeScreenRepository
import dev.krtirtho.spotube.modules.home.HomeScreenViewModel import dev.krtirtho.spotube.modules.home.HomeScreenViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsViewModel
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsViewModel
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.createdAtStart
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
@ -104,11 +100,11 @@ val sharedModules = module {
viewModelOf(::LibraryLocalTracksViewModel) viewModelOf(::LibraryLocalTracksViewModel)
// Plugin system // Plugin system
singleOf(::PluginManager) { bind<PluginProvider>() } singleOf(::PluginManager)
// Settings // Settings
singleOf(::SettingsRepository) singleOf(::SettingsRepository)
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() } viewModelOf(::SettingsViewModel)
// Downloads // Downloads
singleOf(::DownloadManager) singleOf(::DownloadManager)
@ -167,7 +163,7 @@ val sharedModules = module {
singleOf(::LocalServer) withOptions { singleOf(::LocalServer) withOptions {
createdAtStart() createdAtStart()
} }
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() } singleOf(::AudioPlayerQueueRepository)
single<AudioPlayerQueue> { single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get()) DeviceAudioPlayerQueue(get(), get(), get(), get())
} }

View File

@ -1,124 +0,0 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
import dev.krtirtho.spotube.resources.iconsax.Iconsax
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveDialogBottomSheet(
onDismiss: () -> Unit,
title: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier,
breakpointDp: Float = 600f,
actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
if (isLargeScreen) {
ThemedDialog(
onDismissRequest = onDismiss,
title = if (title != null) {
{
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f)) { title() }
IconButton(onClick = onDismiss) {
Icon(
Iconsax.FluentDismiss,
contentDescription = "Close dialog",
)
}
}
}
} else {
null
},
actions = actions,
modifier = modifier,
) {
content()
}
} else {
ModalBottomSheet(
onDismissRequest = onDismiss,
dragHandle = null,
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp),
horizontalAlignment = Alignment.Start,
) {
title?.let {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f)) { it() }
IconButton(onClick = onDismiss) {
Icon(
Iconsax.FluentDismiss,
contentDescription = "Close dialog",
)
}
}
Spacer(Modifier.height(12.dp))
}
content()
actions?.let {
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Spacer(Modifier.height(8.dp))
it()
}
}
Spacer(Modifier.height(24.dp))
}
}
}

View File

@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -50,6 +51,7 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
@ -317,6 +319,102 @@ fun CollectionDetails(
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CollapsedCollectionHeader(
title: String,
imageURL: String,
imageResource: DrawableResource? = null,
isPlaying: Boolean,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
sharedElementKey: String? = null,
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (onBack != null) {
IconButton(
onClick = onBack,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Iconsax.ArrowLeft3,
contentDescription = "Back",
modifier = Modifier.size(20.dp),
)
}
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) {
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else if (imageResource != null) {
androidx.compose.foundation.Image(
painter = painterResource(imageResource),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextWithShimmer(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onPlay,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp),
)
}
}
}
}
@Composable @Composable
@androidx.compose.ui.tooling.preview.Preview @androidx.compose.ui.tooling.preview.Preview
fun CollectionDetailsPreview() { fun CollectionDetailsPreview() {

View File

@ -1,257 +0,0 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
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.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@Composable
fun CollectionView(
title: String,
description: String,
imageURL: String,
imageResource: DrawableResource? = null,
ownerName: String,
ownerImageURL: String?,
onOwnerClick: () -> Unit,
onPlay: () -> Unit,
onShufflePlay: () -> Unit,
onAddToQueue: () -> Unit,
isPlaying: Boolean = false,
isFollowing: Boolean = false,
onFollowClick: () -> Unit = {},
showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null,
isLoading: Boolean = false,
error: String? = null,
onRetry: (() -> Unit)? = null,
tracks: List<MetadataTrack> = emptyList(),
hasMore: Boolean = false,
isLoadingNextPage: Boolean = false,
currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false,
onTrackClick: (MetadataTrack) -> Unit = {},
onLoadNextPage: () -> Unit = {},
onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> },
onArtistClick: (MetadataArtist.Basic) -> Unit = {},
onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {},
onArtistsOverflowClick: (MetadataTrack) -> Unit = {},
onBulkDownload: (List<MetadataTrack>) -> Unit = {},
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {},
) {
val listState = rememberLazyListState()
val isCollapsed by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
Scaffold(
topBar = {
ApplicationMainBar(
backButton = true,
title = if (isCollapsed) {
{
CollapsedCollectionTitle(
title = title,
imageURL = imageURL,
imageResource = imageResource,
)
}
} else {
{}
},
actions = if (isCollapsed) {
{
IconButton(onClick = onPlay) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
}
} else {
{}
},
)
}
) { innerPadding ->
if (error != null && tracks.isEmpty() && !isLoading) {
ErrorDisplay(
errorMessage = error,
onRetry = onRetry ?: {},
modifier = Modifier.padding(innerPadding),
)
} else {
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
headerContent = {
if (isLoading && tracks.isEmpty()) {
dev.krtirtho.spotube.core.ui.misc.SkeletonTree(true) {
CollectionDetails(
title = title.ifBlank { "Loading..." },
description = description,
imageURL = imageURL,
imageResource = imageResource,
ownerName = ownerName.ifBlank { "Unknown" },
ownerImageURL = ownerImageURL,
onOwnerClick = onOwnerClick,
onPlay = onPlay,
onShufflePlay = onShufflePlay,
onAddToQueue = onAddToQueue,
isPlaying = isPlaying,
isFollowing = isFollowing,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton,
onEdit = onEdit,
sharedElementKey = sharedElementKey,
)
}
} else {
CollectionDetails(
title = title,
description = description,
imageURL = imageURL,
imageResource = imageResource,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = onOwnerClick,
onPlay = onPlay,
onShufflePlay = onShufflePlay,
onAddToQueue = onAddToQueue,
isPlaying = isPlaying,
isFollowing = isFollowing,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton,
onEdit = onEdit,
sharedElementKey = sharedElementKey,
)
}
},
footerContent = footerContent,
tracks = tracks,
error = error,
hasMore = hasMore,
isLoading = isLoading,
isLoadingNextPage = isLoadingNextPage,
currentTrackId = currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
onTrackClick = onTrackClick,
onLoadNextPage = onLoadNextPage,
onTrackOptionsAction = onTrackOptionsAction,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
trackOptionsState = trackOptionsState,
)
}
trailingContent()
}
}
@Composable
private fun CollapsedCollectionTitle(
title: String,
imageURL: String,
imageResource: DrawableResource? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) {
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop,
)
} else if (imageResource != null) {
androidx.compose.foundation.Image(
painter = painterResource(imageResource),
contentDescription = title,
modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextWithShimmer(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 10.dp),
)
}
}

View File

@ -18,8 +18,11 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -151,6 +154,8 @@ fun TrackList(
simplified: Boolean = false, simplified: Boolean = false,
scrollable: Boolean = true, scrollable: Boolean = true,
state: LazyListState? = null, state: LazyListState? = null,
collapsedHeader: (@Composable () -> Unit)? = null,
isCollapsedHeaderShown: Boolean = false,
) { ) {
var filterQuery by rememberSaveable { mutableStateOf("") } var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -486,6 +491,17 @@ fun TrackList(
} }
} }
if (collapsedHeader != null) {
AnimatedVisibility(
visible = isCollapsedHeaderShown,
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = if (isCompact) 6.dp else 16.dp),
) {
collapsedHeader()
}
}
if (!useDropdownForOptions) { if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track -> selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet( TrackOptionsBottomSheet(

View File

@ -17,13 +17,18 @@
package dev.krtirtho.spotube.modules.album package dev.krtirtho.spotube.modules.album
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -33,9 +38,14 @@ 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -68,6 +78,14 @@ fun AlbumScreen(albumId: String) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id currentUserId = libraryRepository.currentUser()?.id
} }
@ -102,8 +120,58 @@ fun AlbumScreen(albumId: String) {
} }
} }
val dataState = state as? AlbumScreenState.Data Scaffold(
val album = dataState?.album topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is AlbumScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true) {
CollectionDetails(
title = "Loading album...",
description = "",
imageURL = "",
ownerName = "Unknown artist",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is AlbumScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as AlbumScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is AlbumScreenState.Data -> {
val dataState = state as AlbumScreenState.Data
val album = dataState.album
val ownerName = val ownerName =
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" } album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
@ -112,10 +180,21 @@ fun AlbumScreen(albumId: String) {
val isAlbumPlaying = currentCollectionEntry?.id == albumId && val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
val errorMessage = (state as? AlbumScreenState.Error)?.message TrackList(
val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) modifier = Modifier.padding(innerPadding),
state = listState,
CollectionView( collapsedHeader = {
CollapsedCollectionHeader(
title = album?.title ?: "Album",
imageURL = artworkUrl,
isPlaying = isAlbumPlaying,
onPlay = viewModel::playAlbum,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = album?.title ?: "Loading album...", title = album?.title ?: "Loading album...",
description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}", description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl, imageURL = artworkUrl,
@ -123,7 +202,9 @@ fun AlbumScreen(albumId: String) {
ownerImageURL = ownerImageURL, ownerImageURL = ownerImageURL,
onOwnerClick = { onOwnerClick = {
firstArtistId?.let { firstArtistId?.let {
navigationCommands.navigateTo(Routes.Artist(it)) navigationCommands.navigateTo(
Routes.Artist(it)
)
} }
}, },
onPlay = viewModel::playAlbum, onPlay = viewModel::playAlbum,
@ -132,11 +213,12 @@ fun AlbumScreen(albumId: String) {
isPlaying = isAlbumPlaying, isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId), isFollowing = savedAlbumIds.contains(albumId),
onFollowClick = viewModel::toggleSavedAlbum, onFollowClick = viewModel::toggleSavedAlbum,
isLoading = isLoading, )
error = errorMessage, },
onRetry = { viewModel.refresh() }, tracks = dataState.tracks,
tracks = dataState?.tracks ?: emptyList(), error = null,
hasMore = dataState?.nextPagination != null, hasMore = dataState.nextPagination != null,
isLoading = state is AlbumScreenState.Loading && dataState.tracks.isEmpty(),
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore, isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -159,7 +241,10 @@ fun AlbumScreen(albumId: String) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
trailingContent = { )
}
}
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
@ -173,6 +258,5 @@ fun AlbumScreen(albumId: String) {
} }
}, },
) )
}, }
)
} }

View File

@ -19,6 +19,8 @@ package dev.krtirtho.spotube.modules.library
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -32,17 +34,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.ChipTab
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -90,12 +94,11 @@ fun LibraryScreen(
) { ) {
items(LibraryTab.entries.size) { index -> items(LibraryTab.entries.size) { index ->
val tab = LibraryTab.entries[index] val tab = LibraryTab.entries[index]
ChipTab( FilterChip(
label = { Text(tab.title) },
selected = currentTab == tab, selected = currentTab == tab,
onClick = { libraryState.onTabSelected(tab) }, onClick = { libraryState.onTabSelected(tab) },
) { )
Text(tab.title)
}
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

@ -20,7 +20,7 @@ package dev.krtirtho.spotube.modules.lyrics
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
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.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
@ -50,7 +50,7 @@ data class LyricsUiState(
class LyricsViewModel( class LyricsViewModel(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayer,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
) : ViewModel() { ) : ViewModel() {

View File

@ -21,9 +21,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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
@ -41,14 +44,20 @@ 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.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.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.modules.playlist.AddTracksToPlaylistDialog
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 kotlinx.coroutines.launch
@ -81,6 +90,14 @@ fun PlaylistScreen(playlistId: String) {
var showAddTracksDialog by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action) viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) { if (action is TrackOptionsAction.Share) {
@ -98,18 +115,70 @@ fun PlaylistScreen(playlistId: String) {
} }
} }
val dataState = state as? PlaylistScreenState.Data Scaffold(
val playlist = dataState?.playlist topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is PlaylistScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true){
CollectionDetails(
title = "Loading playlist...",
description = "",
imageURL = "",
ownerName = "Unknown",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is PlaylistScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as PlaylistScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is PlaylistScreenState.Data -> {
val dataState = state as PlaylistScreenState.Data
val playlist = dataState.playlist
val owner = playlist?.owner val owner = playlist?.owner
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown" val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty() val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
val isPlaying = currentCollectionEntry?.id == playlistId && val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry -> val queueTrackIds = queue.mapNotNull { entry ->
@ -123,10 +192,38 @@ fun PlaylistScreen(playlistId: String) {
) )
} }
val errorMessage = (state as? PlaylistScreenState.Error)?.message TrackList(
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) modifier = Modifier.padding(innerPadding),
state = listState,
val footerContent: (@Composable () -> Unit)? = if (isOwner && dataState != null) { collapsedHeader = {
CollapsedCollectionHeader(
title = playlist?.title ?: "Playlist",
imageURL = artworkUrl,
isPlaying = isPlaying,
onPlay = viewModel::playPlaylist,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = playlist?.title ?: "Loading playlist...",
description = playlist?.description.orEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
)
},
footerContent = if (isOwner) {
{ {
Row( Row(
modifier = Modifier modifier = Modifier
@ -143,28 +240,11 @@ fun PlaylistScreen(playlistId: String) {
} }
} }
} }
} else null } else null,
tracks = dataState.tracks,
CollectionView( error = null,
title = playlist?.title ?: "Loading playlist...", hasMore = dataState.nextPagination != null,
description = playlist?.description.orEmpty(), isLoading = state is PlaylistScreenState.Loading && dataState.tracks.isEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
isLoading = isLoading,
error = errorMessage,
onRetry = { viewModel.refresh() },
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore, isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -187,14 +267,17 @@ fun PlaylistScreen(playlistId: String) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
footerContent = footerContent, )
trailingContent = { }
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist }
val dataState = state as? PlaylistScreenState.Data
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet( PlaylistFormSheet(
visible = showEditPlaylist, visible = showEditPlaylist,
onDismiss = { showEditPlaylist = false }, onDismiss = { showEditPlaylist = false },
isEditing = true, isEditing = true,
initialData = loadedPlaylist?.let { initialData = playlist?.let {
PlaylistFormData( PlaylistFormData(
name = it.title, name = it.title,
description = it.description.orEmpty(), description = it.description.orEmpty(),
@ -236,6 +319,5 @@ fun PlaylistScreen(playlistId: String) {
viewModel.refresh() viewModel.refresh()
}, },
) )
}, }
)
} }

View File

@ -62,18 +62,15 @@ import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.collections.set
import kotlin.getValue
const val PLUGIN_API_VERSION = "0.0.1" const val PLUGIN_API_VERSION = "0.0.1"
interface PluginProvider {
val selectedMetadataPlugin: StateFlow<PluginService?>
}
class PluginManager( class PluginManager(
val database: Database, val database: Database,
val paths: Paths, val paths: Paths,
) : KoinComponent, PluginProvider { ) : KoinComponent {
private val logger by injectLogger<PluginManager>() private val logger by injectLogger<PluginManager>()
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
@ -242,7 +239,7 @@ class PluginManager(
val lyricsPlugins = filterPluginByType(PluginAbility.LYRICS) val lyricsPlugins = filterPluginByType(PluginAbility.LYRICS)
val scrobblePlugins = filterPluginByType(PluginAbility.SCROBBLE) val scrobblePlugins = filterPluginByType(PluginAbility.SCROBBLE)
override val selectedMetadataPlugin = val selectedMetadataPlugin =
filterSelectedPluginByType(PluginAbility.METADATA) filterSelectedPluginByType(PluginAbility.METADATA)
val selectedAudioPlugin = val selectedAudioPlugin =
filterSelectedPluginByType(PluginAbility.AUDIO) filterSelectedPluginByType(PluginAbility.AUDIO)

View File

@ -22,12 +22,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -53,65 +51,55 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.spotube.PlatformType
import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.plugin.components.InstallSection
import dev.krtirtho.spotube.modules.plugin.components.PluginCard import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import kotlinx.coroutines.flow.StateFlow
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_download
import spotube.composeapp.generated.resources.plugin_action_install_from_file
import spotube.composeapp.generated.resources.plugin_configure_title
import spotube.composeapp.generated.resources.plugin_empty_subtitle import spotube.composeapp.generated.resources.plugin_empty_subtitle
import spotube.composeapp.generated.resources.plugin_empty_title import spotube.composeapp.generated.resources.plugin_empty_title
import spotube.composeapp.generated.resources.plugin_error_download_failed import spotube.composeapp.generated.resources.plugin_error_download_failed
import spotube.composeapp.generated.resources.plugin_error_enter_url import spotube.composeapp.generated.resources.plugin_error_enter_url
import spotube.composeapp.generated.resources.plugin_error_url_scheme import spotube.composeapp.generated.resources.plugin_error_url_scheme
import spotube.composeapp.generated.resources.plugin_install_section_title
import spotube.composeapp.generated.resources.plugin_installed_count import spotube.composeapp.generated.resources.plugin_installed_count
import spotube.composeapp.generated.resources.plugin_installed_plural import spotube.composeapp.generated.resources.plugin_installed_plural
import spotube.composeapp.generated.resources.plugin_installed_singular import spotube.composeapp.generated.resources.plugin_installed_singular
import spotube.composeapp.generated.resources.plugin_screen_title import spotube.composeapp.generated.resources.plugin_screen_title
import spotube.composeapp.generated.resources.plugin_section_file_title import spotube.composeapp.generated.resources.settings_plugins_action_change
import spotube.composeapp.generated.resources.plugin_section_url_title import spotube.composeapp.generated.resources.settings_plugins_action_select
import spotube.composeapp.generated.resources.plugin_url_placeholder
import spotube.composeapp.generated.resources.settings_plugins_ability_audio import spotube.composeapp.generated.resources.settings_plugins_ability_audio
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
import spotube.composeapp.generated.resources.settings_plugins_action_change import spotube.composeapp.generated.resources.settings_plugins_clear
import spotube.composeapp.generated.resources.settings_plugins_action_select
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
import spotube.composeapp.generated.resources.settings_plugins_manage_title
import spotube.composeapp.generated.resources.settings_plugins_no_plugins import spotube.composeapp.generated.resources.settings_plugins_no_plugins
import spotube.composeapp.generated.resources.settings_plugins_no_selection import spotube.composeapp.generated.resources.settings_plugins_no_selection
import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
@ -132,7 +120,6 @@ fun PluginScreen(
var urlInput by remember { mutableStateOf("") } var urlInput by remember { mutableStateOf("") }
var urlError by remember { mutableStateOf<String?>(null) } var urlError by remember { mutableStateOf<String?>(null) }
var isLoadingUrl by remember { mutableStateOf(false) } var isLoadingUrl by remember { mutableStateOf(false) }
var showInstallSheet by remember { mutableStateOf(false) }
val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url) val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme) val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
@ -188,100 +175,6 @@ fun PluginScreen(
) )
} }
if (showInstallSheet) {
AdaptiveDialogBottomSheet(
onDismiss = { showInstallSheet = false },
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
stringResource(Res.string.plugin_install_section_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
},
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(Res.string.plugin_section_url_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = urlInput,
onValueChange = { urlInput = it; urlError = null },
modifier = Modifier.weight(1f),
placeholder = {
Text(
stringResource(Res.string.plugin_url_placeholder),
style = MaterialTheme.typography.bodySmall
)
},
leadingIcon = {
Icon(
Iconsax.IconsaxLink,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
},
isError = urlError != null,
singleLine = true,
)
SecondaryIconButton(
onClick = { submitUrl() },
enabled = !isLoadingUrl,
) {
if (isLoadingUrl) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = stringResource(Res.string.plugin_action_download),
)
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Text(
stringResource(Res.string.plugin_section_file_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlineButton(
onClick = { launcher.launch() },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Iconsax.IconsaxExportArrowBulk,
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.plugin_action_install_from_file))
}
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
@ -312,28 +205,16 @@ fun PluginScreen(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) )
{ {
// ── Configure header ────────────────────────────── // ── Install section ───────────────────────────────────
item { item {
Row( InstallSection(
modifier = Modifier urlInput = urlInput,
.fillMaxWidth() onUrlChange = { urlInput = it; urlError = null },
.padding(horizontal = 4.dp, vertical = 4.dp), urlError = urlError,
horizontalArrangement = Arrangement.SpaceBetween, isLoadingUrl = isLoadingUrl,
verticalAlignment = Alignment.CenterVertically onSubmitUrl = { submitUrl() },
) { onPickFile = { launcher.launch() }
Text(
stringResource(Res.string.plugin_configure_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
) )
PrimaryButton(onClick = { showInstallSheet = true }) {
Icon(
Iconsax.IconsaxAdd,
contentDescription = "Install a plugin",
)
Text(stringResource(Res.string.plugin_install_section_title))
}
}
} }
// ── Default ability plugin selectors ───────────────── // ── Default ability plugin selectors ─────────────────

View File

@ -0,0 +1,151 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_download
import spotube.composeapp.generated.resources.plugin_action_install_from_file
import spotube.composeapp.generated.resources.plugin_install_section_title
import spotube.composeapp.generated.resources.plugin_url_placeholder
@Composable
internal fun InstallSection(
urlInput: String,
onUrlChange: (String) -> Unit,
urlError: String?,
isLoadingUrl: Boolean,
onSubmitUrl: () -> Unit,
onPickFile: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
stringResource(Res.string.plugin_install_section_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
// URL row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = urlInput,
onValueChange = onUrlChange,
modifier = Modifier.weight(1f),
placeholder = {
Text(
stringResource(Res.string.plugin_url_placeholder),
style = MaterialTheme.typography.bodySmall
)
},
leadingIcon = {
Icon(
Iconsax.IconsaxLink,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
},
isError = urlError != null,
// supportingText = urlError?.let { { Text(it) } },
// shape = RoundedCornerShape(10.dp),
singleLine = true,
)
SecondaryIconButton(
onClick = onSubmitUrl,
enabled = !isLoadingUrl,
) {
if (isLoadingUrl) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = stringResource(Res.string.plugin_action_download),
)
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// File picker
OutlineButton(
onClick = onPickFile,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Iconsax.IconsaxExportArrowBulk,
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.plugin_action_install_from_file))
}
}
}
}

View File

@ -17,13 +17,18 @@
package dev.krtirtho.spotube.modules.saved_tracks package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -34,13 +39,19 @@ 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository 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 kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -69,6 +80,14 @@ fun SavedTracksScreen() {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id currentUserId = libraryRepository.currentUser()?.id
} }
@ -103,16 +122,81 @@ fun SavedTracksScreen() {
} }
} }
val dataState = state as? SavedTracksScreenState.Data Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is SavedTracksScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true) {
CollectionDetails(
title = "Loading saved tracks...",
description = "",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
ownerName = "You",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
showFollowButton = false,
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is SavedTracksScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as SavedTracksScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is SavedTracksScreenState.Data -> {
val dataState = state as SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks && val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
val errorMessage = (state as? SavedTracksScreenState.Error)?.message TrackList(
val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) modifier = Modifier.padding(innerPadding),
state = listState,
CollectionView( collapsedHeader = {
CollapsedCollectionHeader(
title = "Saved Tracks", title = "Saved Tracks",
description = dataState?.let { "${it.totalCount} tracks" } ?: "", imageURL = "",
imageResource = Res.drawable.liked_tracks,
isPlaying = isPlaying,
onPlay = viewModel::playSavedTracks,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = "Saved Tracks",
description = "${dataState.totalCount} tracks",
imageURL = "", imageURL = "",
imageResource = Res.drawable.liked_tracks, imageResource = Res.drawable.liked_tracks,
ownerName = "You", ownerName = "You",
@ -125,11 +209,12 @@ fun SavedTracksScreen() {
isFollowing = false, isFollowing = false,
onFollowClick = {}, onFollowClick = {},
showFollowButton = false, showFollowButton = false,
isLoading = isLoading, )
error = errorMessage, },
onRetry = { viewModel.refresh() }, tracks = dataState.tracks,
tracks = dataState?.tracks ?: emptyList(), error = null,
hasMore = dataState?.nextPagination != null, hasMore = dataState.nextPagination != null,
isLoading = state is SavedTracksScreenState.Loading && dataState.tracks.isEmpty(),
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore, isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -152,7 +237,10 @@ fun SavedTracksScreen() {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
trailingContent = { )
}
}
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
@ -166,6 +254,5 @@ fun SavedTracksScreen() {
} }
}, },
) )
}, }
)
} }

View File

@ -24,15 +24,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
interface SettingsProvider {
val settingsState: StateFlow<UserSettings?>
}
class SettingsViewModel( class SettingsViewModel(
private val repository: SettingsRepository private val repository: SettingsRepository
) : ViewModel(), SettingsProvider { ) : ViewModel() {
override val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn( val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(5000), SharingStarted.WhileSubscribed(5000),
null, null,

View File

@ -78,7 +78,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
@ -150,7 +149,7 @@ fun AppExpandedPlayer(
onDownloadTrack: () -> Unit = {}, onDownloadTrack: () -> Unit = {},
onGoToAlbum: () -> Unit = {}, onGoToAlbum: () -> Unit = {},
onSleepTimer: () -> Unit = {}, onSleepTimer: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(), audioPlayer: AudioPlayer = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>( savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID, key = SAVED_TRACKS_COLLECTION_ID,
@ -594,7 +593,7 @@ private fun rememberSharedAlbumArtModifier(
@Composable @Composable
private fun LyricsPreviewCard( private fun LyricsPreviewCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayer,
onExpand: () -> Unit, onExpand: () -> Unit,
viewModel: LyricsViewModel = koinViewModel() viewModel: LyricsViewModel = koinViewModel()
) { ) {

View File

@ -68,7 +68,6 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.ui.base.BaseUITheme import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
@ -87,7 +86,7 @@ fun AppFloatingPlayer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
audioPlayer: AudioPlayerInterface = koinInject(), audioPlayer: AudioPlayer = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>( savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID, key = SAVED_TRACKS_COLLECTION_ID,

View File

@ -18,23 +18,18 @@
package dev.krtirtho.spotube.modules.shell package dev.krtirtho.spotube.modules.shell
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -50,18 +45,12 @@ 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 dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
@ -111,12 +100,11 @@ import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun AppLargePlayer( fun AppLargePlayer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeState: HazeState,
onQueue: () -> Unit = {}, onQueue: () -> Unit = {},
onAlternativeSource: () -> Unit = {}, onAlternativeSource: () -> Unit = {},
onMoreOptions: () -> Unit = {}, onMoreOptions: () -> Unit = {},
onLyrics: () -> Unit = {}, onLyrics: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(), audioPlayer: AudioPlayer = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
downloadsViewModel: DownloadsViewModel = koinViewModel(), downloadsViewModel: DownloadsViewModel = koinViewModel(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>( savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
@ -183,30 +171,11 @@ fun AppLargePlayer(
} }
} }
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
Surface( Surface(
modifier = modifier modifier = modifier.fillMaxWidth(),
.windowInsetsPadding(WindowInsets.navigationBars), color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.78f),
color = Color.Transparent, tonalElevation = 0.dp,
shadowElevation = 0.dp, shadowElevation = 0.dp
) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = Color.Gray,
thickness = 1.dp,
)
Box(
modifier = Modifier
.fillMaxWidth()
.hazeEffect(state = hazeState) {
blurEffect {
blurRadius = 20.dp
colorEffects = listOf(
HazeColorEffect.tint(surfaceTint)
)
}
}
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -411,6 +380,5 @@ fun AppLargePlayer(
} }
} }
} }
}
} }

View File

@ -21,7 +21,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
@ -73,7 +72,7 @@ internal data class PlayerUiState(
@Composable @Composable
internal fun rememberPlayerUiState( internal fun rememberPlayerUiState(
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayer,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
): PlayerUiState { ): PlayerUiState {
val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList())

View File

@ -19,7 +19,6 @@ package dev.krtirtho.spotube.modules.shell
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -28,7 +27,6 @@ import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
@ -38,11 +36,7 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -58,11 +52,8 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
@ -122,32 +113,14 @@ fun AppShell(
CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) { CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
if (useSidebar) { if (useSidebar) {
val hazeState = rememberHazeState() Column(modifier = Modifier.fillMaxSize()) {
val bgHazeState = rememberHazeState() Box(modifier = Modifier.fillMaxSize().weight(1f)) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.hazeSource(state = bgHazeState) // <-- Put it here!
)
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
AppSidebar( AppSidebar(
hazeState = bgHazeState, navigator = navigator, navigationState = navigationState
navigator = navigator,
navigationState = navigationState
) )
VerticalDivider(
modifier = Modifier.fillMaxHeight(), Box(modifier = Modifier.weight(1f)) {
color = Color.Gray.copy(alpha = 0.2f),
thickness = 1.dp,
)
Box(modifier = Modifier.weight(1f).hazeSource(hazeState)) {
content() content()
} }
} }
@ -195,10 +168,7 @@ fun AppShell(
} }
} }
AppLargePlayer( AppLargePlayer(
hazeState = hazeState, modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
onQueue = queueViewModel::toggleQueueVisibility, onQueue = queueViewModel::toggleQueueVisibility,
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }, onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },

View File

@ -21,6 +21,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -34,9 +35,7 @@ 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
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -48,13 +47,10 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
@ -63,7 +59,6 @@ import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.copyPadding import dev.krtirtho.spotube.core.ui.base.copyPadding
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.LibraryTab
@ -78,7 +73,6 @@ import spotube.composeapp.generated.resources.cookie_regular
@Composable @Composable
fun AppSidebar( fun AppSidebar(
hazeState: HazeState,
navigator: Navigator, navigator: Navigator,
navigationState: NavigationState, navigationState: NavigationState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -87,20 +81,12 @@ fun AppSidebar(
var expanded by rememberSaveable { mutableStateOf(true) } var expanded by rememberSaveable { mutableStateOf(true) }
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
val currentLibraryTab by libraryState.currentTab.collectAsState() val currentLibraryTab by libraryState.currentTab.collectAsState()
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
Column( Column(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(width) .width(width)
.hazeEffect(hazeState) { .background(MaterialTheme.colorScheme.surfaceContainer),
blurEffect {
blurRadius = 20.dp
colorEffects = listOf(
HazeColorEffect.tint(surfaceTint)
)
}
},
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Row( Row(
@ -188,12 +174,12 @@ fun SidebarItem(
showDownloadBadge: Boolean = false, showDownloadBadge: Boolean = false,
) { ) {
val itemContent: @Composable RowScope.() -> Unit = { val itemContent: @Composable RowScope.() -> Unit = {
Box(modifier = Modifier.size(20.dp)) { Box(modifier = Modifier.size(24.dp)) {
Icon( Icon(
imageVector = activeIcon, imageVector = activeIcon,
contentDescription = label, contentDescription = label,
tint = if (selected) { tint = if (selected) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.onSecondaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
@ -209,15 +195,11 @@ fun SidebarItem(
text = label, text = label,
maxLines = 1, maxLines = 1,
softWrap = false, softWrap = false,
style = LocalTextStyle.current.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) { color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer MaterialTheme.colorScheme.onSecondaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
} },
)
) )
} }
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
@ -226,24 +208,20 @@ fun SidebarItem(
val buttonModifier = Modifier val buttonModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp) .padding(horizontal = 10.dp, vertical = 4.dp)
val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp) val contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp)
if (selected) { if (selected) {
SecondaryButton( SecondaryButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.secondary theme = LocalBaseUITheme.current.buttons.secondary.copyPadding(contentPadding),
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
content = itemContent, content = itemContent,
) )
} else { } else {
OutlineButton( OutlineButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.outline theme = LocalBaseUITheme.current.buttons.outline.copyPadding(contentPadding),
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
hoverOnly = true, hoverOnly = true,
content = itemContent, content = itemContent,
) )

View File

@ -1,51 +0,0 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentDismiss: ImageVector
get() {
if (_FluentDismiss != null) {
return _FluentDismiss!!
}
_FluentDismiss = ImageVector.Builder(
name = "FluentDismiss",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveToRelative(4.397f, 4.554f)
lineToRelative(0.073f, -0.084f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.976f, -0.073f)
lineToRelative(0.084f, 0.073f)
lineTo(12f, 10.939f)
lineToRelative(6.47f, -6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = true, isPositiveArc = true, 1.06f, 1.061f)
lineTo(13.061f, 12f)
lineToRelative(6.47f, 6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.072f, 0.976f)
lineToRelative(-0.073f, 0.084f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.976f, 0.073f)
lineToRelative(-0.084f, -0.073f)
lineTo(12f, 13.061f)
lineToRelative(-6.47f, 6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.06f, -1.061f)
lineTo(10.939f, 12f)
lineToRelative(-6.47f, -6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.072f, -0.976f)
lineToRelative(0.073f, -0.084f)
lineToRelative(-0.073f, 0.084f)
close()
}
}.build()
return _FluentDismiss!!
}
@Suppress("ObjectPropertyName")
private var _FluentDismiss: ImageVector? = null

View File

@ -1,49 +0,0 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentMaximize: ImageVector
get() {
if (_FluentMaximize != null) {
return _FluentMaximize!!
}
_FluentMaximize = ImageVector.Builder(
name = "FluentMaximize",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveTo(5.75f, 3f)
horizontalLineToRelative(12.5f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 21f, 5.75f)
verticalLineToRelative(12.5f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.25f, 21f)
lineTo(5.75f, 21f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3f, 18.25f)
lineTo(3f, 5.75f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 5.75f, 3f)
close()
moveTo(5.75f, 4.5f)
curveToRelative(-0.69f, 0f, -1.25f, 0.56f, -1.25f, 1.25f)
verticalLineToRelative(12.5f)
curveToRelative(0f, 0.69f, 0.56f, 1.25f, 1.25f, 1.25f)
horizontalLineToRelative(12.5f)
curveToRelative(0.69f, 0f, 1.25f, -0.56f, 1.25f, -1.25f)
lineTo(19.5f, 5.75f)
curveToRelative(0f, -0.69f, -0.56f, -1.25f, -1.25f, -1.25f)
lineTo(5.75f, 4.5f)
close()
}
}.build()
return _FluentMaximize!!
}
@Suppress("ObjectPropertyName")
private var _FluentMaximize: ImageVector? = null

View File

@ -1,35 +0,0 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentMinus: ImageVector
get() {
if (_FluentMinus != null) {
return _FluentMinus!!
}
_FluentMinus = ImageVector.Builder(
name = "FluentMinus",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveTo(3.755f, 12.5f)
horizontalLineToRelative(16.492f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, -1.5f)
horizontalLineTo(3.755f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 1.5f)
close()
}
}.build()
return _FluentMinus!!
}
@Suppress("ObjectPropertyName")
private var _FluentMinus: ImageVector? = null

View File

@ -1,63 +0,0 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentSquareMultiple: ImageVector
get() {
if (_FluentSquareMultiple != null) {
return _FluentSquareMultiple!!
}
_FluentSquareMultiple = ImageVector.Builder(
name = "FluentSquareMultiple",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveTo(7.518f, 5f)
horizontalLineTo(6.009f)
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3.24f, -3f)
horizontalLineToRelative(8.001f)
arcTo(4.75f, 4.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 22f, 6.75f)
verticalLineToRelative(8f)
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3f, 3.24f)
verticalLineToRelative(-1.508f)
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.5f, -1.732f)
verticalLineToRelative(-8f)
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, -3.25f, -3.25f)
horizontalLineToRelative(-8f)
arcTo(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 7.518f, 5f)
close()
moveTo(5.25f, 6f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, 9.25f)
verticalLineToRelative(9.5f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.25f, 22f)
horizontalLineToRelative(9.5f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 18f, 18.75f)
verticalLineToRelative(-9.5f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.75f, 6f)
horizontalLineToRelative(-9.5f)
close()
moveTo(3.5f, 9.25f)
curveToRelative(0f, -0.966f, 0.784f, -1.75f, 1.75f, -1.75f)
horizontalLineToRelative(9.5f)
curveToRelative(0.967f, 0f, 1.75f, 0.784f, 1.75f, 1.75f)
verticalLineToRelative(9.5f)
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, 1.75f)
horizontalLineToRelative(-9.5f)
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, -1.75f)
verticalLineToRelative(-9.5f)
close()
}
}.build()
return _FluentSquareMultiple!!
}
@Suppress("ObjectPropertyName")
private var _FluentSquareMultiple: ImageVector? = null

View File

@ -1,61 +0,0 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxAdd: ImageVector
get() {
if (_IncosaxAdd != null) {
return _IncosaxAdd!!
}
_IncosaxAdd = ImageVector.Builder(
name = "IncosaxAdd",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(6f, 12f)
horizontalLineTo(18f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(12f, 18f)
verticalLineTo(6f)
}
}
}.build()
return _IncosaxAdd!!
}
@Suppress("ObjectPropertyName")
private var _IncosaxAdd: ImageVector? = null

View File

@ -1,86 +0,0 @@
package dev.krtirtho.spotube.core.audioplayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AudioPlayerQueueTest {
private fun createQueue(
currentCollectionEntry: QueueCollectionEntry? = null
): AudioPlayerQueue {
val collectionEntryFlow = MutableStateFlow(currentCollectionEntry)
return object : AudioPlayerQueue {
override val queueFlow: StateFlow<List<QueueEntry>> = MutableStateFlow(emptyList())
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = MutableStateFlow(null)
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> = collectionEntryFlow
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> = MutableStateFlow(emptyList())
override suspend fun load(entries: List<QueueEntry>, autoPlay: Boolean, startPosition: Int, collectionEntry: QueueCollectionEntry?) = Unit
override suspend fun addToQueue(entry: QueueEntry) = Unit
override suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry?) = Unit
override suspend fun addAllAfterCurrent(entries: List<QueueEntry>) = Unit
override suspend fun removeFromQueue(entry: QueueEntry) = Unit
override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = Unit
override suspend fun move(fromIndex: Int, toIndex: Int) = Unit
override suspend fun jumpTo(index: Int, autoPlay: Boolean) = Unit
override suspend fun reloadCurrent() = Unit
override suspend fun clear() = Unit
override suspend fun getQueue(): List<QueueEntry> = emptyList()
override suspend fun getCurrentQueueEntry(): QueueEntry? = null
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
}
}
@Test
fun `isPlaylistPlaying returns true when current collection is matching playlist`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1"))
assertTrue(queue.isPlaylistPlaying("pl-1"))
}
@Test
fun `isPlaylistPlaying returns false when playlist id does not match`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1"))
assertFalse(queue.isPlaylistPlaying("pl-2"))
}
@Test
fun `isPlaylistPlaying returns false when current is not a playlist`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1"))
assertFalse(queue.isPlaylistPlaying())
}
@Test
fun `isAlbumPlaying returns true when current collection is matching album`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1"))
assertTrue(queue.isAlbumPlaying("al-1"))
}
@Test
fun `isAlbumPlaying returns false when album id does not match`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1"))
assertFalse(queue.isAlbumPlaying("al-2"))
}
@Test
fun `isAlbumPlaying returns false when current is not an album`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1"))
assertFalse(queue.isAlbumPlaying())
}
@Test
fun `isSavedTracksPlaying returns true when current is SavedTracks`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.SavedTracks)
assertTrue(queue.isSavedTracksPlaying())
}
@Test
fun `isSavedTracksPlaying returns false when current is not SavedTracks`() = runTest {
val queue = createQueue(currentCollectionEntry = QueueCollectionEntry.Album("al-1"))
assertFalse(queue.isSavedTracksPlaying())
}
}

View File

@ -1,498 +0,0 @@
package dev.krtirtho.spotube.core.audioplayer
import co.touchlab.kermit.Logger
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.milliseconds
class DeviceAudioPlayerQueueTest {
private lateinit var fakePlayer: FakeAudioPlayer
private lateinit var fakeSettings: FakeSettingsProvider
private lateinit var fakeRepo: FakeAudioPlayerQueueRepository
private lateinit var fakePlugin: FakePluginProvider
@BeforeTest
fun setup() {
startKoin {
modules(module {
factory { (tag: String?) -> Logger.withTag(tag ?: "test") }
})
}
fakePlayer = FakeAudioPlayer()
fakeSettings = FakeSettingsProvider()
fakeRepo = FakeAudioPlayerQueueRepository()
fakePlugin = FakePluginProvider()
}
@AfterTest
fun teardown() {
stopKoin()
}
private fun createQueue(): DeviceAudioPlayerQueue {
return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin)
}
private fun streamingTrack(
id: String,
title: String = "Track $id",
artist: String = "Artist $id"
): MetadataTrack {
val artistBasic = MetadataArtist.Basic(
id = "artist-$id",
name = artist,
thumbnails = emptyList(),
externalUri = null
)
val album = MetadataAlbum.Detailed(
releaseDate = "2024",
genres = emptyList(),
trackCount = 1,
id = "album-$id",
title = "Album $title",
description = null,
thumbnails = listOf(Thumbnail(url = "https://cover/$id", width = 300, height = 300)),
albumType = MetadataAlbumType.Album,
artists = listOf(artistBasic),
externalUri = null
)
return MetadataTrack(
id = id,
title = title,
durationMs = 200_000L,
trackNumber = 1,
discNumber = 1,
artists = listOf(artistBasic),
album = album,
thumbnails = album.thumbnails,
explicit = false,
popularity = 50,
isrcCode = null,
externalUri = null
)
}
private fun streamingEntry(
trackId: String,
url: String = "http://127.0.0.1:14769/stream/$trackId"
): QueueEntry.StreamingTrack {
return QueueEntry.StreamingTrack(
track = streamingTrack(trackId),
url = url,
protocol = StreamProtocol.PROGRESSIVE
)
}
private fun localEntry(
name: String = "Local Track",
url: String = "file:///music/$name.mp3"
): QueueEntry.LocalTrack {
return QueueEntry.LocalTrack(
name = name,
artists = listOf("Local Artist"),
duration = 180_000L,
album = "Local Album",
coverBytes = null,
url = url
)
}
// --- load ---
@Test
fun `load populates player playlist with media items`() = runTest {
val queue = createQueue()
val entries = listOf(streamingEntry("t1"), streamingEntry("t2"))
queue.load(entries)
assertEquals(1, fakePlayer.loadCallCount)
assertEquals(2, fakePlayer.lastLoadPlaylist?.size)
assertEquals("Track t1", fakePlayer.lastLoadPlaylist?.get(0)?.title)
assertEquals("Track t2", fakePlayer.lastLoadPlaylist?.get(1)?.title)
}
@Test
fun `load respects autoPlay parameter`() = runTest {
val queue = createQueue()
val entries = listOf(streamingEntry("t1"))
queue.load(entries, autoPlay = false)
assertEquals(false, fakePlayer.lastLoadAutoPlay)
}
@Test
fun `load respects startPosition`() = runTest {
val queue = createQueue()
val entries = listOf(streamingEntry("t1"), streamingEntry("t2"))
queue.load(entries, startPosition = 1)
assertEquals(1, fakePlayer.lastLoadStartPosition)
}
@Test
fun `load sets collection context`() = runTest {
val queue = createQueue()
val colEntry = QueueCollectionEntry.Playlist("pl-1")
queue.load(
entries = listOf(streamingEntry("t1")),
collectionEntry = colEntry
)
assertEquals(colEntry, queue.getCurrentCollectionEntry())
}
@Test
fun `load with empty entries does not crash`() = runTest {
val queue = createQueue()
queue.load(emptyList())
assertEquals(1, fakePlayer.loadCallCount)
assertTrue { fakePlayer.lastLoadPlaylist?.isEmpty() == true }
}
// --- addToQueue ---
@Test
fun `addToQueue adds entry and calls player`() = runTest {
val queue = createQueue()
val entry = streamingEntry("t1")
queue.addToQueue(entry)
assertEquals(1, fakePlayer.addMediaItemCallCount)
assertNotNull(fakePlayer.lastAddedMediaItem)
assertEquals("Track t1", fakePlayer.lastAddedMediaItem?.title)
}
@Test
fun `addToQueue skips local entry with blank url`() = runTest {
val queue = createQueue()
val entry = localEntry(url = "")
queue.addToQueue(entry)
assertEquals(0, fakePlayer.addMediaItemCallCount)
}
// --- addAllToQueue ---
@Test
fun `addAllToQueue adds multiple entries`() = runTest {
val queue = createQueue()
val entries = listOf(streamingEntry("t1"), streamingEntry("t2"))
queue.addAllToQueue(entries)
assertEquals(2, fakePlayer.addMediaItemCallCount)
}
@Test
fun `addAllToQueue sets collection context`() = runTest {
val queue = createQueue()
val colEntry = QueueCollectionEntry.Album("al-1")
queue.addAllToQueue(
entries = listOf(streamingEntry("t1")),
collectionEntry = colEntry
)
assertEquals(colEntry, queue.getCurrentCollectionEntry())
}
// --- addAllAfterCurrent ---
@Test
fun `addAllAfterCurrent inserts after current track`() = runTest {
val queue = createQueue()
val entries = listOf(streamingEntry("t1"))
queue.addAllAfterCurrent(entries)
assertEquals(1, fakePlayer.insertAtNextCallCount)
}
// --- removeFromQueue ---
@Test
fun `removeFromQueue removes entry by media item url`() = runTest {
val queue = createQueue()
val entry = streamingEntry("t1")
queue.addToQueue(entry)
fakePlayer.resetCallCounts()
queue.removeFromQueue(entry)
assertEquals(1, fakePlayer.removeMediaItemCallCount)
}
@Test
fun `removeFromQueueByMediaUrl calls player remove`() = runTest {
val queue = createQueue()
val entry = streamingEntry("t1", url = "http://example.com/t1")
queue.addToQueue(entry)
fakePlayer.resetCallCounts()
queue.removeFromQueueByMediaUrl("http://example.com/t1")
assertEquals(1, fakePlayer.removeMediaItemCallCount)
}
// --- move ---
@Test
fun `move delegates to player`() = runTest {
val queue = createQueue()
queue.move(0, 2)
assertEquals(1, fakePlayer.moveCallCount)
assertEquals(0, fakePlayer.lastMoveFromIndex)
assertEquals(2, fakePlayer.lastMoveToIndex)
}
// --- jumpTo ---
@Test
fun `jumpTo with autoPlay calls player jumpTo and play`() = runTest {
val queue = createQueue()
fakePlayer.setPlaylist(listOf(
MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE),
MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE),
))
queue.jumpTo(1, autoPlay = true)
assertEquals(1, fakePlayer.jumpToCallCount)
assertEquals(1, fakePlayer.lastJumpToIndex)
assertEquals(1, fakePlayer.playCallCount)
}
@Test
fun `jumpTo without autoPlay does not call play`() = runTest {
val queue = createQueue()
fakePlayer.setPlaylist(listOf(
MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE),
))
queue.jumpTo(0, autoPlay = false)
assertEquals(1, fakePlayer.jumpToCallCount)
assertEquals(0, fakePlayer.playCallCount)
}
@Test
fun `jumpTo clamps out of range index`() = runTest {
val queue = createQueue()
fakePlayer.setPlaylist(listOf(
MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE),
))
queue.jumpTo(999)
assertEquals(1, fakePlayer.jumpToCallCount)
assertEquals(0, fakePlayer.lastJumpToIndex)
}
// --- reloadCurrent ---
@Test
fun `reloadCurrent does nothing when no current entry`() = runTest {
val queue = createQueue()
fakePlayer.setCurrentItem(null)
queue.reloadCurrent()
assertEquals(0, fakePlayer.jumpToCallCount)
}
// --- clear ---
@Test
fun `clear resets everything`() = runTest {
val queue = createQueue()
queue.load(listOf(streamingEntry("t1")), collectionEntry = QueueCollectionEntry.Playlist("pl-1"))
queue.clear()
val lastLoad = fakePlayer.lastLoadPlaylist
assertTrue { lastLoad?.isEmpty() == true }
assertEquals(false, fakePlayer.lastLoadAutoPlay)
assertNull(queue.getCurrentCollectionEntry())
assertTrue { queue.getCollectionHistory().isEmpty() }
}
// --- getQueue ---
@Test
fun `getQueue returns entries with metadata`() = runTest {
val queue = createQueue()
val entry = streamingEntry("t1", url = "url1")
queue.addToQueue(entry)
val result = queue.getQueue()
assertEquals(1, result.size)
val qEntry = result[0] as? QueueEntry.StreamingTrack
assertNotNull(qEntry)
assertEquals("t1", qEntry.track.id)
}
@Test
fun `getCurrentQueueEntry returns current media as queue entry`() = runTest {
val queue = createQueue()
fakePlayer.setCurrentItem(MediaItem("Test", "Artist", "Album", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))
val result = queue.getCurrentQueueEntry()
assertNotNull(result)
val localEntry = result as? QueueEntry.LocalTrack
assertNotNull(localEntry)
assertEquals("Test", localEntry.name)
}
// --- collection history ---
@Test
fun `collection history tracks plays in order`() = runTest {
val queue = createQueue()
queue.addAllToQueue(
listOf(streamingEntry("t1")),
collectionEntry = QueueCollectionEntry.Playlist("pl-1")
)
queue.addAllToQueue(
listOf(streamingEntry("t2")),
collectionEntry = QueueCollectionEntry.Album("al-1")
)
val history = queue.getCollectionHistory()
assertEquals(2, history.size)
assertEquals(QueueCollectionEntry.Album("al-1"), history[0])
assertEquals(QueueCollectionEntry.Playlist("pl-1"), history[1])
}
@Test
fun `collection history deduplicates`() = runTest {
val queue = createQueue()
queue.addAllToQueue(
listOf(streamingEntry("t1")),
collectionEntry = QueueCollectionEntry.Playlist("pl-1")
)
queue.addAllToQueue(
listOf(streamingEntry("t2")),
collectionEntry = QueueCollectionEntry.Playlist("pl-1")
)
val history = queue.getCollectionHistory()
assertEquals(1, history.size)
}
// --- persistence ---
@Test
fun `restorePersistedState loads from repository on init`() = runTest {
val entries = listOf(streamingEntry("t1"))
fakeRepo.setState(
PersistedQueueState(
entries = entries,
currentIndex = 0,
currentCollectionEntry = QueueCollectionEntry.Playlist("pl-1"),
collectionHistory = listOf(QueueCollectionEntry.Playlist("pl-1"))
)
)
val queue = createQueue()
withContext(Dispatchers.Default) { } // yield to let init coroutine run
assertEquals(1, fakePlayer.loadCallCount)
assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry())
assertEquals(1, queue.getCollectionHistory().size)
}
@Test
fun `restorePersistedState skips when no persisted state`() = runTest {
fakeRepo.setState(null)
val queue = createQueue()
withContext(Dispatchers.Default) { }
assertEquals(0, fakePlayer.loadCallCount)
}
// --- toMediaItem / toFallbackQueueEntry ---
@Test
fun `streaming track converts to media item`() = runTest {
val queue = createQueue()
fakeSettings.setSettings(
dev.krtirtho.spotube.modules.settings.UserSettings(playbackProxyServerPort = 14769)
)
val entry = streamingEntry("t1")
queue.addToQueue(entry)
val mediaItem = fakePlayer.lastAddedMediaItem
assertNotNull(mediaItem)
assertEquals("Track t1", mediaItem.title)
assertEquals("Artist t1", mediaItem.artist)
assertTrue { mediaItem.url.contains("/stream/t1") }
}
@Test
fun `local track converts to media item`() = runTest {
val queue = createQueue()
val entry = localEntry()
queue.addToQueue(entry)
val mediaItem = fakePlayer.lastAddedMediaItem
assertNotNull(mediaItem)
assertEquals("Local Track", mediaItem.title)
assertEquals("file:///music/Local Track.mp3", mediaItem.url)
}
@Test
fun `fallback queue entry preserves media item fields`() = runTest {
val queue = createQueue()
val mediaItem = MediaItem(
title = "Fallback",
artist = "Artist1, Artist2",
album = "Album",
duration = 300.milliseconds,
coverURL = "",
url = "file:///test.mp3",
protocol = StreamProtocol.PROGRESSIVE
)
fakePlayer.setCurrentItem(mediaItem)
val result = queue.getCurrentQueueEntry()
assertNotNull(result)
val local = result as? QueueEntry.LocalTrack
assertNotNull(local)
assertEquals("Fallback", local.name)
assertEquals(listOf("Artist1", "Artist2"), local.artists)
}
}

View File

@ -1,179 +0,0 @@
package dev.krtirtho.spotube.core.audioplayer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.time.Duration
class FakeAudioPlayer : AudioPlayerInterface {
private val _playlistFlow = MutableStateFlow<List<MediaItem>>(emptyList())
override val playlistFlow: StateFlow<List<MediaItem>> = _playlistFlow.asStateFlow()
override val durationFlow: StateFlow<Duration>
get() = TODO("Not yet implemented")
override val positionFlow: StateFlow<Duration>
get() = TODO("Not yet implemented")
override val bufferingPositionFlow: StateFlow<Duration>
get() = TODO("Not yet implemented")
private val _currentMediaItemFlow = MutableStateFlow<MediaItem?>(null)
override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItemFlow.asStateFlow()
private val _loopStateFlow = MutableStateFlow(LoopState.NONE)
override val loopStateFlow: StateFlow<LoopState> = _loopStateFlow.asStateFlow()
override val shuffleModeFlow: StateFlow<Boolean>
get() = TODO("Not yet implemented")
override val playbackSpeedFlow: StateFlow<Float>
get() = TODO("Not yet implemented")
override val volumeFlow: StateFlow<Float>
get() = TODO("Not yet implemented")
override val completionFlow: Flow<Unit>
get() = TODO("Not yet implemented")
override val errorFlow: Flow<Throwable>
get() = TODO("Not yet implemented")
override suspend fun setVolume(volume: Float) {
TODO("Not yet implemented")
}
override suspend fun setPlaybackSpeed(speed: Float) {
TODO("Not yet implemented")
}
override fun isDisposed(): Boolean {
TODO("Not yet implemented")
}
override fun dispose() {
TODO("Not yet implemented")
}
var loadCallCount = 0
var lastLoadPlaylist: List<MediaItem>? = null
var lastLoadAutoPlay: Boolean = true
var lastLoadStartPosition: Int = 0
var playCallCount = 0
var addMediaItemCallCount = 0
var lastAddedMediaItem: MediaItem? = null
var insertAtNextCallCount = 0
var lastInsertedMediaItem: MediaItem? = null
var removeMediaItemCallCount = 0
var lastRemovedMediaItem: MediaItem? = null
var moveCallCount = 0
var lastMoveFromIndex: Int = 0
var lastMoveToIndex: Int = 0
var jumpToCallCount = 0
var lastJumpToIndex: Int = 0
override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
loadCallCount++
lastLoadPlaylist = playlist
lastLoadAutoPlay = autoPlay
lastLoadStartPosition = startPosition
_playlistFlow.value = playlist.toList()
_currentMediaItemFlow.value = playlist.getOrNull(startPosition)
}
override suspend fun play() {
playCallCount++
}
override suspend fun pause() {
TODO("Not yet implemented")
}
override suspend fun stop() {
TODO("Not yet implemented")
}
override suspend fun seekTo(position: Duration) {
TODO("Not yet implemented")
}
override suspend fun loop(state: LoopState) {
TODO("Not yet implemented")
}
override suspend fun shuffle(enabled: Boolean) {
TODO("Not yet implemented")
}
override suspend fun addMediaItem(mediaItem: MediaItem) {
addMediaItemCallCount++
lastAddedMediaItem = mediaItem
val updated = _playlistFlow.value.toMutableList()
updated.add(mediaItem)
_playlistFlow.value = updated
}
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
insertAtNextCallCount++
lastInsertedMediaItem = mediaItem
val updated = _playlistFlow.value.toMutableList()
updated.add(mediaItem)
_playlistFlow.value = updated
}
override suspend fun removeMediaItem(mediaItem: MediaItem) {
removeMediaItemCallCount++
lastRemovedMediaItem = mediaItem
_playlistFlow.value = _playlistFlow.value.filter { it.url != mediaItem.url }
}
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
moveCallCount++
lastMoveFromIndex = fromIndex
lastMoveToIndex = toIndex
val updated = _playlistFlow.value.toMutableList()
if (fromIndex in updated.indices && toIndex in updated.indices) {
val item = updated.removeAt(fromIndex)
updated.add(toIndex, item)
_playlistFlow.value = updated
}
}
override suspend fun skipToNext() {
TODO("Not yet implemented")
}
override suspend fun skipToPrevious() {
TODO("Not yet implemented")
}
override suspend fun jumpTo(index: Int) {
jumpToCallCount++
lastJumpToIndex = index
_currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index)
}
override val playerStateFlow: StateFlow<PlayerState>
get() = TODO("Not yet implemented")
fun setPlaylist(items: List<MediaItem>) {
_playlistFlow.value = items
}
fun setCurrentItem(item: MediaItem?) {
_currentMediaItemFlow.value = item
}
fun setLoopState(state: LoopState) {
_loopStateFlow.value = state
}
fun resetCallCounts() {
loadCallCount = 0
playCallCount = 0
addMediaItemCallCount = 0
insertAtNextCallCount = 0
removeMediaItemCallCount = 0
moveCallCount = 0
jumpToCallCount = 0
}
}

View File

@ -1,19 +0,0 @@
package dev.krtirtho.spotube.core.audioplayer
class FakeAudioPlayerQueueRepository : QueueStateRepository {
private var persistedState: PersistedQueueState? = null
override suspend fun getPersistedState(): PersistedQueueState? = persistedState
override suspend fun saveState(state: PersistedQueueState) {
persistedState = state
}
override suspend fun clearState() {
persistedState = null
}
fun setState(state: PersistedQueueState?) {
persistedState = state
}
}

View File

@ -1,60 +0,0 @@
package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
import dev.krtirtho.spotube.core.zipline.PluginService
import dev.krtirtho.spotube.core.zipline.PluginServiceScope
import dev.krtirtho.spotube.modules.plugin.PluginProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeMetadataTrackAPI(
private val recommendations: List<MetadataTrack> = emptyList()
) : MetadataTrackAPI {
override suspend fun getTrack(id: String): MetadataTrack =
error("not mocked")
override suspend fun savedTracks(pagination: PaginationStrategy?): PaginationResult<MetadataTrack> =
error("not mocked")
override suspend fun isSavedTracks(ids: List<String>): List<Boolean> =
error("not mocked")
override suspend fun saveTracks(ids: List<String>) = error("not mocked")
override suspend fun removeSavedTracks(ids: List<String>) = error("not mocked")
override suspend fun recommendationsBasedOnTracks(
seedTrackIds: List<String>,
limit: Int
): List<MetadataTrack> = recommendations
}
class FakePluginService(
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI()
) : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
override suspend fun start() = Unit
override suspend fun stop() = Unit
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
val scope = PluginServiceScope(
mapOf(MetadataTrackAPI::class to metadataTrackAPI)
)
return scope.block()
}
}
class FakePluginProvider(
private val pluginService: PluginService? = null
) : PluginProvider {
private val _selectedMetadataPlugin = MutableStateFlow(pluginService)
override val selectedMetadataPlugin: StateFlow<PluginService?> = _selectedMetadataPlugin
fun setPlugin(service: PluginService?) {
_selectedMetadataPlugin.value = service
}
}

View File

@ -1,22 +0,0 @@
package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.UserSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeSettingsProvider(
initialSettings: UserSettings? = UserSettings()
) : SettingsProvider {
private val _settingsState = MutableStateFlow(initialSettings)
override val settingsState: StateFlow<UserSettings?> = _settingsState.asStateFlow()
fun updateSettings(transform: UserSettings.() -> UserSettings) {
_settingsState.value = _settingsState.value?.transform()
}
fun setSettings(settings: UserSettings?) {
_settingsState.value = settings
}
}

View File

@ -56,7 +56,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { actual class AudioPlayer actual constructor(context: Any) {
actual val context: Any = context actual val context: Any = context
@ -81,18 +81,18 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1) private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow() actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow() actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow() actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow() actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow() actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow() actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow() actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow() actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow() actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow() actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow() actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow() actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
@ -158,36 +158,36 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
return AVPlayerItem(nsUrl) return AVPlayerItem(nsUrl)
} }
actual override suspend fun play() { actual suspend fun play() {
avPlayer.play() avPlayer.play()
} }
actual override suspend fun pause() { actual suspend fun pause() {
avPlayer.pause() avPlayer.pause()
} }
actual override suspend fun stop() { actual suspend fun stop() {
avPlayer.pause() avPlayer.pause()
avPlayer.seekToTime(CMTimeMake(0, 1)) avPlayer.seekToTime(CMTimeMake(0, 1))
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
} }
actual override suspend fun seekTo(position: Duration) { actual suspend fun seekTo(position: Duration) {
val seconds = position.inWholeMilliseconds / 1000.0 val seconds = position.inWholeMilliseconds / 1000.0
val cmTime = CMTimeMakeWithSeconds(seconds, 1000) val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
avPlayer.seekToTime(cmTime) avPlayer.seekToTime(cmTime)
_position.tryEmit(position) _position.tryEmit(position)
} }
actual override suspend fun loop(state: LoopState) { actual suspend fun loop(state: LoopState) {
_loopState.tryEmit(state) _loopState.tryEmit(state)
} }
actual override suspend fun shuffle(enabled: Boolean) { actual suspend fun shuffle(enabled: Boolean) {
_shuffleMode.tryEmit(enabled) _shuffleMode.tryEmit(enabled)
} }
actual override suspend fun load( actual suspend fun load(
playlist: List<MediaItem>, playlist: List<MediaItem>,
autoPlay: Boolean, autoPlay: Boolean,
startPosition: Int startPosition: Int
@ -226,13 +226,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun addMediaItem(mediaItem: MediaItem) { actual suspend fun addMediaItem(mediaItem: MediaItem) {
currentPlaylist.add(mediaItem) currentPlaylist.add(mediaItem)
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
} }
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
val currentIndex = currentPlaylist.indexOfFirst { val currentIndex = currentPlaylist.indexOfFirst {
it.url == _currentMediaItem.value?.url it.url == _currentMediaItem.value?.url
} }
@ -245,7 +245,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
} }
actual override suspend fun removeMediaItem(mediaItem: MediaItem) { actual suspend fun removeMediaItem(mediaItem: MediaItem) {
val index = urlIndexMap[mediaItem.url] ?: return val index = urlIndexMap[mediaItem.url] ?: return
currentPlaylist.removeAt(index) currentPlaylist.removeAt(index)
urlIndexMap.clear() urlIndexMap.clear()
@ -255,7 +255,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
} }
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { actual suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
val item = currentPlaylist.removeAt(fromIndex) val item = currentPlaylist.removeAt(fromIndex)
currentPlaylist.add(toIndex, item) currentPlaylist.add(toIndex, item)
@ -266,7 +266,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
} }
actual override suspend fun skipToNext() { actual suspend fun skipToNext() {
val currentIndex = currentPlaylist.indexOfFirst { val currentIndex = currentPlaylist.indexOfFirst {
it.url == _currentMediaItem.value?.url it.url == _currentMediaItem.value?.url
} }
@ -282,7 +282,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun skipToPrevious() { actual suspend fun skipToPrevious() {
val currentIndex = currentPlaylist.indexOfFirst { val currentIndex = currentPlaylist.indexOfFirst {
it.url == _currentMediaItem.value?.url it.url == _currentMediaItem.value?.url
} }
@ -298,7 +298,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun jumpTo(index: Int) { actual suspend fun jumpTo(index: Int) {
if (index in currentPlaylist.indices) { if (index in currentPlaylist.indices) {
val item = currentPlaylist[index] val item = currentPlaylist[index]
val avItem = buildAVPlayerItem(item.url) val avItem = buildAVPlayerItem(item.url)
@ -309,21 +309,21 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun setVolume(volume: Float) { actual suspend fun setVolume(volume: Float) {
val clamped = volume.coerceIn(0f, 1f) val clamped = volume.coerceIn(0f, 1f)
avPlayer.volume = clamped avPlayer.volume = clamped
_volume.tryEmit(clamped) _volume.tryEmit(clamped)
} }
actual override suspend fun setPlaybackSpeed(speed: Float) { actual suspend fun setPlaybackSpeed(speed: Float) {
val clamped = speed.coerceIn(0.25f, 4f) val clamped = speed.coerceIn(0.25f, 4f)
avPlayer.rate = clamped avPlayer.rate = clamped
_playbackSpeed.tryEmit(clamped) _playbackSpeed.tryEmit(clamped)
} }
actual override fun isDisposed(): Boolean = disposed actual fun isDisposed(): Boolean = disposed
actual override fun dispose() { actual fun dispose() {
disposed = true disposed = true
avPlayer.pause() avPlayer.pause()
avPlayer.replaceCurrentItemWithPlayerItem(null) avPlayer.replaceCurrentItemWithPlayerItem(null)

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.core.di package dev.krtirtho.spotube.core.di
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.share.IosShareService import dev.krtirtho.spotube.core.share.IosShareService
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
@ -31,7 +30,7 @@ import org.koin.dsl.module
actual val platformModules = module { actual val platformModules = module {
singleOf(::Paths) singleOf(::Paths)
singleOf(::WebViewController) singleOf(::WebViewController)
single<AudioPlayerInterface> { AudioPlayer(Unit) } single { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { IosLocalMediaDiscoveryService() } single<LocalMediaDiscoveryService> { IosLocalMediaDiscoveryService() }
single<ShareService> { IosShareService() } single<ShareService> { IosShareService() }
} }

View File

@ -48,7 +48,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface, KoinComponent { actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
actual val context: Any = context actual val context: Any = context
private val logger by injectLogger<AudioPlayer>() private val logger by injectLogger<AudioPlayer>()
@ -86,18 +86,18 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1) private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow() actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow() actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow() actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow() actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow() actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow() actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow() actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow() actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow() actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow() actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow() actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow() actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
init { init {
VLCBundleLoaderGenerated.getVerifiedPath() VLCBundleLoaderGenerated.getVerifiedPath()
@ -241,7 +241,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
startPositionPolling() startPositionPolling()
} }
actual override suspend fun play() { actual suspend fun play() {
lock.withLock { lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return if (disposed || currentPlaylist.isEmpty()) return
if (currentIndex !in currentPlaylist.indices) { if (currentIndex !in currentPlaylist.indices) {
@ -255,14 +255,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun pause() { actual suspend fun pause() {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
mediaListPlayer.controls().pause() mediaListPlayer.controls().pause()
} }
} }
actual override suspend fun stop() { actual suspend fun stop() {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
mediaListPlayer.controls().stop() mediaListPlayer.controls().stop()
@ -271,7 +271,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun seekTo(position: Duration) { actual suspend fun seekTo(position: Duration) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
val maxMs = _duration.value.inWholeMilliseconds val maxMs = _duration.value.inWholeMilliseconds
@ -289,7 +289,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun loop(state: LoopState) { actual suspend fun loop(state: LoopState) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
val vlcMode = when (state) { val vlcMode = when (state) {
@ -302,10 +302,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun shuffle(enabled: Boolean) { actual suspend fun shuffle(enabled: Boolean) {
lock.withLock { lock.withLock {
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return
val wasPlaying = _playerState.value == PlayerState.PLAYING
val wasPaused = _playerState.value == PlayerState.PAUSED
val currentItem = _currentMediaItem.value val currentItem = _currentMediaItem.value
val rebuilt = if (enabled) { val rebuilt = if (enabled) {
@ -329,16 +331,24 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
val nextIndex = when { val nextIndex = when {
currentItem == null -> if (currentPlaylist.isEmpty()) -1 else 0 currentItem == null -> if (currentPlaylist.isEmpty()) -1 else 0
enabled -> 0
else -> currentPlaylist.indexOfFirst { it.url == currentItem.url } else -> currentPlaylist.indexOfFirst { it.url == currentItem.url }
.takeIf { it >= 0 } ?: 0 .takeIf { it >= 0 } ?: 0
} }
currentIndex = nextIndex currentIndex = nextIndex
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(nextIndex)) _currentMediaItem.tryEmit(currentPlaylist.getOrNull(nextIndex))
if (nextIndex >= 0 && (wasPlaying || wasPaused)) {
mediaListPlayer.controls().play(nextIndex)
if (wasPaused) {
mediaListPlayer.controls().setPause(true)
}
}
} }
} }
actual override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) { actual suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
@ -375,7 +385,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun addMediaItem(mediaItem: MediaItem) { actual suspend fun addMediaItem(mediaItem: MediaItem) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
originalPlaylist.add(mediaItem) originalPlaylist.add(mediaItem)
@ -389,7 +399,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0 val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
@ -407,7 +417,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun removeMediaItem(mediaItem: MediaItem) { actual suspend fun removeMediaItem(mediaItem: MediaItem) {
lock.withLock { lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return if (disposed || currentPlaylist.isEmpty()) return
@ -437,7 +447,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { actual suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
@ -461,31 +471,27 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun skipToNext() { actual suspend fun skipToNext() {
lock.withLock { lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return if (disposed || currentPlaylist.isEmpty()) return
val nextIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex) val nextIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex)
logger.d { "skipToNext: currentIndex=$currentIndex, targetIndex=$nextIndex" } logger.d { "skipToNext: currentIndex=$currentIndex, targetIndex=$nextIndex" }
currentIndex = nextIndex
_currentMediaItem.value = currentPlaylist[nextIndex]
pendingNextIndex = nextIndex pendingNextIndex = nextIndex
mediaListPlayer.controls().play(nextIndex) mediaListPlayer.controls().playNext()
} }
} }
actual override suspend fun skipToPrevious() { actual suspend fun skipToPrevious() {
lock.withLock { lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return if (disposed || currentPlaylist.isEmpty()) return
val prevIndex = (currentIndex - 1).coerceAtLeast(0) val prevIndex = (currentIndex - 1).coerceAtLeast(0)
logger.d { "skipToPrevious: currentIndex=$currentIndex, targetIndex=$prevIndex" } logger.d { "skipToPrevious: currentIndex=$currentIndex, targetIndex=$prevIndex" }
currentIndex = prevIndex
_currentMediaItem.value = currentPlaylist[prevIndex]
pendingNextIndex = prevIndex pendingNextIndex = prevIndex
mediaListPlayer.controls().play(prevIndex) mediaListPlayer.controls().playPrevious()
} }
} }
actual override suspend fun jumpTo(index: Int) { actual suspend fun jumpTo(index: Int) {
lock.withLock { lock.withLock {
if (disposed || index !in currentPlaylist.indices) return if (disposed || index !in currentPlaylist.indices) return
currentIndex = index currentIndex = index
@ -494,7 +500,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun setVolume(volume: Float) { actual suspend fun setVolume(volume: Float) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
val clamped = volume.coerceIn(0f, 1f) val clamped = volume.coerceIn(0f, 1f)
@ -503,7 +509,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun setPlaybackSpeed(speed: Float) { actual suspend fun setPlaybackSpeed(speed: Float) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
val clamped = speed.coerceIn(0.25f, 4f) val clamped = speed.coerceIn(0.25f, 4f)
@ -516,9 +522,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override fun isDisposed(): Boolean = disposed actual fun isDisposed(): Boolean = disposed
actual override fun dispose() { actual fun dispose() {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return
disposed = true disposed = true
@ -548,13 +554,10 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
private fun rebuildVlcMediaListLocked() { private fun rebuildVlcMediaListLocked() {
val oldList = mediaList mediaList.media().clear()
mediaList = mediaPlayerFactory.media().newMediaList()
currentPlaylist.forEach { mediaItem -> currentPlaylist.forEach { mediaItem ->
mediaList.media().add(mediaItem.toMrl()) mediaList.media().add(mediaItem.toMrl())
} }
mediaListPlayer.list().setMediaList(mediaList.newMediaListRef())
oldList.release()
} }
private fun startPositionPolling() { private fun startPositionPolling() {
@ -576,9 +579,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
delay(250.milliseconds) delay(250.milliseconds)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
if (!disposed) {
logger.e(e) { "Error in position polling loop" } logger.e(e) { "Error in position polling loop" }
} _error.tryEmit(e)
} }
} }
} }

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.core.di package dev.krtirtho.spotube.core.di
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.share.JvmShareService import dev.krtirtho.spotube.core.share.JvmShareService
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
@ -29,7 +28,7 @@ import org.koin.dsl.module
actual val platformModules = module { actual val platformModules = module {
singleOf(::Paths) singleOf(::Paths)
single<AudioPlayerInterface> { AudioPlayer(Unit) } single { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() } single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() } single<ShareService> { JvmShareService() }
} }

View File

@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.foundation.window.WindowDraggableArea
@ -34,12 +36,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import compose.icons.FeatherIcons
import dev.krtirtho.spotube.resources.iconsax.FluentMaximize import compose.icons.feathericons.Maximize2
import dev.krtirtho.spotube.resources.iconsax.FluentMinus import compose.icons.feathericons.Minimize2
import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple import compose.icons.feathericons.Minus
import dev.krtirtho.spotube.resources.iconsax.Iconsax import compose.icons.feathericons.X
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@ -76,7 +79,7 @@ actual fun ApplicationMainBar(
@Composable @Composable
private fun WindowButtons(modifier: Modifier = Modifier) { private fun WindowButtons(modifier: Modifier = Modifier) {
val windowState = LocalWindowState.current val window = LocalWindowScope.current.window
val applicationScope = LocalApplicationScope.current val applicationScope = LocalApplicationScope.current
Row( Row(
@ -85,27 +88,27 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
) { ) {
IconButton( IconButton(
onClick = { onClick = {
windowState.isMinimized = true window.isMinimized = true
}) { }) {
Icon( Icon(
Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp) FeatherIcons.Minus, "Minimize", modifier = Modifier.size(14.dp)
) )
} }
IconButton( IconButton(
onClick = { onClick = {
windowState.placement = if (windowState.placement == WindowPlacement.Maximized) { window.placement = if (window.placement == WindowPlacement.Maximized) {
WindowPlacement.Floating WindowPlacement.Floating
} else { } else {
WindowPlacement.Maximized WindowPlacement.Maximized
} }
}) { }) {
if (windowState.placement == WindowPlacement.Floating) { if (window.placement == WindowPlacement.Floating) {
Icon( Icon(
Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp) FeatherIcons.Maximize2, "Maximize", modifier = Modifier.size(14.dp)
) )
} else { } else {
Icon( Icon(
Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp) FeatherIcons.Minimize2, "Restore", modifier = Modifier.size(14.dp)
) )
} }
} }
@ -114,7 +117,7 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
applicationScope.exitApplication() applicationScope.exitApplication()
}) { }) {
Icon( Icon(
Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp) FeatherIcons.X, "Close", modifier = Modifier.size(14.dp)
) )
} }
} }

View File

@ -19,12 +19,8 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowState
// LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy. // LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy.
val LocalWindowScope = compositionLocalOf<FrameWindowScope> { val LocalWindowScope = compositionLocalOf<FrameWindowScope> {
error("No Window found in LocalWindowScope") error("No Window found in LocalWindowScope")
} }
val LocalWindowState = compositionLocalOf<WindowState> {
error("No WindowState found in LocalWindowState")
}

View File

@ -29,7 +29,6 @@ import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowState
import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.FileKit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -67,7 +66,6 @@ fun main() {
CompositionLocalProvider( CompositionLocalProvider(
LocalApplicationScope provides this@application, LocalApplicationScope provides this@application,
LocalWindowScope provides this@Window, LocalWindowScope provides this@Window,
LocalWindowState provides windowState
) { ) {
App() App()
} }

View File

@ -1,446 +0,0 @@
package dev.krtirtho.spotube.core.audioplayer
import co.touchlab.kermit.Logger
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class AudioPlayerTest {
private lateinit var player: AudioPlayer
private lateinit var testAudioDir: File
private val audioFiles = mutableListOf<File>()
@BeforeTest
fun setup() {
runCatching { stopKoin() }
startKoin {
modules(module {
factory { (tag: String?) -> Logger.withTag(tag ?: "test") }
})
}
val vlcNativesDir = File("build/vlc-natives/windows-x64")
if (vlcNativesDir.isDirectory) {
System.setProperty("compose.application.resources.dir", vlcNativesDir.absolutePath)
}
testAudioDir = findTestAudioDir()
val files = testAudioDir.listFiles()
?.filter { it.isFile && it.extension in setOf("mp3", "flac", "wav", "ogg", "m4a") }
?.sortedBy { it.name }
?: emptyList()
require(files.size >= 5) {
"Need at least 5 audio files in $testAudioDir, found ${files.size}. " +
"Place .mp3/.flac/.wav/.ogg/.m4a files in composeApp/src/jvmTest/resources/test-audio/"
}
audioFiles.addAll(files)
player = AudioPlayer(Any())
}
@AfterTest
fun teardown() {
runCatching { if (::player.isInitialized) player.dispose() }
runCatching { stopKoin() }
}
private fun findTestAudioDir(): File {
val candidates = listOf(
File("src/jvmTest/resources/test-audio"),
File("../composeApp/src/jvmTest/resources/test-audio"),
File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio"),
)
for (dir in candidates) {
if (dir.isDirectory) return dir
}
return candidates.first()
}
private fun mediaItem(url: String, title: String): MediaItem {
return MediaItem(
title = title,
artist = "Test Artist",
album = "Test Album",
duration = 10.seconds,
coverURL = "",
url = url,
protocol = StreamProtocol.PROGRESSIVE,
)
}
private fun testItems(count: Int = 3): List<MediaItem> {
return (1..count).map { i ->
mediaItem("file:///test/unique-$i.wav", "Track $i")
}
}
private fun realAudioItems(count: Int = 1): List<MediaItem> {
return (1..count).map { i ->
val file = audioFiles.getOrNull(i - 1) ?: audioFiles.first()
mediaItem(file.absolutePath, file.nameWithoutExtension)
}
}
// Wait for player to reach a target state
private fun awaitState(target: PlayerState, timeoutMs: Long = 4000) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (player.playerStateFlow.value == target) return
runBlocking { delay(50.milliseconds) }
}
}
// --- Playback ---
@Test
fun `play transitions to PLAYING`() = runBlocking {
withTimeout(10000.milliseconds) {
player.load(realAudioItems(1), autoPlay = false, startPosition = 0)
assertEquals(PlayerState.PAUSED, player.playerStateFlow.value)
player.play()
awaitState(PlayerState.PLAYING)
assertTrue(player.playerStateFlow.value == PlayerState.PLAYING ||
player.playerStateFlow.value == PlayerState.COMPLETED,
"Expected PLAYING but got ${player.playerStateFlow.value}")
}
}
@Test
fun `pause transitions to PAUSED`() = runBlocking {
withTimeout(10000.milliseconds) {
player.load(realAudioItems(1), autoPlay = false, startPosition = 0)
player.play()
delay(100.milliseconds)
val state = player.playerStateFlow.value
if (state == PlayerState.COMPLETED) return@withTimeout
awaitState(PlayerState.PLAYING)
player.pause()
delay(200.milliseconds)
assertTrue(player.playerStateFlow.value == PlayerState.PAUSED ||
player.playerStateFlow.value == PlayerState.COMPLETED,
"Expected PAUSED but got ${player.playerStateFlow.value}")
}
}
@Test
fun `stop resets position and state`() = runBlocking {
withTimeout(10000.milliseconds) {
player.load(realAudioItems(1), autoPlay = false, startPosition = 0)
player.play()
awaitState(PlayerState.PLAYING)
delay(200.milliseconds)
player.stop()
delay(200.milliseconds)
assertEquals(PlayerState.IDLE, player.playerStateFlow.value)
assertEquals(0L, player.positionFlow.value.inWholeMilliseconds)
}
}
@Test
fun `seekTo changes position`() = runBlocking {
withTimeout(10000.milliseconds) {
player.load(realAudioItems(1), autoPlay = false, startPosition = 0)
player.play()
awaitState(PlayerState.PLAYING)
delay(200.milliseconds)
player.seekTo(1000.milliseconds)
delay(300.milliseconds)
val pos = player.positionFlow.value.inWholeMilliseconds
assertTrue(pos >= 800 && pos <= 2000 || pos == 0L && player.playerStateFlow.value == PlayerState.COMPLETED,
"Position $pos should be near 1000ms (state=${player.playerStateFlow.value})")
}
}
// --- Shuffle (the bug fix) ---
@Test
fun `shuffle enabled reorders playlist`() = runBlocking {
val items = testItems(3)
player.load(items, autoPlay = false, startPosition = 0)
val originalOrder = player.playlistFlow.value.map { it.url }
player.shuffle(true)
assertEquals(true, player.shuffleModeFlow.value)
val shuffledOrder = player.playlistFlow.value.map { it.url }
assertEquals(items.size, shuffledOrder.size)
assertTrue(shuffledOrder.containsAll(originalOrder))
}
@Test
fun `shuffle does NOT restart current track`() = runBlocking {
withTimeout(15000.milliseconds) {
val items = realAudioItems(2)
player.load(items, autoPlay = false, startPosition = 0)
player.play()
awaitState(PlayerState.PLAYING)
delay(500.milliseconds)
val posBefore = player.positionFlow.value.inWholeMilliseconds
if (posBefore <= 100 && player.playerStateFlow.value == PlayerState.COMPLETED) return@withTimeout
assertTrue(posBefore > 100, "Track should have progressed past 100ms (state=${player.playerStateFlow.value})")
player.shuffle(true)
delay(200.milliseconds)
val posAfter = player.positionFlow.value.inWholeMilliseconds
assertTrue(posAfter >= posBefore - 200,
"Position after shuffle ($posAfter) dropped vs before ($posBefore)")
}
}
@Test
fun `shuffle disable restores original order`() = runBlocking {
val items = testItems(3)
player.load(items, autoPlay = false, startPosition = 0)
val originalOrder = player.playlistFlow.value.map { it.url }
player.shuffle(true)
player.shuffle(false)
assertEquals(false, player.shuffleModeFlow.value)
val restoredOrder = player.playlistFlow.value.map { it.url }
assertEquals(originalOrder, restoredOrder)
}
@Test
fun `shuffle no-op when already shuffled`() = runBlocking {
val items = testItems(3)
player.load(items, autoPlay = false, startPosition = 0)
player.shuffle(true)
val orderAfterFirst = player.playlistFlow.value.map { it.url }
player.shuffle(true)
val orderAfterSecond = player.playlistFlow.value.map { it.url }
assertEquals(orderAfterFirst, orderAfterSecond)
}
@Test
fun `shuffle preserves current item as first in shuffled list`() = runBlocking {
val items = testItems(4)
player.load(items, autoPlay = false, startPosition = 0)
player.shuffle(true)
val shuffled = player.playlistFlow.value
assertEquals(items[0].url, shuffled.first().url,
"Current item should be first in shuffled playlist")
}
// --- Playlist management ---
@Test
fun `load populates playlist and sets current item`() = runBlocking {
val items = testItems(2)
player.load(items, autoPlay = false, startPosition = 0)
assertEquals(2, player.playlistFlow.value.size)
assertNotNull(player.currentMediaItemFlow.value)
assertEquals(items[0].url, player.currentMediaItemFlow.value?.url)
}
@Test
fun `load with empty playlist clears state`() = runBlocking {
player.load(testItems(1), autoPlay = false, startPosition = 0)
player.load(emptyList(), autoPlay = false, startPosition = 0)
assertEquals(PlayerState.IDLE, player.playerStateFlow.value)
assertTrue(player.playlistFlow.value.isEmpty())
}
@Test
fun `load with autoPlay false stays paused`() = runBlocking {
player.load(testItems(1), autoPlay = false, startPosition = 0)
delay(200.milliseconds)
assertEquals(PlayerState.PAUSED, player.playerStateFlow.value)
}
@Test
fun `addMediaItem appends to playlist`() = runBlocking {
player.load(testItems(1), autoPlay = false, startPosition = 0)
val newItem = mediaItem(audioFiles.first().absolutePath, "Appended Track")
player.addMediaItem(newItem)
assertEquals(2, player.playlistFlow.value.size)
assertEquals("Appended Track", player.playlistFlow.value.last().title)
}
@Test
fun `removeMediaItem removes by url`() = runBlocking {
val items = testItems(2)
player.load(items, autoPlay = false, startPosition = 0)
player.removeMediaItem(items[0])
assertEquals(1, player.playlistFlow.value.size)
assertNotEquals(items[0].url, player.playlistFlow.value.first().url)
}
@Test
fun `moveMediaItem reorders playlist`() = runBlocking {
val items = testItems(3)
player.load(items, autoPlay = false, startPosition = 0)
player.moveMediaItem(0, 2)
assertEquals(items[1].url, player.playlistFlow.value[0].url)
assertEquals(items[2].url, player.playlistFlow.value[1].url)
assertEquals(items[0].url, player.playlistFlow.value[2].url)
}
// --- Navigation ---
@Test
fun `skipToNext advances track`() = runBlocking {
withTimeout(15000.milliseconds) {
val items = realAudioItems(2)
player.load(items, autoPlay = false, startPosition = 0)
player.play()
awaitState(PlayerState.PLAYING)
delay(500.milliseconds)
val firstUrl = player.currentMediaItemFlow.value?.url
player.skipToNext()
delay(1000.milliseconds)
val secondUrl = player.currentMediaItemFlow.value?.url
assertNotEquals(firstUrl, secondUrl)
assertEquals(items[1].url, secondUrl)
}
}
@Test
fun `jumpTo plays specific index`() = runBlocking {
withTimeout(10000.milliseconds) {
val items = realAudioItems(3)
player.load(items, autoPlay = true, startPosition = 0)
awaitState(PlayerState.PLAYING)
delay(500.milliseconds)
player.jumpTo(2)
delay(1000.milliseconds)
assertEquals(items[2].url, player.currentMediaItemFlow.value?.url)
}
}
@Test
fun `jumpTo out of bounds is no-op`() = runBlocking {
withTimeout(10000.milliseconds) {
val items = testItems(2)
player.load(items, autoPlay = true, startPosition = 0)
awaitState(PlayerState.PLAYING)
delay(500.milliseconds)
val beforeUrl = player.currentMediaItemFlow.value?.url
player.jumpTo(99)
delay(100.milliseconds)
assertEquals(beforeUrl, player.currentMediaItemFlow.value?.url)
}
}
// --- Other controls ---
@Test
fun `loop sets repeat mode`() = runBlocking {
player.load(testItems(1), autoPlay = false, startPosition = 0)
player.loop(LoopState.ONE)
assertEquals(LoopState.ONE, player.loopStateFlow.value)
player.loop(LoopState.ALL)
assertEquals(LoopState.ALL, player.loopStateFlow.value)
player.loop(LoopState.NONE)
assertEquals(LoopState.NONE, player.loopStateFlow.value)
}
@Test
fun `setVolume changes volume`() = runBlocking {
player.setVolume(0.5f)
delay(100.milliseconds)
assertTrue(player.volumeFlow.value in 0.45f..0.55f)
}
@Test
fun `setPlaybackSpeed changes rate`() = runBlocking {
player.setPlaybackSpeed(2.0f)
delay(100.milliseconds)
assertEquals(2.0f, player.playbackSpeedFlow.value)
}
@Test
fun `dispose cleans up and marks disposed`() = runBlocking {
player.load(testItems(1), autoPlay = false, startPosition = 0)
player.dispose()
assertTrue(player.isDisposed())
assertEquals(PlayerState.IDLE, player.playerStateFlow.value)
assertNull(player.currentMediaItemFlow.value)
}
// --- Edge cases ---
@Test
fun `shuffle on empty playlist is no-op`() = runBlocking {
player.shuffle(true)
assertEquals(false, player.shuffleModeFlow.value)
}
@Test
fun `shuffle on empty playlist after load with empty`() = runBlocking {
player.load(emptyList(), autoPlay = false, startPosition = 0)
player.shuffle(true)
assertEquals(false, player.shuffleModeFlow.value)
}
@Test
fun `multiple back-to-back shuffle toggles`() = runBlocking {
val items = testItems(3)
player.load(items, autoPlay = false, startPosition = 0)
player.shuffle(true)
player.shuffle(false)
player.shuffle(true)
player.shuffle(false)
assertEquals(false, player.shuffleModeFlow.value)
assertEquals(items.size, player.playlistFlow.value.size)
}
@Test
fun `removeMediaItem on empty playlist does nothing`() = runBlocking {
val item = mediaItem(audioFiles.first().absolutePath, "Test")
player.removeMediaItem(item)
assertTrue(player.playlistFlow.value.isEmpty())
}
}

View File

@ -74,7 +74,6 @@ reorderable = "3.1.0"
vlcjBundler = "0.1.0" vlcjBundler = "0.1.0"
uniffi = "0.3.7" uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0" kotlinx-atomicfu = "0.33.0"
mokkery = "3.3.0"
[libraries] [libraries]
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
@ -181,4 +180,3 @@ vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "v
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" } kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }