mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat: introduce AudioPlayerInterface and refactor AudioPlayer implementations for better abstraction
This commit is contained in:
parent
189a0c658b
commit
a9760a711a
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
Loading…
Reference in New Issue
Block a user