From a9760a711acb5fd8633b27c8277b1b0da3b9ef8a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 20 Jul 2026 21:45:33 +0600 Subject: [PATCH] feat: introduce AudioPlayerInterface and refactor AudioPlayer implementations for better abstraction --- build.gradle.kts | 1 + composeApp/build.gradle.kts | 1 + .../core/audioplayer/AudioPlayer.android.kt | 62 +-- .../spotube/core/di/Modules.android.kt | 3 +- .../spotube/core/audioplayer/AudioPlayer.kt | 49 +- .../audioplayer/AudioPlayerQueueRepository.kt | 14 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 19 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 28 +- .../spotube/modules/plugin/PluginManager.kt | 11 +- .../modules/settings/SettingsViewModel.kt | 9 +- .../modules/shell/AppExpandedPlayer.kt | 5 +- .../modules/shell/AppFloatingPlayer.kt | 3 +- .../spotube/modules/shell/AppLargePlayer.kt | 3 +- .../spotube/modules/shell/AppPlayerState.kt | 3 +- .../core/audioplayer/AudioPlayerQueueTest.kt | 86 +++ .../audioplayer/DeviceAudioPlayerQueueTest.kt | 498 ++++++++++++++++++ .../core/audioplayer/FakeAudioPlayer.kt | 179 +++++++ .../FakeAudioPlayerQueueRepository.kt | 19 + .../core/audioplayer/FakePluginProvider.kt | 60 +++ .../core/audioplayer/FakeSettingsProvider.kt | 22 + .../core/audioplayer/AudioPlayer.ios.kt | 62 +-- .../krtirtho/spotube/core/di/Modules.ios.kt | 3 +- .../core/audioplayer/AudioPlayer.jvm.kt | 72 +-- .../krtirtho/spotube/core/di/Modules.jvm.kt | 3 +- .../core/audioplayer/AudioPlayerTest.kt | 446 ++++++++++++++++ .../src/jvmTest/resources/test-audio/.gitkeep | 0 gradle/libs.versions.toml | 4 +- 27 files changed, 1522 insertions(+), 143 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt create mode 100644 composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt create mode 100644 composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt create mode 100644 composeApp/src/jvmTest/resources/test-audio/.gitkeep diff --git a/build.gradle.kts b/build.gradle.kts index bd0d4631..55e4c403 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,4 +32,5 @@ plugins { alias(libs.plugins.vlcjBundler) apply false alias(libs.plugins.uniffi) apply false alias(libs.plugins.cargo) apply false + alias(libs.plugins.mokkery) apply false } \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a2dc6e7f..f17d227d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -34,6 +34,7 @@ plugins { alias(libs.plugins.vlcjBundler) alias(libs.plugins.uniffi) alias(libs.plugins.cargo) + alias(libs.plugins.mokkery) kotlin("plugin.atomicfu") version libs.versions.kotlin } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt index 9e65bd91..899cd58f 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt @@ -44,7 +44,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -actual class AudioPlayer actual constructor(context: Any) { +actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { actual val context: Any = context @@ -90,18 +90,18 @@ actual class AudioPlayer actual constructor(context: Any) { private val _completion = MutableSharedFlow(extraBufferCapacity = 1) private val _error = MutableSharedFlow(extraBufferCapacity = 1) - actual val playerStateFlow: StateFlow = _playerState.asStateFlow() - actual val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() - actual val playlistFlow: StateFlow> = _playlist.asStateFlow() - actual val durationFlow: StateFlow = _duration.asStateFlow() - actual val positionFlow: StateFlow = _position.asStateFlow() - actual val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() - actual val loopStateFlow: StateFlow = _loopState.asStateFlow() - actual val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() - actual val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() - actual val volumeFlow: StateFlow = _volume.asStateFlow() - actual val completionFlow: Flow = _completion.asSharedFlow() - actual val errorFlow: Flow = _error.asSharedFlow() + actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() + actual override val durationFlow: StateFlow = _duration.asStateFlow() + actual override val positionFlow: StateFlow = _position.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() + actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() + actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() + actual override val volumeFlow: StateFlow = _volume.asStateFlow() + actual override val completionFlow: Flow = _completion.asSharedFlow() + actual override val errorFlow: Flow = _error.asSharedFlow() private var lastPlaybackState = Player.STATE_IDLE @@ -204,27 +204,27 @@ actual class AudioPlayer actual constructor(context: Any) { .build() } - actual suspend fun play() { + actual override suspend fun play() { withContext(Dispatchers.Main) { ensureServiceStarted() exoPlayer.play() } } - actual suspend fun pause() { + actual override suspend fun pause() { withContext(Dispatchers.Main) { exoPlayer.pause() } } - actual suspend fun stop() { + actual override suspend fun stop() { withContext(Dispatchers.Main) { exoPlayer.stop() _playerState.tryEmit(PlayerState.IDLE) } } - actual suspend fun seekTo(position: Duration) { + actual override suspend fun seekTo(position: Duration) { withContext(Dispatchers.Main) { val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration) exoPlayer.seekTo(targetMs) @@ -232,7 +232,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun loop(state: LoopState) { + actual override suspend fun loop(state: LoopState) { withContext(Dispatchers.Main) { val repeatMode = when (state) { LoopState.NONE -> Player.REPEAT_MODE_OFF @@ -244,14 +244,14 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun shuffle(enabled: Boolean) { + actual override suspend fun shuffle(enabled: Boolean) { withContext(Dispatchers.Main) { exoPlayer.shuffleModeEnabled = enabled _shuffleMode.tryEmit(enabled) } } - actual suspend fun load( + actual override suspend fun load( playlist: List, autoPlay: Boolean, startPosition: Int @@ -288,7 +288,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun addMediaItem(mediaItem: MediaItem) { + actual override suspend fun addMediaItem(mediaItem: MediaItem) { withContext(Dispatchers.Main) { currentPlaylist.add(mediaItem) urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex @@ -297,7 +297,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { + actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { withContext(Dispatchers.Main) { val currentIndex = exoPlayer.currentMediaItemIndex val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size) @@ -313,7 +313,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun removeMediaItem(mediaItem: MediaItem) { + actual override suspend fun removeMediaItem(mediaItem: MediaItem) { withContext(Dispatchers.Main) { val index = urlIndexMap[mediaItem.url] ?: return@withContext currentPlaylist.removeAt(index) @@ -326,7 +326,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { + actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { withContext(Dispatchers.Main) { if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return@withContext @@ -342,19 +342,19 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun skipToNext() { + actual override suspend fun skipToNext() { withContext(Dispatchers.Main) { exoPlayer.seekToNextMediaItem() } } - actual suspend fun skipToPrevious() { + actual override suspend fun skipToPrevious() { withContext(Dispatchers.Main) { exoPlayer.seekToPreviousMediaItem() } } - actual suspend fun jumpTo(index: Int) { + actual override suspend fun jumpTo(index: Int) { withContext(Dispatchers.Main) { if (index in currentPlaylist.indices) { exoPlayer.seekToDefaultPosition(index) @@ -362,7 +362,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun setVolume(volume: Float) { + actual override suspend fun setVolume(volume: Float) { withContext(Dispatchers.Main) { val clamped = volume.coerceIn(0f, 1f) exoPlayer.setVolume(clamped) @@ -370,7 +370,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun setPlaybackSpeed(speed: Float) { + actual override suspend fun setPlaybackSpeed(speed: Float) { withContext(Dispatchers.Main) { val clamped = speed.coerceIn(0.25f, 4f) exoPlayer.setPlaybackSpeed(clamped) @@ -378,9 +378,9 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual fun isDisposed(): Boolean = disposed + actual override fun isDisposed(): Boolean = disposed - actual fun dispose() { + actual override fun dispose() { disposed = true exoPlayer.release() currentPlaylist.clear() diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt index 7ce98069..836db5d6 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt @@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.di import android.content.Context 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.share.AndroidShareService import dev.krtirtho.spotube.core.share.ShareService @@ -29,7 +30,7 @@ import org.koin.dsl.module actual val platformModules = module { single { Paths(get()) } - single { AudioPlayer(get()) } + single { AudioPlayer(get()) } single { AndroidLocalMediaDiscoveryService(get()) } single { AndroidShareService(get()) } single { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt index 94aca2c2..8587e043 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt @@ -52,11 +52,8 @@ enum class PlayerState { IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED } -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -expect class AudioPlayer(context: Any) { - val context: Any // Optional context for platform-specific implementations (e.g., Android Context) - - // Playback +interface AudioPlayerInterface { + // // Playback suspend fun play() suspend fun pause() suspend fun stop() @@ -65,7 +62,7 @@ expect class AudioPlayer(context: Any) { suspend fun shuffle(enabled: Boolean) // Playlist management - suspend fun load(playlist: List, autoPlay: Boolean = true, startPosition: Int = 0) + suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) suspend fun addMediaItem(mediaItem: MediaItem) suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) suspend fun removeMediaItem(mediaItem: MediaItem) @@ -95,3 +92,43 @@ expect class AudioPlayer(context: Any) { fun isDisposed(): Boolean 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, + 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 + override val currentMediaItemFlow: StateFlow + override val playlistFlow: StateFlow> + override val durationFlow: StateFlow + override val positionFlow: StateFlow + override val bufferingPositionFlow: StateFlow + override val loopStateFlow: StateFlow + override val shuffleModeFlow: StateFlow + override val playbackSpeedFlow: StateFlow + override val volumeFlow: StateFlow + override val completionFlow: Flow + override val errorFlow: Flow + override suspend fun setVolume(volume: Float) + override suspend fun setPlaybackSpeed(speed: Float) + override fun isDisposed(): Boolean + override fun dispose() +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt index 72394652..91b03b26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt @@ -24,12 +24,18 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json -class AudioPlayerQueueRepository(private val database: Database) { +interface QueueStateRepository { + suspend fun getPersistedState(): PersistedQueueState? + suspend fun saveState(state: PersistedQueueState) + suspend fun clearState() +} + +class AudioPlayerQueueRepository(private val database: Database) : QueueStateRepository { private val json = Json { ignoreUnknownKeys = true } - suspend fun getPersistedState(): PersistedQueueState? { + override suspend fun getPersistedState(): PersistedQueueState? { return database.audioPlayerQueueDataStore.data.map { preferences -> val payload = preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] payload?.let { @@ -39,13 +45,13 @@ class AudioPlayerQueueRepository(private val database: Database) { }.first() } - suspend fun saveState(state: PersistedQueueState) { + override suspend fun saveState(state: PersistedQueueState) { database.audioPlayerQueueDataStore.edit { preferences -> preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] = json.encodeToString(state) } } - suspend fun clearState() { + override suspend fun clearState() { database.audioPlayerQueueDataStore.edit { preferences -> preferences.remove(DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 57e711cc..f20e70a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -19,8 +19,8 @@ package dev.krtirtho.spotube.core.audioplayer import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import dev.krtirtho.spotube.modules.plugin.PluginProvider +import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn @@ -41,10 +40,10 @@ import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds class DeviceAudioPlayerQueue( - private val audioPlayer: AudioPlayer, - private val settingsViewModel: SettingsViewModel, - private val repository: AudioPlayerQueueRepository, - private val pluginManager: PluginManager, + private val audioPlayer: AudioPlayerInterface, + private val settingsProvider: SettingsProvider, + private val repository: QueueStateRepository, + private val pluginProvider: PluginProvider, ) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() @@ -310,7 +309,7 @@ class DeviceAudioPlayerQueue( private suspend fun handleQueueCompletion() { if (isFetchingRecommendations) return - val settings = settingsViewModel.settingsState.value ?: return + val settings = settingsProvider.settingsState.value ?: return if (!settings.enableEndlessPlayback) return val queue = queueFlow.value @@ -330,7 +329,7 @@ class DeviceAudioPlayerQueue( isFetchingRecommendations = true logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" } - val metadataService = pluginManager.selectedMetadataPlugin.value ?: run { + val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run { logger.w { "Endless playback: no metadata plugin available" } isFetchingRecommendations = false return @@ -482,7 +481,7 @@ class DeviceAudioPlayerQueue( private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { val port: Int = - settingsViewModel.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() + settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() val baseUrl = "http://127.0.0.1:$port" return when (protocol) { StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index c9780e9c..8288f014 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -21,45 +21,49 @@ import co.touchlab.kermit.Logger import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository 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.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.navigation.navigationModule +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.LocalServer import dev.krtirtho.spotube.core.server.MatchedTracksRepository 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.modules.artist.ArtistRepository -import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.album.AlbumRepository import dev.krtirtho.spotube.modules.album.AlbumViewModel -import dev.krtirtho.spotube.modules.home.HomeScreenRepository -import dev.krtirtho.spotube.modules.home.HomeScreenViewModel +import dev.krtirtho.spotube.modules.artist.ArtistRepository +import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel +import dev.krtirtho.spotube.modules.home.HomeScreenRepository +import dev.krtirtho.spotube.modules.home.HomeScreenViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryState -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.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.local_tracks.media.LocalMediaCacheRepository +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.playlist.LibraryPlaylistsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel 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.SavedTracksViewModel import dev.krtirtho.spotube.modules.search.SearchRepository 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.SettingsViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel 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.singleOf import org.koin.core.module.dsl.viewModel @@ -100,11 +104,11 @@ val sharedModules = module { viewModelOf(::LibraryLocalTracksViewModel) // Plugin system - singleOf(::PluginManager) + singleOf(::PluginManager) { bind() } // Settings singleOf(::SettingsRepository) - viewModelOf(::SettingsViewModel) + viewModelOf(::SettingsViewModel) { bind() } // Downloads singleOf(::DownloadManager) @@ -163,7 +167,7 @@ val sharedModules = module { singleOf(::LocalServer) withOptions { createdAtStart() } - singleOf(::AudioPlayerQueueRepository) + singleOf(::AudioPlayerQueueRepository) { bind() } single { DeviceAudioPlayerQueue(get(), get(), get(), get()) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 78cb8881..84cd4110 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -62,15 +62,18 @@ import okio.SYSTEM import okio.buffer import okio.use import org.koin.core.component.KoinComponent -import kotlin.collections.set -import kotlin.getValue const val PLUGIN_API_VERSION = "0.0.1" +interface PluginProvider { + val selectedMetadataPlugin: StateFlow +} + + class PluginManager( val database: Database, val paths: Paths, -) : KoinComponent { +) : KoinComponent, PluginProvider { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -239,7 +242,7 @@ class PluginManager( val lyricsPlugins = filterPluginByType(PluginAbility.LYRICS) val scrobblePlugins = filterPluginByType(PluginAbility.SCROBBLE) - val selectedMetadataPlugin = + override val selectedMetadataPlugin = filterSelectedPluginByType(PluginAbility.METADATA) val selectedAudioPlugin = filterSelectedPluginByType(PluginAbility.AUDIO) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt index dcd418de..77c0fdba 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt @@ -24,10 +24,15 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +interface SettingsProvider { + val settingsState: StateFlow +} + + class SettingsViewModel( private val repository: SettingsRepository -) : ViewModel() { - val settingsState: StateFlow = repository.userSettings.stateIn( +) : ViewModel(), SettingsProvider { + override val settingsState: StateFlow = repository.userSettings.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), null, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 2c2ba3e0..c43ef433 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage 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.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry @@ -149,7 +150,7 @@ fun AppExpandedPlayer( onDownloadTrack: () -> Unit = {}, onGoToAlbum: () -> Unit = {}, onSleepTimer: () -> Unit = {}, - audioPlayer: AudioPlayer = koinInject(), + audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, @@ -593,7 +594,7 @@ private fun rememberSharedAlbumArtModifier( @Composable private fun LyricsPreviewCard( modifier: Modifier = Modifier, - audioPlayer: AudioPlayer, + audioPlayer: AudioPlayerInterface, onExpand: () -> Unit, viewModel: LyricsViewModel = koinViewModel() ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt index 3398413f..59178e07 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt @@ -68,6 +68,7 @@ import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf 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.IconButton import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme @@ -86,7 +87,7 @@ fun AppFloatingPlayer( modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, - audioPlayer: AudioPlayer = koinInject(), + audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 82b45b86..9d7ffbd1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -61,6 +61,7 @@ import dev.chrisbanes.haze.hazeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage 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.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry @@ -115,7 +116,7 @@ fun AppLargePlayer( onAlternativeSource: () -> Unit = {}, onMoreOptions: () -> Unit = {}, onLyrics: () -> Unit = {}, - audioPlayer: AudioPlayer = koinInject(), + audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), downloadsViewModel: DownloadsViewModel = koinViewModel(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt index 30d8f4fd..33b03057 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.LoopState import dev.krtirtho.spotube.core.audioplayer.PlayerState @@ -72,7 +73,7 @@ internal data class PlayerUiState( @Composable internal fun rememberPlayerUiState( - audioPlayer: AudioPlayer, + audioPlayer: AudioPlayerInterface, audioPlayerQueue: AudioPlayerQueue, ): PlayerUiState { val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt new file mode 100644 index 00000000..08c42b0f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt @@ -0,0 +1,86 @@ +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> = MutableStateFlow(emptyList()) + override val currentQueueEntryFlow: StateFlow = MutableStateFlow(null) + override val currentCollectionEntryFlow: StateFlow = collectionEntryFlow + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + override suspend fun load(entries: List, autoPlay: Boolean, startPosition: Int, collectionEntry: QueueCollectionEntry?) = Unit + override suspend fun addToQueue(entry: QueueEntry) = Unit + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = Unit + override suspend fun addAllAfterCurrent(entries: List) = 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 = emptyList() + override suspend fun getCurrentQueueEntry(): QueueEntry? = null + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = 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()) + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt new file mode 100644 index 00000000..74fa0327 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -0,0 +1,498 @@ +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) + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt new file mode 100644 index 00000000..406134b9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt @@ -0,0 +1,179 @@ +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>(emptyList()) + override val playlistFlow: StateFlow> = _playlistFlow.asStateFlow() + override val durationFlow: StateFlow + get() = TODO("Not yet implemented") + override val positionFlow: StateFlow + get() = TODO("Not yet implemented") + override val bufferingPositionFlow: StateFlow + get() = TODO("Not yet implemented") + + private val _currentMediaItemFlow = MutableStateFlow(null) + override val currentMediaItemFlow: StateFlow = _currentMediaItemFlow.asStateFlow() + + private val _loopStateFlow = MutableStateFlow(LoopState.NONE) + override val loopStateFlow: StateFlow = _loopStateFlow.asStateFlow() + override val shuffleModeFlow: StateFlow + get() = TODO("Not yet implemented") + override val playbackSpeedFlow: StateFlow + get() = TODO("Not yet implemented") + override val volumeFlow: StateFlow + get() = TODO("Not yet implemented") + override val completionFlow: Flow + get() = TODO("Not yet implemented") + override val errorFlow: Flow + 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? = 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, 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 + get() = TODO("Not yet implemented") + + fun setPlaylist(items: List) { + _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 + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt new file mode 100644 index 00000000..77cea29e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt @@ -0,0 +1,19 @@ +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 + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt new file mode 100644 index 00000000..40891c1e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt @@ -0,0 +1,60 @@ +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 = emptyList() +) : MetadataTrackAPI { + override suspend fun getTrack(id: String): MetadataTrack = + error("not mocked") + + override suspend fun savedTracks(pagination: PaginationStrategy?): PaginationResult = + error("not mocked") + + override suspend fun isSavedTracks(ids: List): List = + error("not mocked") + + override suspend fun saveTracks(ids: List) = error("not mocked") + + override suspend fun removeSavedTracks(ids: List) = error("not mocked") + + override suspend fun recommendationsBasedOnTracks( + seedTrackIds: List, + limit: Int + ): List = recommendations +} + +class FakePluginService( + private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI() +) : PluginService { + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun 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 = _selectedMetadataPlugin + + fun setPlugin(service: PluginService?) { + _selectedMetadataPlugin.value = service + } +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt new file mode 100644 index 00000000..cafdb7b1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt @@ -0,0 +1,22 @@ +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 = _settingsState.asStateFlow() + + fun updateSettings(transform: UserSettings.() -> UserSettings) { + _settingsState.value = _settingsState.value?.transform() + } + + fun setSettings(settings: UserSettings?) { + _settingsState.value = settings + } +} diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt index f4e6c621..04efce38 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -56,7 +56,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -actual class AudioPlayer actual constructor(context: Any) { +actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { actual val context: Any = context @@ -81,18 +81,18 @@ actual class AudioPlayer actual constructor(context: Any) { private val _completion = MutableSharedFlow(extraBufferCapacity = 1) private val _error = MutableSharedFlow(extraBufferCapacity = 1) - actual val playerStateFlow: StateFlow = _playerState.asStateFlow() - actual val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() - actual val playlistFlow: StateFlow> = _playlist.asStateFlow() - actual val durationFlow: StateFlow = _duration.asStateFlow() - actual val positionFlow: StateFlow = _position.asStateFlow() - actual val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() - actual val loopStateFlow: StateFlow = _loopState.asStateFlow() - actual val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() - actual val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() - actual val volumeFlow: StateFlow = _volume.asStateFlow() - actual val completionFlow: Flow = _completion.asSharedFlow() - actual val errorFlow: Flow = _error.asSharedFlow() + actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() + actual override val durationFlow: StateFlow = _duration.asStateFlow() + actual override val positionFlow: StateFlow = _position.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() + actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() + actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() + actual override val volumeFlow: StateFlow = _volume.asStateFlow() + actual override val completionFlow: Flow = _completion.asSharedFlow() + actual override val errorFlow: Flow = _error.asSharedFlow() private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null @@ -158,36 +158,36 @@ actual class AudioPlayer actual constructor(context: Any) { return AVPlayerItem(nsUrl) } - actual suspend fun play() { + actual override suspend fun play() { avPlayer.play() } - actual suspend fun pause() { + actual override suspend fun pause() { avPlayer.pause() } - actual suspend fun stop() { + actual override suspend fun stop() { avPlayer.pause() avPlayer.seekToTime(CMTimeMake(0, 1)) _playerState.tryEmit(PlayerState.IDLE) } - actual suspend fun seekTo(position: Duration) { + actual override suspend fun seekTo(position: Duration) { val seconds = position.inWholeMilliseconds / 1000.0 val cmTime = CMTimeMakeWithSeconds(seconds, 1000) avPlayer.seekToTime(cmTime) _position.tryEmit(position) } - actual suspend fun loop(state: LoopState) { + actual override suspend fun loop(state: LoopState) { _loopState.tryEmit(state) } - actual suspend fun shuffle(enabled: Boolean) { + actual override suspend fun shuffle(enabled: Boolean) { _shuffleMode.tryEmit(enabled) } - actual suspend fun load( + actual override suspend fun load( playlist: List, autoPlay: Boolean, startPosition: Int @@ -226,13 +226,13 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun addMediaItem(mediaItem: MediaItem) { + actual override suspend fun addMediaItem(mediaItem: MediaItem) { currentPlaylist.add(mediaItem) urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex _playlist.tryEmit(currentPlaylist.toList()) } - actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { + actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { val currentIndex = currentPlaylist.indexOfFirst { it.url == _currentMediaItem.value?.url } @@ -245,7 +245,7 @@ actual class AudioPlayer actual constructor(context: Any) { _playlist.tryEmit(currentPlaylist.toList()) } - actual suspend fun removeMediaItem(mediaItem: MediaItem) { + actual override suspend fun removeMediaItem(mediaItem: MediaItem) { val index = urlIndexMap[mediaItem.url] ?: return currentPlaylist.removeAt(index) urlIndexMap.clear() @@ -255,7 +255,7 @@ actual class AudioPlayer actual constructor(context: Any) { _playlist.tryEmit(currentPlaylist.toList()) } - actual suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { + actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return val item = currentPlaylist.removeAt(fromIndex) currentPlaylist.add(toIndex, item) @@ -266,7 +266,7 @@ actual class AudioPlayer actual constructor(context: Any) { _playlist.tryEmit(currentPlaylist.toList()) } - actual suspend fun skipToNext() { + actual override suspend fun skipToNext() { val currentIndex = currentPlaylist.indexOfFirst { it.url == _currentMediaItem.value?.url } @@ -282,7 +282,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun skipToPrevious() { + actual override suspend fun skipToPrevious() { val currentIndex = currentPlaylist.indexOfFirst { it.url == _currentMediaItem.value?.url } @@ -298,7 +298,7 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun jumpTo(index: Int) { + actual override suspend fun jumpTo(index: Int) { if (index in currentPlaylist.indices) { val item = currentPlaylist[index] val avItem = buildAVPlayerItem(item.url) @@ -309,21 +309,21 @@ actual class AudioPlayer actual constructor(context: Any) { } } - actual suspend fun setVolume(volume: Float) { + actual override suspend fun setVolume(volume: Float) { val clamped = volume.coerceIn(0f, 1f) avPlayer.volume = clamped _volume.tryEmit(clamped) } - actual suspend fun setPlaybackSpeed(speed: Float) { + actual override suspend fun setPlaybackSpeed(speed: Float) { val clamped = speed.coerceIn(0.25f, 4f) avPlayer.rate = clamped _playbackSpeed.tryEmit(clamped) } - actual fun isDisposed(): Boolean = disposed + actual override fun isDisposed(): Boolean = disposed - actual fun dispose() { + actual override fun dispose() { disposed = true avPlayer.pause() avPlayer.replaceCurrentItemWithPlayerItem(null) diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt index cd4c0116..c1b3efff 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube.core.di 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.share.IosShareService import dev.krtirtho.spotube.core.share.ShareService @@ -30,7 +31,7 @@ import org.koin.dsl.module actual val platformModules = module { singleOf(::Paths) singleOf(::WebViewController) - single { AudioPlayer(Unit) } + single { AudioPlayer(Unit) } single { IosLocalMediaDiscoveryService() } single { IosShareService() } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt index 905cf5e2..3dd640b0 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -48,7 +48,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -actual class AudioPlayer actual constructor(context: Any) : KoinComponent { +actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface, KoinComponent { actual val context: Any = context private val logger by injectLogger() @@ -86,18 +86,18 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { private val _completion = MutableSharedFlow(extraBufferCapacity = 1) private val _error = MutableSharedFlow(extraBufferCapacity = 1) - actual val playerStateFlow: StateFlow = _playerState.asStateFlow() - actual val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() - actual val playlistFlow: StateFlow> = _playlist.asStateFlow() - actual val durationFlow: StateFlow = _duration.asStateFlow() - actual val positionFlow: StateFlow = _position.asStateFlow() - actual val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() - actual val loopStateFlow: StateFlow = _loopState.asStateFlow() - actual val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() - actual val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() - actual val volumeFlow: StateFlow = _volume.asStateFlow() - actual val completionFlow: Flow = _completion.asSharedFlow() - actual val errorFlow: Flow = _error.asSharedFlow() + actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() + actual override val durationFlow: StateFlow = _duration.asStateFlow() + actual override val positionFlow: StateFlow = _position.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() + actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() + actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() + actual override val volumeFlow: StateFlow = _volume.asStateFlow() + actual override val completionFlow: Flow = _completion.asSharedFlow() + actual override val errorFlow: Flow = _error.asSharedFlow() init { VLCBundleLoaderGenerated.getVerifiedPath() @@ -241,7 +241,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { startPositionPolling() } - actual suspend fun play() { + actual override suspend fun play() { lock.withLock { if (disposed || currentPlaylist.isEmpty()) return if (currentIndex !in currentPlaylist.indices) { @@ -255,14 +255,14 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun pause() { + actual override suspend fun pause() { lock.withLock { if (disposed) return mediaListPlayer.controls().pause() } } - actual suspend fun stop() { + actual override suspend fun stop() { lock.withLock { if (disposed) return mediaListPlayer.controls().stop() @@ -271,7 +271,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun seekTo(position: Duration) { + actual override suspend fun seekTo(position: Duration) { lock.withLock { if (disposed) return val maxMs = _duration.value.inWholeMilliseconds @@ -289,7 +289,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun loop(state: LoopState) { + actual override suspend fun loop(state: LoopState) { lock.withLock { if (disposed) return val vlcMode = when (state) { @@ -302,7 +302,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun shuffle(enabled: Boolean) { + actual override suspend fun shuffle(enabled: Boolean) { lock.withLock { if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return @@ -338,7 +338,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { + actual override suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { lock.withLock { if (disposed) return @@ -375,7 +375,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun addMediaItem(mediaItem: MediaItem) { + actual override suspend fun addMediaItem(mediaItem: MediaItem) { lock.withLock { if (disposed) return originalPlaylist.add(mediaItem) @@ -389,7 +389,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { + actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { lock.withLock { if (disposed) return val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0 @@ -407,7 +407,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun removeMediaItem(mediaItem: MediaItem) { + actual override suspend fun removeMediaItem(mediaItem: MediaItem) { lock.withLock { if (disposed || currentPlaylist.isEmpty()) return @@ -437,7 +437,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { + actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { lock.withLock { if (disposed) return if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return @@ -461,7 +461,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun skipToNext() { + actual override suspend fun skipToNext() { lock.withLock { if (disposed || currentPlaylist.isEmpty()) return val nextIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex) @@ -471,7 +471,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun skipToPrevious() { + actual override suspend fun skipToPrevious() { lock.withLock { if (disposed || currentPlaylist.isEmpty()) return val prevIndex = (currentIndex - 1).coerceAtLeast(0) @@ -481,7 +481,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun jumpTo(index: Int) { + actual override suspend fun jumpTo(index: Int) { lock.withLock { if (disposed || index !in currentPlaylist.indices) return currentIndex = index @@ -490,7 +490,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun setVolume(volume: Float) { + actual override suspend fun setVolume(volume: Float) { lock.withLock { if (disposed) return val clamped = volume.coerceIn(0f, 1f) @@ -499,7 +499,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual suspend fun setPlaybackSpeed(speed: Float) { + actual override suspend fun setPlaybackSpeed(speed: Float) { lock.withLock { if (disposed) return val clamped = speed.coerceIn(0.25f, 4f) @@ -512,9 +512,9 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } } - actual fun isDisposed(): Boolean = disposed + actual override fun isDisposed(): Boolean = disposed - actual fun dispose() { + actual override fun dispose() { lock.withLock { if (disposed) return disposed = true @@ -544,10 +544,13 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { } private fun rebuildVlcMediaListLocked() { - mediaList.media().clear() + val oldList = mediaList + mediaList = mediaPlayerFactory.media().newMediaList() currentPlaylist.forEach { mediaItem -> mediaList.media().add(mediaItem.toMrl()) } + mediaListPlayer.list().setMediaList(mediaList.newMediaListRef()) + oldList.release() } private fun startPositionPolling() { @@ -569,8 +572,9 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent { delay(250.milliseconds) } } catch (e: Throwable) { - logger.e(e) { "Error in position polling loop" } - _error.tryEmit(e) + if (!disposed) { + logger.e(e) { "Error in position polling loop" } + } } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt index 5c7baf17..3e06d9ad 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube.core.di 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.share.JvmShareService import dev.krtirtho.spotube.core.share.ShareService @@ -28,7 +29,7 @@ import org.koin.dsl.module actual val platformModules = module { singleOf(::Paths) - single { AudioPlayer(Unit) } + single { AudioPlayer(Unit) } single { JvmLocalMediaDiscoveryService() } single { JvmShareService() } } diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt new file mode 100644 index 00000000..537fcfbb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt @@ -0,0 +1,446 @@ +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() + + @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 { + return (1..count).map { i -> + mediaItem("file:///test/unique-$i.wav", "Track $i") + } + } + + private fun realAudioItems(count: Int = 1): List { + 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()) + } +} diff --git a/composeApp/src/jvmTest/resources/test-audio/.gitkeep b/composeApp/src/jvmTest/resources/test-audio/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f71336f..1ab1fd02 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,6 +74,7 @@ reorderable = "3.1.0" vlcjBundler = "0.1.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" +mokkery = "3.3.0" [libraries] appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } @@ -179,4 +180,5 @@ spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spot vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" } kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" } uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } -cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } \ No newline at end of file +cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } +mokkery = { id = "dev.mokkery", version.ref = "mokkery" } \ No newline at end of file