feat: introduce AudioPlayerInterface and refactor AudioPlayer implementations for better abstraction

This commit is contained in:
Kingkor Roy Tirtho 2026-07-20 21:45:33 +06:00
parent 189a0c658b
commit a9760a711a
27 changed files with 1522 additions and 143 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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<Unit>(extraBufferCapacity = 1)
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _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<MediaItem>,
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()

View File

@ -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<Context>()) }
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
single<ShareService> { AndroidShareService(get()) }
single {

View File

@ -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<MediaItem>, autoPlay: Boolean = true, startPosition: Int = 0)
suspend fun load(playlist: List<MediaItem>, 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<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,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)
}

View File

@ -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<DeviceAudioPlayerQueue>()
@ -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"

View File

@ -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<PluginProvider>() }
// Settings
singleOf(::SettingsRepository)
viewModelOf(::SettingsViewModel)
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
// Downloads
singleOf(::DownloadManager)
@ -163,7 +167,7 @@ val sharedModules = module {
singleOf(::LocalServer) withOptions {
createdAtStart()
}
singleOf(::AudioPlayerQueueRepository)
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get())
}

View File

@ -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<PluginService?>
}
class PluginManager(
val database: Database,
val paths: Paths,
) : KoinComponent {
) : KoinComponent, PluginProvider {
private val logger by injectLogger<PluginManager>()
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)

View File

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

View File

@ -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<SavedTracksViewModel>(
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()
) {

View File

@ -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<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID,

View File

@ -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<SavedTracksViewModel>(

View File

@ -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())

View File

@ -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<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

@ -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)
}
}

View File

@ -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<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

@ -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
}
}

View File

@ -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<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

@ -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<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
@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<Unit>(extraBufferCapacity = 1)
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _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<MediaItem>,
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)

View File

@ -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<AudioPlayerInterface> { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { IosLocalMediaDiscoveryService() }
single<ShareService> { IosShareService() }
}

View File

@ -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<AudioPlayer>()
@ -86,18 +86,18 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _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<MediaItem>, autoPlay: Boolean, startPosition: Int) {
actual override suspend fun load(playlist: List<MediaItem>, 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" }
}
}
}
}

View File

@ -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<AudioPlayerInterface> { AudioPlayer(Unit) }
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
single<ShareService> { JvmShareService() }
}

View File

@ -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<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,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" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }