mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
13 Commits
66e9dc9f67
...
591292ce9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
591292ce9e | ||
|
|
16f10b5ba2 | ||
|
|
5f9f98fb9e | ||
|
|
a9760a711a | ||
|
|
189a0c658b | ||
|
|
9013b374c7 | ||
|
|
c10c9bb3fc | ||
|
|
b22ab3d43e | ||
|
|
c3852a1c32 | ||
|
|
7d1a6750c3 | ||
|
|
edc7cdd3fa | ||
|
|
12dea49c2c | ||
|
|
d48b0a2f65 |
12
.github/workflows/spotube-release-binary.yml
vendored
12
.github/workflows/spotube-release-binary.yml
vendored
@ -182,14 +182,8 @@ jobs:
|
|||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Package DEB
|
- name: Package Linux
|
||||||
run: ./gradlew :composeApp:packageReleaseDeb
|
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm :composeApp:packageReleaseAppImage
|
||||||
|
|
||||||
- name: Package RPM
|
|
||||||
run: ./gradlew :composeApp:packageReleaseRpm
|
|
||||||
|
|
||||||
- name: Package AppImage
|
|
||||||
run: ./gradlew :composeApp:packageReleaseAppImage
|
|
||||||
|
|
||||||
- name: Rename Linux artifacts
|
- name: Rename Linux artifacts
|
||||||
run: |
|
run: |
|
||||||
@ -348,7 +342,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.vars.outputs.tag }}
|
tag_name: ${{ steps.vars.outputs.tag }}
|
||||||
name: ${{ steps.vars.outputs.release_name }}
|
name: ${{ steps.vars.outputs.release_name }}
|
||||||
|
|||||||
@ -29,7 +29,10 @@
|
|||||||
- `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system.
|
- `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system.
|
||||||
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||||
|
|
||||||
## Desktop JVM specifics
|
## UI component patterns
|
||||||
|
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
||||||
|
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
||||||
|
- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain.
|
||||||
- JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`.
|
- JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`.
|
||||||
- JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`.
|
- JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`.
|
||||||
|
|
||||||
|
|||||||
@ -32,4 +32,5 @@ plugins {
|
|||||||
alias(libs.plugins.vlcjBundler) apply false
|
alias(libs.plugins.vlcjBundler) apply false
|
||||||
alias(libs.plugins.uniffi) apply false
|
alias(libs.plugins.uniffi) apply false
|
||||||
alias(libs.plugins.cargo) apply false
|
alias(libs.plugins.cargo) apply false
|
||||||
|
alias(libs.plugins.mokkery) apply false
|
||||||
}
|
}
|
||||||
@ -34,6 +34,7 @@ plugins {
|
|||||||
alias(libs.plugins.vlcjBundler)
|
alias(libs.plugins.vlcjBundler)
|
||||||
alias(libs.plugins.uniffi)
|
alias(libs.plugins.uniffi)
|
||||||
alias(libs.plugins.cargo)
|
alias(libs.plugins.cargo)
|
||||||
|
alias(libs.plugins.mokkery)
|
||||||
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ import kotlin.time.Duration
|
|||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
actual class AudioPlayer actual constructor(context: Any) {
|
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
|
||||||
|
|
||||||
actual val context: Any = context
|
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 _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
|
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
|
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
|
||||||
actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
|
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
|
||||||
actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
|
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
|
||||||
actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
|
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
|
||||||
actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
|
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
|
||||||
actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
|
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
|
||||||
actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
|
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
|
||||||
actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
|
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
|
||||||
actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
||||||
actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
|
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
|
||||||
actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||||
actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||||
|
|
||||||
private var lastPlaybackState = Player.STATE_IDLE
|
private var lastPlaybackState = Player.STATE_IDLE
|
||||||
|
|
||||||
@ -204,27 +204,27 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun play() {
|
actual override suspend fun play() {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
ensureServiceStarted()
|
ensureServiceStarted()
|
||||||
exoPlayer.play()
|
exoPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun pause() {
|
actual override suspend fun pause() {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
exoPlayer.pause()
|
exoPlayer.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun stop() {
|
actual override suspend fun stop() {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
exoPlayer.stop()
|
exoPlayer.stop()
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration)
|
val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration)
|
||||||
exoPlayer.seekTo(targetMs)
|
exoPlayer.seekTo(targetMs)
|
||||||
@ -232,7 +232,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun loop(state: LoopState) {
|
actual override suspend fun loop(state: LoopState) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val repeatMode = when (state) {
|
val repeatMode = when (state) {
|
||||||
LoopState.NONE -> Player.REPEAT_MODE_OFF
|
LoopState.NONE -> Player.REPEAT_MODE_OFF
|
||||||
@ -244,14 +244,14 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun shuffle(enabled: Boolean) {
|
actual override suspend fun shuffle(enabled: Boolean) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
exoPlayer.shuffleModeEnabled = enabled
|
exoPlayer.shuffleModeEnabled = enabled
|
||||||
_shuffleMode.tryEmit(enabled)
|
_shuffleMode.tryEmit(enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun load(
|
actual override suspend fun load(
|
||||||
playlist: List<MediaItem>,
|
playlist: List<MediaItem>,
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
startPosition: Int
|
startPosition: Int
|
||||||
@ -288,7 +288,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun addMediaItem(mediaItem: MediaItem) {
|
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
currentPlaylist.add(mediaItem)
|
currentPlaylist.add(mediaItem)
|
||||||
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
||||||
@ -297,7 +297,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val currentIndex = exoPlayer.currentMediaItemIndex
|
val currentIndex = exoPlayer.currentMediaItemIndex
|
||||||
val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size)
|
val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size)
|
||||||
@ -313,7 +313,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun removeMediaItem(mediaItem: MediaItem) {
|
actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val index = urlIndexMap[mediaItem.url] ?: return@withContext
|
val index = urlIndexMap[mediaItem.url] ?: return@withContext
|
||||||
currentPlaylist.removeAt(index)
|
currentPlaylist.removeAt(index)
|
||||||
@ -326,7 +326,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
|
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return@withContext
|
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return@withContext
|
||||||
|
|
||||||
@ -342,19 +342,19 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun skipToNext() {
|
actual override suspend fun skipToNext() {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
exoPlayer.seekToNextMediaItem()
|
exoPlayer.seekToNextMediaItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun skipToPrevious() {
|
actual override suspend fun skipToPrevious() {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
exoPlayer.seekToPreviousMediaItem()
|
exoPlayer.seekToPreviousMediaItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun jumpTo(index: Int) {
|
actual override suspend fun jumpTo(index: Int) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (index in currentPlaylist.indices) {
|
if (index in currentPlaylist.indices) {
|
||||||
exoPlayer.seekToDefaultPosition(index)
|
exoPlayer.seekToDefaultPosition(index)
|
||||||
@ -362,7 +362,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun setVolume(volume: Float) {
|
actual override suspend fun setVolume(volume: Float) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val clamped = volume.coerceIn(0f, 1f)
|
val clamped = volume.coerceIn(0f, 1f)
|
||||||
exoPlayer.setVolume(clamped)
|
exoPlayer.setVolume(clamped)
|
||||||
@ -370,7 +370,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun setPlaybackSpeed(speed: Float) {
|
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val clamped = speed.coerceIn(0.25f, 4f)
|
val clamped = speed.coerceIn(0.25f, 4f)
|
||||||
exoPlayer.setPlaybackSpeed(clamped)
|
exoPlayer.setPlaybackSpeed(clamped)
|
||||||
@ -378,9 +378,9 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun isDisposed(): Boolean = disposed
|
actual override fun isDisposed(): Boolean = disposed
|
||||||
|
|
||||||
actual fun dispose() {
|
actual override fun dispose() {
|
||||||
disposed = true
|
disposed = true
|
||||||
exoPlayer.release()
|
exoPlayer.release()
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
|
|||||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.di
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.share.AndroidShareService
|
import dev.krtirtho.spotube.core.share.AndroidShareService
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
@ -29,7 +30,7 @@ import org.koin.dsl.module
|
|||||||
|
|
||||||
actual val platformModules = module {
|
actual val platformModules = module {
|
||||||
single { Paths(get()) }
|
single { Paths(get()) }
|
||||||
single { AudioPlayer(get<Context>()) }
|
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
|
||||||
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
|
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
|
||||||
single<ShareService> { AndroidShareService(get()) }
|
single<ShareService> { AndroidShareService(get()) }
|
||||||
single {
|
single {
|
||||||
|
|||||||
@ -126,6 +126,7 @@
|
|||||||
<string name="settings_plugins_plugin_content_description">%1$s plugin</string>
|
<string name="settings_plugins_plugin_content_description">%1$s plugin</string>
|
||||||
<string name="settings_plugins_clear_content_description">Clear</string>
|
<string name="settings_plugins_clear_content_description">Clear</string>
|
||||||
|
|
||||||
|
<string name="plugin_configure_title">Configure</string>
|
||||||
<string name="plugin_screen_title">Plugin Manager</string>
|
<string name="plugin_screen_title">Plugin Manager</string>
|
||||||
<string name="plugin_error_enter_url">Please enter a URL</string>
|
<string name="plugin_error_enter_url">Please enter a URL</string>
|
||||||
<string name="plugin_error_url_scheme">URL must start with http:// or https://</string>
|
<string name="plugin_error_url_scheme">URL must start with http:// or https://</string>
|
||||||
@ -145,6 +146,8 @@
|
|||||||
<string name="plugin_action_remove">Remove plugin</string>
|
<string name="plugin_action_remove">Remove plugin</string>
|
||||||
<string name="plugin_action_login">Login</string>
|
<string name="plugin_action_login">Login</string>
|
||||||
<string name="plugin_action_logout">Logout</string>
|
<string name="plugin_action_logout">Logout</string>
|
||||||
|
<string name="plugin_section_url_title">Download from URL</string>
|
||||||
|
<string name="plugin_section_file_title">Install from file</string>
|
||||||
|
|
||||||
<string name="plugin_permissions_author_version">• %1$s</string>
|
<string name="plugin_permissions_author_version">• %1$s</string>
|
||||||
<string name="plugin_permissions_requested_title">Permissions requested</string>
|
<string name="plugin_permissions_requested_title">Permissions requested</string>
|
||||||
|
|||||||
@ -52,11 +52,8 @@ enum class PlayerState {
|
|||||||
IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED
|
IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
interface AudioPlayerInterface {
|
||||||
expect class AudioPlayer(context: Any) {
|
// // Playback
|
||||||
val context: Any // Optional context for platform-specific implementations (e.g., Android Context)
|
|
||||||
|
|
||||||
// Playback
|
|
||||||
suspend fun play()
|
suspend fun play()
|
||||||
suspend fun pause()
|
suspend fun pause()
|
||||||
suspend fun stop()
|
suspend fun stop()
|
||||||
@ -65,7 +62,7 @@ expect class AudioPlayer(context: Any) {
|
|||||||
suspend fun shuffle(enabled: Boolean)
|
suspend fun shuffle(enabled: Boolean)
|
||||||
|
|
||||||
// Playlist management
|
// 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 addMediaItem(mediaItem: MediaItem)
|
||||||
suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem)
|
suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem)
|
||||||
suspend fun removeMediaItem(mediaItem: MediaItem)
|
suspend fun removeMediaItem(mediaItem: MediaItem)
|
||||||
@ -95,3 +92,43 @@ expect class AudioPlayer(context: Any) {
|
|||||||
fun isDisposed(): Boolean
|
fun isDisposed(): Boolean
|
||||||
fun dispose() // Clean up resources when done. The player should not be used after this is called.
|
fun dispose() // Clean up resources when done. The player should not be used after this is called.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
|
expect class AudioPlayer(context: Any) : AudioPlayerInterface {
|
||||||
|
val context: Any // Optional context for platform-specific implementations (e.g., Android Context)
|
||||||
|
override suspend fun play()
|
||||||
|
override suspend fun pause()
|
||||||
|
override suspend fun stop()
|
||||||
|
override suspend fun seekTo(position: Duration)
|
||||||
|
override suspend fun loop(state: LoopState)
|
||||||
|
override suspend fun shuffle(enabled: Boolean)
|
||||||
|
override suspend fun load(
|
||||||
|
playlist: List<MediaItem>,
|
||||||
|
autoPlay: Boolean,
|
||||||
|
startPosition: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun addMediaItem(mediaItem: MediaItem)
|
||||||
|
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem)
|
||||||
|
override suspend fun removeMediaItem(mediaItem: MediaItem)
|
||||||
|
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int)
|
||||||
|
override suspend fun skipToNext()
|
||||||
|
override suspend fun skipToPrevious()
|
||||||
|
override suspend fun jumpTo(index: Int)
|
||||||
|
override val playerStateFlow: StateFlow<PlayerState>
|
||||||
|
override val currentMediaItemFlow: StateFlow<MediaItem?>
|
||||||
|
override val playlistFlow: StateFlow<List<MediaItem>>
|
||||||
|
override val durationFlow: StateFlow<Duration>
|
||||||
|
override val positionFlow: StateFlow<Duration>
|
||||||
|
override val bufferingPositionFlow: StateFlow<Duration>
|
||||||
|
override val loopStateFlow: StateFlow<LoopState>
|
||||||
|
override val shuffleModeFlow: StateFlow<Boolean>
|
||||||
|
override val playbackSpeedFlow: StateFlow<Float>
|
||||||
|
override val volumeFlow: StateFlow<Float>
|
||||||
|
override val completionFlow: Flow<Unit>
|
||||||
|
override val errorFlow: Flow<Throwable>
|
||||||
|
override suspend fun setVolume(volume: Float)
|
||||||
|
override suspend fun setPlaybackSpeed(speed: Float)
|
||||||
|
override fun isDisposed(): Boolean
|
||||||
|
override fun dispose()
|
||||||
|
}
|
||||||
|
|||||||
@ -24,12 +24,18 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
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 {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPersistedState(): PersistedQueueState? {
|
override suspend fun getPersistedState(): PersistedQueueState? {
|
||||||
return database.audioPlayerQueueDataStore.data.map { preferences ->
|
return database.audioPlayerQueueDataStore.data.map { preferences ->
|
||||||
val payload = preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY]
|
val payload = preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY]
|
||||||
payload?.let {
|
payload?.let {
|
||||||
@ -39,13 +45,13 @@ class AudioPlayerQueueRepository(private val database: Database) {
|
|||||||
}.first()
|
}.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveState(state: PersistedQueueState) {
|
override suspend fun saveState(state: PersistedQueueState) {
|
||||||
database.audioPlayerQueueDataStore.edit { preferences ->
|
database.audioPlayerQueueDataStore.edit { preferences ->
|
||||||
preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] = json.encodeToString(state)
|
preferences[DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY] = json.encodeToString(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clearState() {
|
override suspend fun clearState() {
|
||||||
database.audioPlayerQueueDataStore.edit { preferences ->
|
database.audioPlayerQueueDataStore.edit { preferences ->
|
||||||
preferences.remove(DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY)
|
preferences.remove(DatabaseKeys.AUDIO_PLAYER_QUEUE_STATE_KEY)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,8 @@ package dev.krtirtho.spotube.core.audioplayer
|
|||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@ -41,10 +40,10 @@ import kotlin.random.Random
|
|||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class DeviceAudioPlayerQueue(
|
class DeviceAudioPlayerQueue(
|
||||||
private val audioPlayer: AudioPlayer,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val settingsViewModel: SettingsViewModel,
|
private val settingsProvider: SettingsProvider,
|
||||||
private val repository: AudioPlayerQueueRepository,
|
private val repository: QueueStateRepository,
|
||||||
private val pluginManager: PluginManager,
|
private val pluginProvider: PluginProvider,
|
||||||
) : AudioPlayerQueue, KoinComponent {
|
) : AudioPlayerQueue, KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
@ -310,7 +309,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
private suspend fun handleQueueCompletion() {
|
private suspend fun handleQueueCompletion() {
|
||||||
if (isFetchingRecommendations) return
|
if (isFetchingRecommendations) return
|
||||||
|
|
||||||
val settings = settingsViewModel.settingsState.value ?: return
|
val settings = settingsProvider.settingsState.value ?: return
|
||||||
if (!settings.enableEndlessPlayback) return
|
if (!settings.enableEndlessPlayback) return
|
||||||
|
|
||||||
val queue = queueFlow.value
|
val queue = queueFlow.value
|
||||||
@ -330,7 +329,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
isFetchingRecommendations = true
|
isFetchingRecommendations = true
|
||||||
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
|
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
|
||||||
|
|
||||||
val metadataService = pluginManager.selectedMetadataPlugin.value ?: run {
|
val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run {
|
||||||
logger.w { "Endless playback: no metadata plugin available" }
|
logger.w { "Endless playback: no metadata plugin available" }
|
||||||
isFetchingRecommendations = false
|
isFetchingRecommendations = false
|
||||||
return
|
return
|
||||||
@ -482,7 +481,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
||||||
val port: Int =
|
val port: Int =
|
||||||
settingsViewModel.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
|
settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
|
||||||
val baseUrl = "http://127.0.0.1:$port"
|
val baseUrl = "http://127.0.0.1:$port"
|
||||||
return when (protocol) {
|
return when (protocol) {
|
||||||
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
|
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
|
||||||
|
|||||||
@ -21,45 +21,49 @@ import co.touchlab.kermit.Logger
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
|
||||||
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
||||||
import dev.krtirtho.spotube.core.db.Database
|
import dev.krtirtho.spotube.core.db.Database
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
|
||||||
import dev.krtirtho.spotube.core.navigation.navigationModule
|
import dev.krtirtho.spotube.core.navigation.navigationModule
|
||||||
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
|
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||||
import dev.krtirtho.spotube.core.server.LocalServer
|
import dev.krtirtho.spotube.core.server.LocalServer
|
||||||
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
||||||
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
||||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumViewModel
|
import dev.krtirtho.spotube.modules.album.AlbumViewModel
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreenViewModel
|
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
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.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
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.album.LibraryAlbumsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsViewModel
|
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.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.library.playlist.LibraryPlaylistsViewModel
|
||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
|
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
import dev.krtirtho.spotube.modules.search.SearchRepository
|
||||||
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
|
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.bind
|
||||||
import org.koin.core.module.dsl.createdAtStart
|
import org.koin.core.module.dsl.createdAtStart
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
@ -100,11 +104,11 @@ val sharedModules = module {
|
|||||||
viewModelOf(::LibraryLocalTracksViewModel)
|
viewModelOf(::LibraryLocalTracksViewModel)
|
||||||
|
|
||||||
// Plugin system
|
// Plugin system
|
||||||
singleOf(::PluginManager)
|
singleOf(::PluginManager) { bind<PluginProvider>() }
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
singleOf(::SettingsRepository)
|
singleOf(::SettingsRepository)
|
||||||
viewModelOf(::SettingsViewModel)
|
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
singleOf(::DownloadManager)
|
singleOf(::DownloadManager)
|
||||||
@ -163,7 +167,7 @@ val sharedModules = module {
|
|||||||
singleOf(::LocalServer) withOptions {
|
singleOf(::LocalServer) withOptions {
|
||||||
createdAtStart()
|
createdAtStart()
|
||||||
}
|
}
|
||||||
singleOf(::AudioPlayerQueueRepository)
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
DeviceAudioPlayerQueue(get(), get(), get(), get())
|
DeviceAudioPlayerQueue(get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AdaptiveDialogBottomSheet(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
title: @Composable (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
breakpointDp: Float = 600f,
|
||||||
|
actions: @Composable (() -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||||
|
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||||
|
|
||||||
|
if (isLargeScreen) {
|
||||||
|
ThemedDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = if (title != null) {
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(Modifier.weight(1f)) { title() }
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.FluentDismiss,
|
||||||
|
contentDescription = "Close dialog",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
dragHandle = null,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
title?.let {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(Modifier.weight(1f)) { it() }
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.FluentDismiss,
|
||||||
|
contentDescription = "Close dialog",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
actions?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
it()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -51,7 +50,6 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
|||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
||||||
@ -319,102 +317,6 @@ fun CollectionDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
|
||||||
@Composable
|
|
||||||
fun CollapsedCollectionHeader(
|
|
||||||
title: String,
|
|
||||||
imageURL: String,
|
|
||||||
imageResource: DrawableResource? = null,
|
|
||||||
isPlaying: Boolean,
|
|
||||||
onPlay: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onBack: (() -> Unit)? = null,
|
|
||||||
sharedElementKey: String? = null,
|
|
||||||
) {
|
|
||||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
|
||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
|
||||||
Card(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
if (onBack != null) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack,
|
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.ArrowLeft3,
|
|
||||||
contentDescription = "Back",
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.sharedElementOrNone(
|
|
||||||
key = sharedElementKey,
|
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
|
||||||
)
|
|
||||||
.shimmerApply(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (imageURL.isNotBlank()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageURL,
|
|
||||||
contentDescription = title,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
} else if (imageResource != null) {
|
|
||||||
androidx.compose.foundation.Image(
|
|
||||||
painter = painterResource(imageResource),
|
|
||||||
contentDescription = title,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextWithShimmer(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onPlay,
|
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@androidx.compose.ui.tooling.preview.Preview
|
@androidx.compose.ui.tooling.preview.Preview
|
||||||
fun CollectionDetailsPreview() {
|
fun CollectionDetailsPreview() {
|
||||||
|
|||||||
@ -0,0 +1,257 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
||||||
|
import org.jetbrains.compose.resources.DrawableResource
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CollectionView(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
imageURL: String,
|
||||||
|
imageResource: DrawableResource? = null,
|
||||||
|
ownerName: String,
|
||||||
|
ownerImageURL: String?,
|
||||||
|
onOwnerClick: () -> Unit,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onShufflePlay: () -> Unit,
|
||||||
|
onAddToQueue: () -> Unit,
|
||||||
|
isPlaying: Boolean = false,
|
||||||
|
isFollowing: Boolean = false,
|
||||||
|
onFollowClick: () -> Unit = {},
|
||||||
|
showFollowButton: Boolean = true,
|
||||||
|
onEdit: (() -> Unit)? = null,
|
||||||
|
sharedElementKey: String? = null,
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
error: String? = null,
|
||||||
|
onRetry: (() -> Unit)? = null,
|
||||||
|
tracks: List<MetadataTrack> = emptyList(),
|
||||||
|
hasMore: Boolean = false,
|
||||||
|
isLoadingNextPage: Boolean = false,
|
||||||
|
currentTrackId: String? = null,
|
||||||
|
isCurrentTrackPlaying: Boolean = false,
|
||||||
|
onTrackClick: (MetadataTrack) -> Unit = {},
|
||||||
|
onLoadNextPage: () -> Unit = {},
|
||||||
|
onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> },
|
||||||
|
onArtistClick: (MetadataArtist.Basic) -> Unit = {},
|
||||||
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {},
|
||||||
|
onArtistsOverflowClick: (MetadataTrack) -> Unit = {},
|
||||||
|
onBulkDownload: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
|
footerContent: (@Composable () -> Unit)? = null,
|
||||||
|
trailingContent: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val isCollapsed by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.firstVisibleItemIndex > 0 ||
|
||||||
|
listState.firstVisibleItemScrollOffset > 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
ApplicationMainBar(
|
||||||
|
backButton = true,
|
||||||
|
title = if (isCollapsed) {
|
||||||
|
{
|
||||||
|
CollapsedCollectionTitle(
|
||||||
|
title = title,
|
||||||
|
imageURL = imageURL,
|
||||||
|
imageResource = imageResource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
{}
|
||||||
|
},
|
||||||
|
actions = if (isCollapsed) {
|
||||||
|
{
|
||||||
|
IconButton(onClick = onPlay) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
{}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
if (error != null && tracks.isEmpty() && !isLoading) {
|
||||||
|
ErrorDisplay(
|
||||||
|
errorMessage = error,
|
||||||
|
onRetry = onRetry ?: {},
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TrackList(
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
state = listState,
|
||||||
|
headerContent = {
|
||||||
|
if (isLoading && tracks.isEmpty()) {
|
||||||
|
dev.krtirtho.spotube.core.ui.misc.SkeletonTree(true) {
|
||||||
|
CollectionDetails(
|
||||||
|
title = title.ifBlank { "Loading..." },
|
||||||
|
description = description,
|
||||||
|
imageURL = imageURL,
|
||||||
|
imageResource = imageResource,
|
||||||
|
ownerName = ownerName.ifBlank { "Unknown" },
|
||||||
|
ownerImageURL = ownerImageURL,
|
||||||
|
onOwnerClick = onOwnerClick,
|
||||||
|
onPlay = onPlay,
|
||||||
|
onShufflePlay = onShufflePlay,
|
||||||
|
onAddToQueue = onAddToQueue,
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
isFollowing = isFollowing,
|
||||||
|
onFollowClick = onFollowClick,
|
||||||
|
showFollowButton = showFollowButton,
|
||||||
|
onEdit = onEdit,
|
||||||
|
sharedElementKey = sharedElementKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CollectionDetails(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
imageURL = imageURL,
|
||||||
|
imageResource = imageResource,
|
||||||
|
ownerName = ownerName,
|
||||||
|
ownerImageURL = ownerImageURL,
|
||||||
|
onOwnerClick = onOwnerClick,
|
||||||
|
onPlay = onPlay,
|
||||||
|
onShufflePlay = onShufflePlay,
|
||||||
|
onAddToQueue = onAddToQueue,
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
isFollowing = isFollowing,
|
||||||
|
onFollowClick = onFollowClick,
|
||||||
|
showFollowButton = showFollowButton,
|
||||||
|
onEdit = onEdit,
|
||||||
|
sharedElementKey = sharedElementKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footerContent = footerContent,
|
||||||
|
tracks = tracks,
|
||||||
|
error = error,
|
||||||
|
hasMore = hasMore,
|
||||||
|
isLoading = isLoading,
|
||||||
|
isLoadingNextPage = isLoadingNextPage,
|
||||||
|
currentTrackId = currentTrackId,
|
||||||
|
isCurrentTrackPlaying = isCurrentTrackPlaying,
|
||||||
|
onTrackClick = onTrackClick,
|
||||||
|
onLoadNextPage = onLoadNextPage,
|
||||||
|
onTrackOptionsAction = onTrackOptionsAction,
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
|
onBulkDownload = onBulkDownload,
|
||||||
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
|
trackOptionsState = trackOptionsState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
trailingContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CollapsedCollectionTitle(
|
||||||
|
title: String,
|
||||||
|
imageURL: String,
|
||||||
|
imageResource: DrawableResource? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.shimmerApply(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (imageURL.isNotBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageURL,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else if (imageResource != null) {
|
||||||
|
androidx.compose.foundation.Image(
|
||||||
|
painter = painterResource(imageResource),
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextWithShimmer(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,11 +18,8 @@
|
|||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@ -154,8 +151,6 @@ fun TrackList(
|
|||||||
simplified: Boolean = false,
|
simplified: Boolean = false,
|
||||||
scrollable: Boolean = true,
|
scrollable: Boolean = true,
|
||||||
state: LazyListState? = null,
|
state: LazyListState? = null,
|
||||||
collapsedHeader: (@Composable () -> Unit)? = null,
|
|
||||||
isCollapsedHeaderShown: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
var filterQuery by rememberSaveable { mutableStateOf("") }
|
||||||
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
||||||
@ -491,17 +486,6 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collapsedHeader != null) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = isCollapsedHeaderShown,
|
|
||||||
enter = fadeIn() + slideInVertically { -it },
|
|
||||||
exit = fadeOut() + slideOutVertically { -it },
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = if (isCompact) 6.dp else 16.dp),
|
|
||||||
) {
|
|
||||||
collapsedHeader()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!useDropdownForOptions) {
|
if (!useDropdownForOptions) {
|
||||||
selectedTrackForOptions?.let { track ->
|
selectedTrackForOptions?.let { track ->
|
||||||
TrackOptionsBottomSheet(
|
TrackOptionsBottomSheet(
|
||||||
|
|||||||
@ -17,18 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.album
|
package dev.krtirtho.spotube.modules.album
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
@ -38,14 +33,9 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackList
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
@ -78,14 +68,6 @@ fun AlbumScreen(albumId: String) {
|
|||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val isCollapsedHeaderShown by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
listState.firstVisibleItemIndex > 0 ||
|
|
||||||
listState.firstVisibleItemScrollOffset > 400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
currentUserId = libraryRepository.currentUser()?.id
|
currentUserId = libraryRepository.currentUser()?.id
|
||||||
}
|
}
|
||||||
@ -120,143 +102,77 @@ fun AlbumScreen(albumId: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
val dataState = state as? AlbumScreenState.Data
|
||||||
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
|
val album = dataState?.album
|
||||||
) { innerPadding ->
|
val ownerName =
|
||||||
when (state) {
|
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
|
||||||
is AlbumScreenState.Loading -> {
|
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
|
||||||
TrackList(
|
val firstArtistId = album?.artists?.firstOrNull()?.id
|
||||||
modifier = Modifier.padding(innerPadding),
|
val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty()
|
||||||
headerContent = {
|
val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
|
||||||
SkeletonTree(true) {
|
playerState == PlayerState.PLAYING
|
||||||
CollectionDetails(
|
|
||||||
title = "Loading album...",
|
val errorMessage = (state as? AlbumScreenState.Error)?.message
|
||||||
description = "",
|
val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
|
||||||
imageURL = "",
|
|
||||||
ownerName = "Unknown artist",
|
CollectionView(
|
||||||
ownerImageURL = null,
|
title = album?.title ?: "Loading album...",
|
||||||
onOwnerClick = {},
|
description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}",
|
||||||
onPlay = {},
|
imageURL = artworkUrl,
|
||||||
onShufflePlay = {},
|
ownerName = ownerName,
|
||||||
onAddToQueue = {},
|
ownerImageURL = ownerImageURL,
|
||||||
isPlaying = false,
|
onOwnerClick = {
|
||||||
isFollowing = false,
|
firstArtistId?.let {
|
||||||
onFollowClick = {},
|
navigationCommands.navigateTo(Routes.Artist(it))
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tracks = emptyList(),
|
|
||||||
error = null,
|
|
||||||
hasMore = false,
|
|
||||||
isLoading = true,
|
|
||||||
isLoadingNextPage = false,
|
|
||||||
currentTrackId = null,
|
|
||||||
isCurrentTrackPlaying = false,
|
|
||||||
onTrackClick = {},
|
|
||||||
onLoadNextPage = {},
|
|
||||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
|
||||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
|
||||||
onTrackOptionsAction = { _, _ -> },
|
|
||||||
trackOptionsState = { TrackOptionsState() },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
is AlbumScreenState.Error -> {
|
onPlay = viewModel::playAlbum,
|
||||||
ErrorDisplay(
|
onShufflePlay = {},
|
||||||
errorMessage = (state as AlbumScreenState.Error).message,
|
onAddToQueue = viewModel::addAlbumToQueue,
|
||||||
onRetry = { viewModel.refresh() },
|
isPlaying = isAlbumPlaying,
|
||||||
modifier = Modifier.padding(innerPadding),
|
isFollowing = savedAlbumIds.contains(albumId),
|
||||||
)
|
onFollowClick = viewModel::toggleSavedAlbum,
|
||||||
}
|
isLoading = isLoading,
|
||||||
|
error = errorMessage,
|
||||||
is AlbumScreenState.Data -> {
|
onRetry = { viewModel.refresh() },
|
||||||
val dataState = state as AlbumScreenState.Data
|
tracks = dataState?.tracks ?: emptyList(),
|
||||||
val album = dataState.album
|
hasMore = dataState?.nextPagination != null,
|
||||||
val ownerName =
|
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
|
||||||
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
|
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||||
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
|
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||||
val firstArtistId = album?.artists?.firstOrNull()?.id
|
onTrackClick = viewModel::playAlbumFromTrack,
|
||||||
val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty()
|
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||||
val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
|
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||||
playerState == PlayerState.PLAYING
|
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||||
|
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||||
TrackList(
|
trackOptionsState = ::getTrackOptionsState,
|
||||||
modifier = Modifier.padding(innerPadding),
|
onBulkDownload = { tracks ->
|
||||||
state = listState,
|
downloadsViewModel.downloadTracks(tracks)
|
||||||
collapsedHeader = {
|
},
|
||||||
CollapsedCollectionHeader(
|
onBulkAddToQueue = { tracks ->
|
||||||
title = album?.title ?: "Album",
|
viewModel.addTracksToQueue(tracks)
|
||||||
imageURL = artworkUrl,
|
},
|
||||||
isPlaying = isAlbumPlaying,
|
onBulkPlayNext = { tracks ->
|
||||||
onPlay = viewModel::playAlbum,
|
viewModel.playTracksNext(tracks)
|
||||||
onBack = { navigationCommands.pop() },
|
},
|
||||||
|
onBulkAddToPlaylist = { tracks ->
|
||||||
|
tracksToAddToPlaylist = tracks
|
||||||
|
showAddToPlaylistPicker = true
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
AddToPlaylistPicker(
|
||||||
|
visible = showAddToPlaylistPicker,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
onDismiss = { showAddToPlaylistPicker = false },
|
||||||
|
onPlaylistSelected = { playlistId ->
|
||||||
|
scope.launch {
|
||||||
|
libraryRepository.addTracksToPlaylist(
|
||||||
|
playlistId,
|
||||||
|
tracksToAddToPlaylist.map { it.id }
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
isCollapsedHeaderShown = isCollapsedHeaderShown,
|
},
|
||||||
headerContent = {
|
)
|
||||||
CollectionDetails(
|
},
|
||||||
title = album?.title ?: "Loading album...",
|
)
|
||||||
description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}",
|
|
||||||
imageURL = artworkUrl,
|
|
||||||
ownerName = ownerName,
|
|
||||||
ownerImageURL = ownerImageURL,
|
|
||||||
onOwnerClick = {
|
|
||||||
firstArtistId?.let {
|
|
||||||
navigationCommands.navigateTo(
|
|
||||||
Routes.Artist(it)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPlay = viewModel::playAlbum,
|
|
||||||
onShufflePlay = {},
|
|
||||||
onAddToQueue = viewModel::addAlbumToQueue,
|
|
||||||
isPlaying = isAlbumPlaying,
|
|
||||||
isFollowing = savedAlbumIds.contains(albumId),
|
|
||||||
onFollowClick = viewModel::toggleSavedAlbum,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
tracks = dataState.tracks,
|
|
||||||
error = null,
|
|
||||||
hasMore = dataState.nextPagination != null,
|
|
||||||
isLoading = state is AlbumScreenState.Loading && dataState.tracks.isEmpty(),
|
|
||||||
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
|
|
||||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
|
||||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
|
||||||
onTrackClick = viewModel::playAlbumFromTrack,
|
|
||||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
|
||||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
|
||||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
|
||||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
|
||||||
onBulkDownload = { tracks ->
|
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
|
||||||
},
|
|
||||||
onBulkAddToQueue = { tracks ->
|
|
||||||
viewModel.addTracksToQueue(tracks)
|
|
||||||
},
|
|
||||||
onBulkPlayNext = { tracks ->
|
|
||||||
viewModel.playTracksNext(tracks)
|
|
||||||
},
|
|
||||||
onBulkAddToPlaylist = { tracks ->
|
|
||||||
tracksToAddToPlaylist = tracks
|
|
||||||
showAddToPlaylistPicker = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AddToPlaylistPicker(
|
|
||||||
visible = showAddToPlaylistPicker,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
onDismiss = { showAddToPlaylistPicker = false },
|
|
||||||
onPlaylistSelected = { playlistId ->
|
|
||||||
scope.launch {
|
|
||||||
libraryRepository.addTracksToPlaylist(
|
|
||||||
playlistId,
|
|
||||||
tracksToAddToPlaylist.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,6 @@ package dev.krtirtho.spotube.modules.library
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -34,19 +32,17 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
|
||||||
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
|
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
|
||||||
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
|
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
|
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
|
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
|
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@ -94,11 +90,12 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
items(LibraryTab.entries.size) { index ->
|
items(LibraryTab.entries.size) { index ->
|
||||||
val tab = LibraryTab.entries[index]
|
val tab = LibraryTab.entries[index]
|
||||||
FilterChip(
|
ChipTab(
|
||||||
label = { Text(tab.title) },
|
|
||||||
selected = currentTab == tab,
|
selected = currentTab == tab,
|
||||||
onClick = { libraryState.onTabSelected(tab) },
|
onClick = { libraryState.onTabSelected(tab) },
|
||||||
)
|
) {
|
||||||
|
Text(tab.title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|||||||
@ -20,7 +20,7 @@ package dev.krtirtho.spotube.modules.lyrics
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
@ -50,7 +50,7 @@ data class LyricsUiState(
|
|||||||
|
|
||||||
class LyricsViewModel(
|
class LyricsViewModel(
|
||||||
private val pluginManager: PluginManager,
|
private val pluginManager: PluginManager,
|
||||||
audioPlayer: AudioPlayer,
|
audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|||||||
@ -21,12 +21,9 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -44,20 +41,14 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
|||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackList
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
|
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
|
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
|
||||||
import dev.krtirtho.spotube.modules.playlist.AddTracksToPlaylistDialog
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -90,14 +81,6 @@ fun PlaylistScreen(playlistId: String) {
|
|||||||
var showAddTracksDialog by remember { mutableStateOf(false) }
|
var showAddTracksDialog by remember { mutableStateOf(false) }
|
||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val isCollapsedHeaderShown by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
listState.firstVisibleItemIndex > 0 ||
|
|
||||||
listState.firstVisibleItemScrollOffset > 400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||||
viewModel.handleTrackOptionsAction(track, action)
|
viewModel.handleTrackOptionsAction(track, action)
|
||||||
if (action is TrackOptionsAction.Share) {
|
if (action is TrackOptionsAction.Share) {
|
||||||
@ -115,209 +98,144 @@ fun PlaylistScreen(playlistId: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
val dataState = state as? PlaylistScreenState.Data
|
||||||
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
|
val playlist = dataState?.playlist
|
||||||
) { innerPadding ->
|
val owner = playlist?.owner
|
||||||
when (state) {
|
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
|
||||||
is PlaylistScreenState.Loading -> {
|
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
|
||||||
TrackList(
|
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
|
||||||
modifier = Modifier.padding(innerPadding),
|
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
|
||||||
headerContent = {
|
val isPlaying = currentCollectionEntry?.id == playlistId &&
|
||||||
SkeletonTree(true){
|
playerState == PlayerState.PLAYING
|
||||||
CollectionDetails(
|
|
||||||
title = "Loading playlist...",
|
|
||||||
description = "",
|
|
||||||
imageURL = "",
|
|
||||||
ownerName = "Unknown",
|
|
||||||
ownerImageURL = null,
|
|
||||||
onOwnerClick = {},
|
|
||||||
onPlay = {},
|
|
||||||
onShufflePlay = {},
|
|
||||||
onAddToQueue = {},
|
|
||||||
isPlaying = false,
|
|
||||||
isFollowing = false,
|
|
||||||
onFollowClick = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tracks = emptyList(),
|
|
||||||
error = null,
|
|
||||||
hasMore = false,
|
|
||||||
isLoading = true,
|
|
||||||
isLoadingNextPage = false,
|
|
||||||
currentTrackId = null,
|
|
||||||
isCurrentTrackPlaying = false,
|
|
||||||
onTrackClick = {},
|
|
||||||
onLoadNextPage = {},
|
|
||||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
|
||||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
|
||||||
onTrackOptionsAction = { _, _ -> },
|
|
||||||
trackOptionsState = { TrackOptionsState() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlaylistScreenState.Error -> {
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
ErrorDisplay(
|
|
||||||
errorMessage = (state as PlaylistScreenState.Error).message,
|
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlaylistScreenState.Data -> {
|
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||||
val dataState = state as PlaylistScreenState.Data
|
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
val playlist = dataState.playlist
|
val queueTrackIds = queue.mapNotNull { entry ->
|
||||||
val owner = playlist?.owner
|
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
|
}.toSet()
|
||||||
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
|
return TrackOptionsState(
|
||||||
|
isInQueue = queueTrackIds.contains(track.id),
|
||||||
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
|
isFavorite = savedTrackIds.contains(track.id),
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
isBlacklisted = false,
|
||||||
|
|
||||||
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
|
|
||||||
val isPlaying = currentCollectionEntry?.id == playlistId &&
|
|
||||||
playerState == PlayerState.PLAYING
|
|
||||||
|
|
||||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
|
||||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
|
||||||
val queueTrackIds = queue.mapNotNull { entry ->
|
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
|
||||||
}.toSet()
|
|
||||||
return TrackOptionsState(
|
|
||||||
isInQueue = queueTrackIds.contains(track.id),
|
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
|
||||||
isFavorite = savedTrackIds.contains(track.id),
|
|
||||||
isBlacklisted = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TrackList(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
state = listState,
|
|
||||||
collapsedHeader = {
|
|
||||||
CollapsedCollectionHeader(
|
|
||||||
title = playlist?.title ?: "Playlist",
|
|
||||||
imageURL = artworkUrl,
|
|
||||||
isPlaying = isPlaying,
|
|
||||||
onPlay = viewModel::playPlaylist,
|
|
||||||
onBack = { navigationCommands.pop() },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
isCollapsedHeaderShown = isCollapsedHeaderShown,
|
|
||||||
headerContent = {
|
|
||||||
CollectionDetails(
|
|
||||||
title = playlist?.title ?: "Loading playlist...",
|
|
||||||
description = playlist?.description.orEmpty(),
|
|
||||||
imageURL = artworkUrl,
|
|
||||||
ownerName = ownerName,
|
|
||||||
ownerImageURL = ownerImageURL,
|
|
||||||
onOwnerClick = {},
|
|
||||||
onPlay = viewModel::playPlaylist,
|
|
||||||
onShufflePlay = {},
|
|
||||||
onAddToQueue = viewModel::addPlaylistToQueue,
|
|
||||||
isPlaying = isPlaying,
|
|
||||||
isFollowing = savedPlaylistIds.contains(playlistId),
|
|
||||||
onFollowClick = viewModel::toggleSavedPlaylist,
|
|
||||||
showFollowButton = playlistId != "saved_tracks",
|
|
||||||
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
footerContent = if (isOwner) {
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
OutlineButton(onClick = { showAddTracksDialog = true }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
Text("Add Tracks", modifier = Modifier.padding(start = 8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else null,
|
|
||||||
tracks = dataState.tracks,
|
|
||||||
error = null,
|
|
||||||
hasMore = dataState.nextPagination != null,
|
|
||||||
isLoading = state is PlaylistScreenState.Loading && dataState.tracks.isEmpty(),
|
|
||||||
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
|
|
||||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
|
||||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
|
||||||
onTrackClick = viewModel::playPlaylistFromTrack,
|
|
||||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
|
||||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
|
||||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
|
||||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
|
||||||
onBulkDownload = { tracks ->
|
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
|
||||||
},
|
|
||||||
onBulkAddToQueue = { tracks ->
|
|
||||||
viewModel.addTracksToQueue(tracks)
|
|
||||||
},
|
|
||||||
onBulkPlayNext = { tracks ->
|
|
||||||
viewModel.playTracksNext(tracks)
|
|
||||||
},
|
|
||||||
onBulkAddToPlaylist = { tracks ->
|
|
||||||
tracksToAddToPlaylist = tracks
|
|
||||||
showAddToPlaylistPicker = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val dataState = state as? PlaylistScreenState.Data
|
|
||||||
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
|
||||||
PlaylistFormSheet(
|
|
||||||
visible = showEditPlaylist,
|
|
||||||
onDismiss = { showEditPlaylist = false },
|
|
||||||
isEditing = true,
|
|
||||||
initialData = playlist?.let {
|
|
||||||
PlaylistFormData(
|
|
||||||
name = it.title,
|
|
||||||
description = it.description.orEmpty(),
|
|
||||||
isPublic = true,
|
|
||||||
isCollaborating = false,
|
|
||||||
imagePreviewUrl = it.thumbnails.firstOrNull()?.url,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onSubmit = { data ->
|
|
||||||
viewModel.updatePlaylist(
|
|
||||||
name = data.name,
|
|
||||||
description = data.description.ifBlank { null },
|
|
||||||
isPublic = data.isPublic,
|
|
||||||
isCollaborating = data.isCollaborating,
|
|
||||||
imageBase64 = data.imageBase64.ifBlank { null },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
AddToPlaylistPicker(
|
|
||||||
visible = showAddToPlaylistPicker,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
onDismiss = { showAddToPlaylistPicker = false },
|
|
||||||
onPlaylistSelected = { selectedPlaylistId ->
|
|
||||||
scope.launch {
|
|
||||||
libraryRepository.addTracksToPlaylist(
|
|
||||||
selectedPlaylistId,
|
|
||||||
tracksToAddToPlaylist.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
AddTracksToPlaylistDialog(
|
|
||||||
visible = showAddTracksDialog,
|
|
||||||
playlistId = playlistId,
|
|
||||||
onDismiss = {
|
|
||||||
showAddTracksDialog = false
|
|
||||||
viewModel.refresh()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val errorMessage = (state as? PlaylistScreenState.Error)?.message
|
||||||
|
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
|
||||||
|
|
||||||
|
val footerContent: (@Composable () -> Unit)? = if (isOwner && dataState != null) {
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
OutlineButton(onClick = { showAddTracksDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text("Add Tracks", modifier = Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
CollectionView(
|
||||||
|
title = playlist?.title ?: "Loading playlist...",
|
||||||
|
description = playlist?.description.orEmpty(),
|
||||||
|
imageURL = artworkUrl,
|
||||||
|
ownerName = ownerName,
|
||||||
|
ownerImageURL = ownerImageURL,
|
||||||
|
onOwnerClick = {},
|
||||||
|
onPlay = viewModel::playPlaylist,
|
||||||
|
onShufflePlay = {},
|
||||||
|
onAddToQueue = viewModel::addPlaylistToQueue,
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
isFollowing = savedPlaylistIds.contains(playlistId),
|
||||||
|
onFollowClick = viewModel::toggleSavedPlaylist,
|
||||||
|
showFollowButton = playlistId != "saved_tracks",
|
||||||
|
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
|
||||||
|
isLoading = isLoading,
|
||||||
|
error = errorMessage,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
tracks = dataState?.tracks ?: emptyList(),
|
||||||
|
hasMore = dataState?.nextPagination != null,
|
||||||
|
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
|
||||||
|
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||||
|
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||||
|
onTrackClick = viewModel::playPlaylistFromTrack,
|
||||||
|
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||||
|
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||||
|
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||||
|
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||||
|
trackOptionsState = ::getTrackOptionsState,
|
||||||
|
onBulkDownload = { tracks ->
|
||||||
|
downloadsViewModel.downloadTracks(tracks)
|
||||||
|
},
|
||||||
|
onBulkAddToQueue = { tracks ->
|
||||||
|
viewModel.addTracksToQueue(tracks)
|
||||||
|
},
|
||||||
|
onBulkPlayNext = { tracks ->
|
||||||
|
viewModel.playTracksNext(tracks)
|
||||||
|
},
|
||||||
|
onBulkAddToPlaylist = { tracks ->
|
||||||
|
tracksToAddToPlaylist = tracks
|
||||||
|
showAddToPlaylistPicker = true
|
||||||
|
},
|
||||||
|
footerContent = footerContent,
|
||||||
|
trailingContent = {
|
||||||
|
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||||
|
PlaylistFormSheet(
|
||||||
|
visible = showEditPlaylist,
|
||||||
|
onDismiss = { showEditPlaylist = false },
|
||||||
|
isEditing = true,
|
||||||
|
initialData = loadedPlaylist?.let {
|
||||||
|
PlaylistFormData(
|
||||||
|
name = it.title,
|
||||||
|
description = it.description.orEmpty(),
|
||||||
|
isPublic = true,
|
||||||
|
isCollaborating = false,
|
||||||
|
imagePreviewUrl = it.thumbnails.firstOrNull()?.url,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSubmit = { data ->
|
||||||
|
viewModel.updatePlaylist(
|
||||||
|
name = data.name,
|
||||||
|
description = data.description.ifBlank { null },
|
||||||
|
isPublic = data.isPublic,
|
||||||
|
isCollaborating = data.isCollaborating,
|
||||||
|
imageBase64 = data.imageBase64.ifBlank { null },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AddToPlaylistPicker(
|
||||||
|
visible = showAddToPlaylistPicker,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
onDismiss = { showAddToPlaylistPicker = false },
|
||||||
|
onPlaylistSelected = { selectedPlaylistId ->
|
||||||
|
scope.launch {
|
||||||
|
libraryRepository.addTracksToPlaylist(
|
||||||
|
selectedPlaylistId,
|
||||||
|
tracksToAddToPlaylist.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AddTracksToPlaylistDialog(
|
||||||
|
visible = showAddTracksDialog,
|
||||||
|
playlistId = playlistId,
|
||||||
|
onDismiss = {
|
||||||
|
showAddTracksDialog = false
|
||||||
|
viewModel.refresh()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,15 +62,18 @@ import okio.SYSTEM
|
|||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.use
|
import okio.use
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import kotlin.collections.set
|
|
||||||
import kotlin.getValue
|
|
||||||
|
|
||||||
const val PLUGIN_API_VERSION = "0.0.1"
|
const val PLUGIN_API_VERSION = "0.0.1"
|
||||||
|
|
||||||
|
interface PluginProvider {
|
||||||
|
val selectedMetadataPlugin: StateFlow<PluginService?>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PluginManager(
|
class PluginManager(
|
||||||
val database: Database,
|
val database: Database,
|
||||||
val paths: Paths,
|
val paths: Paths,
|
||||||
) : KoinComponent {
|
) : KoinComponent, PluginProvider {
|
||||||
private val logger by injectLogger<PluginManager>()
|
private val logger by injectLogger<PluginManager>()
|
||||||
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
|
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
|
||||||
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
|
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
|
||||||
@ -239,7 +242,7 @@ class PluginManager(
|
|||||||
val lyricsPlugins = filterPluginByType(PluginAbility.LYRICS)
|
val lyricsPlugins = filterPluginByType(PluginAbility.LYRICS)
|
||||||
val scrobblePlugins = filterPluginByType(PluginAbility.SCROBBLE)
|
val scrobblePlugins = filterPluginByType(PluginAbility.SCROBBLE)
|
||||||
|
|
||||||
val selectedMetadataPlugin =
|
override val selectedMetadataPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.METADATA)
|
filterSelectedPluginByType(PluginAbility.METADATA)
|
||||||
val selectedAudioPlugin =
|
val selectedAudioPlugin =
|
||||||
filterSelectedPluginByType(PluginAbility.AUDIO)
|
filterSelectedPluginByType(PluginAbility.AUDIO)
|
||||||
|
|||||||
@ -22,10 +22,12 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@ -51,55 +53,65 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
|
||||||
import dev.krtirtho.spotube.PlatformType
|
import dev.krtirtho.spotube.PlatformType
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.Card
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
|
||||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
|
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.modules.plugin.components.InstallSection
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
|
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
|
||||||
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
|
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
|
||||||
import io.github.vinceglb.filekit.dialogs.FileKitType
|
import io.github.vinceglb.filekit.dialogs.FileKitType
|
||||||
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
|
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
|
||||||
import io.github.vinceglb.filekit.readBytes
|
import io.github.vinceglb.filekit.readBytes
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.plugin_action_download
|
||||||
|
import spotube.composeapp.generated.resources.plugin_action_install_from_file
|
||||||
|
import spotube.composeapp.generated.resources.plugin_configure_title
|
||||||
import spotube.composeapp.generated.resources.plugin_empty_subtitle
|
import spotube.composeapp.generated.resources.plugin_empty_subtitle
|
||||||
import spotube.composeapp.generated.resources.plugin_empty_title
|
import spotube.composeapp.generated.resources.plugin_empty_title
|
||||||
import spotube.composeapp.generated.resources.plugin_error_download_failed
|
import spotube.composeapp.generated.resources.plugin_error_download_failed
|
||||||
import spotube.composeapp.generated.resources.plugin_error_enter_url
|
import spotube.composeapp.generated.resources.plugin_error_enter_url
|
||||||
import spotube.composeapp.generated.resources.plugin_error_url_scheme
|
import spotube.composeapp.generated.resources.plugin_error_url_scheme
|
||||||
|
import spotube.composeapp.generated.resources.plugin_install_section_title
|
||||||
import spotube.composeapp.generated.resources.plugin_installed_count
|
import spotube.composeapp.generated.resources.plugin_installed_count
|
||||||
import spotube.composeapp.generated.resources.plugin_installed_plural
|
import spotube.composeapp.generated.resources.plugin_installed_plural
|
||||||
import spotube.composeapp.generated.resources.plugin_installed_singular
|
import spotube.composeapp.generated.resources.plugin_installed_singular
|
||||||
import spotube.composeapp.generated.resources.plugin_screen_title
|
import spotube.composeapp.generated.resources.plugin_screen_title
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_action_change
|
import spotube.composeapp.generated.resources.plugin_section_file_title
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_action_select
|
import spotube.composeapp.generated.resources.plugin_section_url_title
|
||||||
|
import spotube.composeapp.generated.resources.plugin_url_placeholder
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_clear
|
import spotube.composeapp.generated.resources.settings_plugins_action_change
|
||||||
|
import spotube.composeapp.generated.resources.settings_plugins_action_select
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
|
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_manage_title
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_no_plugins
|
import spotube.composeapp.generated.resources.settings_plugins_no_plugins
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_no_selection
|
import spotube.composeapp.generated.resources.settings_plugins_no_selection
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
|
import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
|
||||||
@ -120,6 +132,7 @@ fun PluginScreen(
|
|||||||
var urlInput by remember { mutableStateOf("") }
|
var urlInput by remember { mutableStateOf("") }
|
||||||
var urlError by remember { mutableStateOf<String?>(null) }
|
var urlError by remember { mutableStateOf<String?>(null) }
|
||||||
var isLoadingUrl by remember { mutableStateOf(false) }
|
var isLoadingUrl by remember { mutableStateOf(false) }
|
||||||
|
var showInstallSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
|
val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
|
||||||
val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
|
val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
|
||||||
@ -175,6 +188,100 @@ fun PluginScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showInstallSheet) {
|
||||||
|
AdaptiveDialogBottomSheet(
|
||||||
|
onDismiss = { showInstallSheet = false },
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.IconsaxImportArrow2Bulk,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.plugin_install_section_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.plugin_section_url_title),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = urlInput,
|
||||||
|
onValueChange = { urlInput = it; urlError = null },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.plugin_url_placeholder),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Iconsax.IconsaxLink,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isError = urlError != null,
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
SecondaryIconButton(
|
||||||
|
onClick = { submitUrl() },
|
||||||
|
enabled = !isLoadingUrl,
|
||||||
|
) {
|
||||||
|
if (isLoadingUrl) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Iconsax.IconsaxImportArrow2Bulk,
|
||||||
|
contentDescription = stringResource(Res.string.plugin_action_download),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.plugin_section_file_title),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
OutlineButton(
|
||||||
|
onClick = { launcher.launch() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.IconsaxExportArrowBulk,
|
||||||
|
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(Res.string.plugin_action_install_from_file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||||
@ -205,16 +312,28 @@ fun PluginScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// ── Install section ───────────────────────────────────
|
// ── Configure header ──────────────────────────────
|
||||||
item {
|
item {
|
||||||
InstallSection(
|
Row(
|
||||||
urlInput = urlInput,
|
modifier = Modifier
|
||||||
onUrlChange = { urlInput = it; urlError = null },
|
.fillMaxWidth()
|
||||||
urlError = urlError,
|
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
isLoadingUrl = isLoadingUrl,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
onSubmitUrl = { submitUrl() },
|
verticalAlignment = Alignment.CenterVertically
|
||||||
onPickFile = { launcher.launch() }
|
) {
|
||||||
)
|
Text(
|
||||||
|
stringResource(Res.string.plugin_configure_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
PrimaryButton(onClick = { showInstallSheet = true }) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.IconsaxAdd,
|
||||||
|
contentDescription = "Install a plugin",
|
||||||
|
)
|
||||||
|
Text(stringResource(Res.string.plugin_install_section_title))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default ability plugin selectors ─────────────────
|
// ── Default ability plugin selectors ─────────────────
|
||||||
|
|||||||
@ -1,151 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.plugin.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
|
||||||
import spotube.composeapp.generated.resources.plugin_action_download
|
|
||||||
import spotube.composeapp.generated.resources.plugin_action_install_from_file
|
|
||||||
import spotube.composeapp.generated.resources.plugin_install_section_title
|
|
||||||
import spotube.composeapp.generated.resources.plugin_url_placeholder
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun InstallSection(
|
|
||||||
urlInput: String,
|
|
||||||
onUrlChange: (String) -> Unit,
|
|
||||||
urlError: String?,
|
|
||||||
isLoadingUrl: Boolean,
|
|
||||||
onSubmitUrl: () -> Unit,
|
|
||||||
onPickFile: () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.IconsaxImportArrow2Bulk,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(Res.string.plugin_install_section_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL row
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = urlInput,
|
|
||||||
onValueChange = onUrlChange,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
stringResource(Res.string.plugin_url_placeholder),
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
Iconsax.IconsaxLink,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
isError = urlError != null,
|
|
||||||
// supportingText = urlError?.let { { Text(it) } },
|
|
||||||
// shape = RoundedCornerShape(10.dp),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
SecondaryIconButton(
|
|
||||||
onClick = onSubmitUrl,
|
|
||||||
enabled = !isLoadingUrl,
|
|
||||||
) {
|
|
||||||
if (isLoadingUrl) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
Iconsax.IconsaxImportArrow2Bulk,
|
|
||||||
contentDescription = stringResource(Res.string.plugin_action_download),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
|
||||||
|
|
||||||
// File picker
|
|
||||||
OutlineButton(
|
|
||||||
onClick = onPickFile,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.IconsaxExportArrowBulk,
|
|
||||||
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(Res.string.plugin_action_install_from_file))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,18 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.saved_tracks
|
package dev.krtirtho.spotube.modules.saved_tracks
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
@ -39,19 +34,13 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackList
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.DrawableResource
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@ -80,14 +69,6 @@ fun SavedTracksScreen() {
|
|||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val isCollapsedHeaderShown by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
listState.firstVisibleItemIndex > 0 ||
|
|
||||||
listState.firstVisibleItemScrollOffset > 400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
currentUserId = libraryRepository.currentUser()?.id
|
currentUserId = libraryRepository.currentUser()?.id
|
||||||
}
|
}
|
||||||
@ -122,137 +103,69 @@ fun SavedTracksScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
val dataState = state as? SavedTracksScreenState.Data
|
||||||
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
|
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
|
||||||
) { innerPadding ->
|
playerState == PlayerState.PLAYING
|
||||||
when (state) {
|
|
||||||
is SavedTracksScreenState.Loading -> {
|
|
||||||
TrackList(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
headerContent = {
|
|
||||||
SkeletonTree(true) {
|
|
||||||
CollectionDetails(
|
|
||||||
title = "Loading saved tracks...",
|
|
||||||
description = "",
|
|
||||||
imageURL = "",
|
|
||||||
imageResource = Res.drawable.liked_tracks,
|
|
||||||
ownerName = "You",
|
|
||||||
ownerImageURL = null,
|
|
||||||
onOwnerClick = {},
|
|
||||||
onPlay = {},
|
|
||||||
onShufflePlay = {},
|
|
||||||
onAddToQueue = {},
|
|
||||||
isPlaying = false,
|
|
||||||
isFollowing = false,
|
|
||||||
onFollowClick = {},
|
|
||||||
showFollowButton = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tracks = emptyList(),
|
|
||||||
error = null,
|
|
||||||
hasMore = false,
|
|
||||||
isLoading = true,
|
|
||||||
isLoadingNextPage = false,
|
|
||||||
currentTrackId = null,
|
|
||||||
isCurrentTrackPlaying = false,
|
|
||||||
onTrackClick = {},
|
|
||||||
onLoadNextPage = {},
|
|
||||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
|
||||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
|
||||||
onTrackOptionsAction = { _, _ -> },
|
|
||||||
trackOptionsState = { TrackOptionsState() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SavedTracksScreenState.Error -> {
|
val errorMessage = (state as? SavedTracksScreenState.Error)?.message
|
||||||
ErrorDisplay(
|
val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
|
||||||
errorMessage = (state as SavedTracksScreenState.Error).message,
|
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SavedTracksScreenState.Data -> {
|
CollectionView(
|
||||||
val dataState = state as SavedTracksScreenState.Data
|
title = "Saved Tracks",
|
||||||
|
description = dataState?.let { "${it.totalCount} tracks" } ?: "",
|
||||||
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
|
imageURL = "",
|
||||||
playerState == PlayerState.PLAYING
|
imageResource = Res.drawable.liked_tracks,
|
||||||
|
ownerName = "You",
|
||||||
TrackList(
|
ownerImageURL = null,
|
||||||
modifier = Modifier.padding(innerPadding),
|
onOwnerClick = {},
|
||||||
state = listState,
|
onPlay = viewModel::playSavedTracks,
|
||||||
collapsedHeader = {
|
onShufflePlay = {},
|
||||||
CollapsedCollectionHeader(
|
onAddToQueue = viewModel::addSavedTracksToQueue,
|
||||||
title = "Saved Tracks",
|
isPlaying = isPlaying,
|
||||||
imageURL = "",
|
isFollowing = false,
|
||||||
imageResource = Res.drawable.liked_tracks,
|
onFollowClick = {},
|
||||||
isPlaying = isPlaying,
|
showFollowButton = false,
|
||||||
onPlay = viewModel::playSavedTracks,
|
isLoading = isLoading,
|
||||||
onBack = { navigationCommands.pop() },
|
error = errorMessage,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
tracks = dataState?.tracks ?: emptyList(),
|
||||||
|
hasMore = dataState?.nextPagination != null,
|
||||||
|
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
|
||||||
|
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||||
|
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||||
|
onTrackClick = viewModel::playSavedTracksFromTrack,
|
||||||
|
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||||
|
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||||
|
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||||
|
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||||
|
trackOptionsState = ::getTrackOptionsState,
|
||||||
|
onBulkDownload = { tracks ->
|
||||||
|
downloadsViewModel.downloadTracks(tracks)
|
||||||
|
},
|
||||||
|
onBulkAddToQueue = { tracks ->
|
||||||
|
viewModel.addTracksToQueue(tracks)
|
||||||
|
},
|
||||||
|
onBulkPlayNext = { tracks ->
|
||||||
|
viewModel.playTracksNext(tracks)
|
||||||
|
},
|
||||||
|
onBulkAddToPlaylist = { tracks ->
|
||||||
|
tracksToAddToPlaylist = tracks
|
||||||
|
showAddToPlaylistPicker = true
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
AddToPlaylistPicker(
|
||||||
|
visible = showAddToPlaylistPicker,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
onDismiss = { showAddToPlaylistPicker = false },
|
||||||
|
onPlaylistSelected = { playlistId ->
|
||||||
|
scope.launch {
|
||||||
|
libraryRepository.addTracksToPlaylist(
|
||||||
|
playlistId,
|
||||||
|
tracksToAddToPlaylist.map { it.id }
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
isCollapsedHeaderShown = isCollapsedHeaderShown,
|
},
|
||||||
headerContent = {
|
)
|
||||||
CollectionDetails(
|
},
|
||||||
title = "Saved Tracks",
|
)
|
||||||
description = "${dataState.totalCount} tracks",
|
|
||||||
imageURL = "",
|
|
||||||
imageResource = Res.drawable.liked_tracks,
|
|
||||||
ownerName = "You",
|
|
||||||
ownerImageURL = null,
|
|
||||||
onOwnerClick = {},
|
|
||||||
onPlay = viewModel::playSavedTracks,
|
|
||||||
onShufflePlay = {},
|
|
||||||
onAddToQueue = viewModel::addSavedTracksToQueue,
|
|
||||||
isPlaying = isPlaying,
|
|
||||||
isFollowing = false,
|
|
||||||
onFollowClick = {},
|
|
||||||
showFollowButton = false,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
tracks = dataState.tracks,
|
|
||||||
error = null,
|
|
||||||
hasMore = dataState.nextPagination != null,
|
|
||||||
isLoading = state is SavedTracksScreenState.Loading && dataState.tracks.isEmpty(),
|
|
||||||
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
|
|
||||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
|
||||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
|
||||||
onTrackClick = viewModel::playSavedTracksFromTrack,
|
|
||||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
|
||||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
|
||||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
|
||||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
|
||||||
onBulkDownload = { tracks ->
|
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
|
||||||
},
|
|
||||||
onBulkAddToQueue = { tracks ->
|
|
||||||
viewModel.addTracksToQueue(tracks)
|
|
||||||
},
|
|
||||||
onBulkPlayNext = { tracks ->
|
|
||||||
viewModel.playTracksNext(tracks)
|
|
||||||
},
|
|
||||||
onBulkAddToPlaylist = { tracks ->
|
|
||||||
tracksToAddToPlaylist = tracks
|
|
||||||
showAddToPlaylistPicker = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AddToPlaylistPicker(
|
|
||||||
visible = showAddToPlaylistPicker,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
onDismiss = { showAddToPlaylistPicker = false },
|
|
||||||
onPlaylistSelected = { playlistId ->
|
|
||||||
scope.launch {
|
|
||||||
libraryRepository.addTracksToPlaylist(
|
|
||||||
playlistId,
|
|
||||||
tracksToAddToPlaylist.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,10 +24,15 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
interface SettingsProvider {
|
||||||
|
val settingsState: StateFlow<UserSettings?>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val repository: SettingsRepository
|
private val repository: SettingsRepository
|
||||||
) : ViewModel() {
|
) : ViewModel(), SettingsProvider {
|
||||||
val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
|
override val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.WhileSubscribed(5000),
|
SharingStarted.WhileSubscribed(5000),
|
||||||
null,
|
null,
|
||||||
|
|||||||
@ -78,6 +78,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
@ -149,7 +150,7 @@ fun AppExpandedPlayer(
|
|||||||
onDownloadTrack: () -> Unit = {},
|
onDownloadTrack: () -> Unit = {},
|
||||||
onGoToAlbum: () -> Unit = {},
|
onGoToAlbum: () -> Unit = {},
|
||||||
onSleepTimer: () -> Unit = {},
|
onSleepTimer: () -> Unit = {},
|
||||||
audioPlayer: AudioPlayer = koinInject(),
|
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
||||||
key = SAVED_TRACKS_COLLECTION_ID,
|
key = SAVED_TRACKS_COLLECTION_ID,
|
||||||
@ -593,7 +594,7 @@ private fun rememberSharedAlbumArtModifier(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LyricsPreviewCard(
|
private fun LyricsPreviewCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayer: AudioPlayer,
|
audioPlayer: AudioPlayerInterface,
|
||||||
onExpand: () -> Unit,
|
onExpand: () -> Unit,
|
||||||
viewModel: LyricsViewModel = koinViewModel()
|
viewModel: LyricsViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -68,6 +68,7 @@ import org.koin.compose.koinInject
|
|||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
@ -86,7 +87,7 @@ fun AppFloatingPlayer(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
audioPlayer: AudioPlayer = koinInject(),
|
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
||||||
key = SAVED_TRACKS_COLLECTION_ID,
|
key = SAVED_TRACKS_COLLECTION_ID,
|
||||||
|
|||||||
@ -18,18 +18,23 @@
|
|||||||
package dev.krtirtho.spotube.modules.shell
|
package dev.krtirtho.spotube.modules.shell
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@ -45,12 +50,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.blur.HazeColorEffect
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
@ -100,11 +111,12 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
@Composable
|
@Composable
|
||||||
fun AppLargePlayer(
|
fun AppLargePlayer(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hazeState: HazeState,
|
||||||
onQueue: () -> Unit = {},
|
onQueue: () -> Unit = {},
|
||||||
onAlternativeSource: () -> Unit = {},
|
onAlternativeSource: () -> Unit = {},
|
||||||
onMoreOptions: () -> Unit = {},
|
onMoreOptions: () -> Unit = {},
|
||||||
onLyrics: () -> Unit = {},
|
onLyrics: () -> Unit = {},
|
||||||
audioPlayer: AudioPlayer = koinInject(),
|
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
downloadsViewModel: DownloadsViewModel = koinViewModel(),
|
downloadsViewModel: DownloadsViewModel = koinViewModel(),
|
||||||
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
||||||
@ -171,211 +183,231 @@ fun AppLargePlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.78f),
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||||
tonalElevation = 0.dp,
|
color = Color.Transparent,
|
||||||
shadowElevation = 0.dp
|
shadowElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
Row(
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = Color.Gray,
|
||||||
|
thickness = 1.dp,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
.hazeEffect(state = hazeState) {
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
blurEffect {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
blurRadius = 20.dp
|
||||||
|
colorEffects = listOf(
|
||||||
|
HazeColorEffect.tint(surfaceTint)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(52.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = coverModel,
|
|
||||||
contentDescription = playerUiState.title,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = playerUiState.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = playerUiState.artists,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
|
||||||
PlayerHeartButton(
|
|
||||||
audioPlayerQueue = audioPlayerQueue,
|
|
||||||
savedTracksViewModel = savedTracksViewModel,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1.1f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Slider(
|
|
||||||
value = seekProgress,
|
|
||||||
onValueChange = {
|
|
||||||
seekProgress = it
|
|
||||||
isSeeking = true
|
|
||||||
},
|
|
||||||
onValueChangeFinished = {
|
|
||||||
isSeeking = false
|
|
||||||
onSeekFinished()
|
|
||||||
},
|
|
||||||
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.weight(1f),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Box(
|
||||||
text = formatPlayerTime(playerUiState.position),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatPlayerTime(playerUiState.displayDuration),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
VariableIconButton(
|
|
||||||
onClick = ::onShuffleToggle,
|
|
||||||
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.IconsaxShuffle,
|
|
||||||
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
|
||||||
tint = if (playerUiState.isShuffling) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GhostIconButton(onClick = ::onSkipPrevious) {
|
|
||||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = ::onPlayPause,
|
|
||||||
theme = invertedButtonStyle().copyShape(CircleShape),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(50.dp),
|
.size(52.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
) {
|
) {
|
||||||
Icon(
|
AsyncImage(
|
||||||
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
model = coverModel,
|
||||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
contentDescription = playerUiState.title,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipNext) {
|
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
Text(
|
||||||
}
|
text = playerUiState.title,
|
||||||
VariableIconButton(
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
onClick = ::onLoopToggle,
|
maxLines = 1,
|
||||||
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
overflow = TextOverflow.Ellipsis
|
||||||
) {
|
)
|
||||||
Icon(
|
Text(
|
||||||
imageVector = when (playerUiState.loopState) {
|
text = playerUiState.artists,
|
||||||
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
style = MaterialTheme.typography.bodySmall,
|
||||||
LoopState.ONE -> Iconsax.IconsaxRepeateOne
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
|
maxLines = 1,
|
||||||
},
|
overflow = TextOverflow.Ellipsis
|
||||||
contentDescription = "Loop mode ${playerUiState.loopState.name}",
|
|
||||||
tint = if (playerUiState.loopState == LoopState.NONE) {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
PlayerHeartButton(
|
||||||
|
audioPlayerQueue = audioPlayerQueue,
|
||||||
|
savedTracksViewModel = savedTracksViewModel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1.1f),
|
||||||
horizontalAlignment = Alignment.End
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Slider(
|
||||||
GhostIconButton(onClick = onQueue) {
|
value = seekProgress,
|
||||||
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
|
onValueChange = {
|
||||||
}
|
seekProgress = it
|
||||||
GhostIconButton(onClick = {
|
isSeeking = true
|
||||||
val track = (currentEntry as? QueueEntry.StreamingTrack)?.track
|
},
|
||||||
if (track != null) {
|
onValueChangeFinished = {
|
||||||
downloadsViewModel.downloadTrack(track)
|
isSeeking = false
|
||||||
}
|
onSeekFinished()
|
||||||
}) {
|
},
|
||||||
DownloadProgressIcon(
|
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
|
||||||
track = (currentEntry as? QueueEntry.StreamingTrack)?.track,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
)
|
||||||
contentDescription = "Download",
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatPlayerTime(playerUiState.position),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatPlayerTime(playerUiState.displayDuration),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = onAlternativeSource) {
|
Row(
|
||||||
Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source")
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
}
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
GhostIconButton(onClick = onLyrics) {
|
modifier = Modifier.fillMaxWidth()
|
||||||
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
|
) {
|
||||||
}
|
VariableIconButton(
|
||||||
GhostIconButton(onClick = onMoreOptions) {
|
onClick = ::onShuffleToggle,
|
||||||
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
|
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.IconsaxShuffle,
|
||||||
|
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
||||||
|
tint = if (playerUiState.isShuffling) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GhostIconButton(onClick = ::onSkipPrevious) {
|
||||||
|
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = ::onPlayPause,
|
||||||
|
theme = invertedButtonStyle().copyShape(CircleShape),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(50.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
|
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GhostIconButton(onClick = ::onSkipNext) {
|
||||||
|
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||||
|
}
|
||||||
|
VariableIconButton(
|
||||||
|
onClick = ::onLoopToggle,
|
||||||
|
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (playerUiState.loopState) {
|
||||||
|
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
||||||
|
LoopState.ONE -> Iconsax.IconsaxRepeateOne
|
||||||
|
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
|
||||||
|
},
|
||||||
|
contentDescription = "Loop mode ${playerUiState.loopState.name}",
|
||||||
|
tint = if (playerUiState.loopState == LoopState.NONE) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
GhostIconButton(
|
Column(
|
||||||
onClick = {
|
modifier = Modifier.weight(1f),
|
||||||
scope.launch {
|
horizontalAlignment = Alignment.End
|
||||||
if (playerUiState.volume <= 0f) {
|
) {
|
||||||
audioPlayer.setVolume(lastNonZeroVolume)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
} else {
|
GhostIconButton(onClick = onQueue) {
|
||||||
lastNonZeroVolume = playerUiState.volume
|
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
|
||||||
audioPlayer.setVolume(0f)
|
}
|
||||||
|
GhostIconButton(onClick = {
|
||||||
|
val track = (currentEntry as? QueueEntry.StreamingTrack)?.track
|
||||||
|
if (track != null) {
|
||||||
|
downloadsViewModel.downloadTrack(track)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
DownloadProgressIcon(
|
||||||
|
track = (currentEntry as? QueueEntry.StreamingTrack)?.track,
|
||||||
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
|
contentDescription = "Download",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GhostIconButton(onClick = onAlternativeSource) {
|
||||||
|
Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source")
|
||||||
|
}
|
||||||
|
GhostIconButton(onClick = onLyrics) {
|
||||||
|
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
|
||||||
|
}
|
||||||
|
GhostIconButton(onClick = onMoreOptions) {
|
||||||
|
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GhostIconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (playerUiState.volume <= 0f) {
|
||||||
|
audioPlayer.setVolume(lastNonZeroVolume)
|
||||||
|
} else {
|
||||||
|
lastNonZeroVolume = playerUiState.volume
|
||||||
|
audioPlayer.setVolume(0f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
|
val volumeIcon = when {
|
||||||
|
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
|
||||||
|
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
|
||||||
|
else -> Iconsax.IconsaxVolumeHigh
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = volumeIcon,
|
||||||
|
contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) {
|
Slider(
|
||||||
val volumeIcon = when {
|
value = playerUiState.volume,
|
||||||
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
|
onValueChange = {
|
||||||
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
|
lastNonZeroVolume = it
|
||||||
else -> Iconsax.IconsaxVolumeHigh
|
scope.launch {
|
||||||
}
|
audioPlayer.setVolume(it)
|
||||||
Icon(
|
}
|
||||||
imageVector = volumeIcon,
|
},
|
||||||
contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute"
|
modifier = Modifier.fillMaxWidth(0.62f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(
|
|
||||||
value = playerUiState.volume,
|
|
||||||
onValueChange = {
|
|
||||||
lastNonZeroVolume = it
|
|
||||||
scope.launch {
|
|
||||||
audioPlayer.setVolume(it)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(0.62f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
@ -72,7 +73,7 @@ internal data class PlayerUiState(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun rememberPlayerUiState(
|
internal fun rememberPlayerUiState(
|
||||||
audioPlayer: AudioPlayer,
|
audioPlayer: AudioPlayerInterface,
|
||||||
audioPlayerQueue: AudioPlayerQueue,
|
audioPlayerQueue: AudioPlayerQueue,
|
||||||
): PlayerUiState {
|
): PlayerUiState {
|
||||||
val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList())
|
val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList())
|
||||||
|
|||||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.shell
|
|||||||
|
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
@ -36,7 +38,11 @@ import androidx.compose.ui.graphics.RectangleShape
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.material3.BottomSheetScaffold
|
import androidx.compose.material3.BottomSheetScaffold
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
import androidx.compose.material3.rememberStandardBottomSheetState
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -52,8 +58,11 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
@ -113,14 +122,32 @@ fun AppShell(
|
|||||||
|
|
||||||
CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
|
CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
|
||||||
if (useSidebar) {
|
if (useSidebar) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
val hazeState = rememberHazeState()
|
||||||
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
|
val bgHazeState = rememberHazeState()
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.hazeSource(state = bgHazeState) // <-- Put it here!
|
||||||
|
)
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
AppSidebar(
|
AppSidebar(
|
||||||
navigator = navigator, navigationState = navigationState
|
hazeState = bgHazeState,
|
||||||
|
navigator = navigator,
|
||||||
|
navigationState = navigationState
|
||||||
)
|
)
|
||||||
|
VerticalDivider(
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
color = Color.Gray.copy(alpha = 0.2f),
|
||||||
|
thickness = 1.dp,
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.weight(1f).hazeSource(hazeState)) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -168,7 +195,10 @@ fun AppShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppLargePlayer(
|
AppLargePlayer(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
hazeState = hazeState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter),
|
||||||
onQueue = queueViewModel::toggleQueueVisibility,
|
onQueue = queueViewModel::toggleQueueVisibility,
|
||||||
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
|
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
|
||||||
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },
|
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import androidx.compose.animation.AnimatedVisibility
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -35,7 +34,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -47,10 +48,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.blur.HazeColorEffect
|
||||||
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
@ -59,6 +63,7 @@ import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
@ -73,6 +78,7 @@ import spotube.composeapp.generated.resources.cookie_regular
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppSidebar(
|
fun AppSidebar(
|
||||||
|
hazeState: HazeState,
|
||||||
navigator: Navigator,
|
navigator: Navigator,
|
||||||
navigationState: NavigationState,
|
navigationState: NavigationState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@ -81,12 +87,20 @@ fun AppSidebar(
|
|||||||
var expanded by rememberSaveable { mutableStateOf(true) }
|
var expanded by rememberSaveable { mutableStateOf(true) }
|
||||||
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
|
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
|
||||||
val currentLibraryTab by libraryState.currentTab.collectAsState()
|
val currentLibraryTab by libraryState.currentTab.collectAsState()
|
||||||
|
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(width)
|
.width(width)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
.hazeEffect(hazeState) {
|
||||||
|
blurEffect {
|
||||||
|
blurRadius = 20.dp
|
||||||
|
colorEffects = listOf(
|
||||||
|
HazeColorEffect.tint(surfaceTint)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@ -174,12 +188,12 @@ fun SidebarItem(
|
|||||||
showDownloadBadge: Boolean = false,
|
showDownloadBadge: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val itemContent: @Composable RowScope.() -> Unit = {
|
val itemContent: @Composable RowScope.() -> Unit = {
|
||||||
Box(modifier = Modifier.size(24.dp)) {
|
Box(modifier = Modifier.size(20.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = activeIcon,
|
imageVector = activeIcon,
|
||||||
contentDescription = label,
|
contentDescription = label,
|
||||||
tint = if (selected) {
|
tint = if (selected) {
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
},
|
},
|
||||||
@ -195,11 +209,15 @@ fun SidebarItem(
|
|||||||
text = label,
|
text = label,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
color = if (selected) {
|
style = LocalTextStyle.current.copy(
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||||
} else {
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (selected) {
|
||||||
},
|
MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
||||||
@ -208,20 +226,24 @@ fun SidebarItem(
|
|||||||
val buttonModifier = Modifier
|
val buttonModifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
val contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp)
|
val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp)
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = buttonModifier,
|
modifier = buttonModifier,
|
||||||
theme = LocalBaseUITheme.current.buttons.secondary.copyPadding(contentPadding),
|
theme = LocalBaseUITheme.current.buttons.secondary
|
||||||
|
.copyPadding(contentPadding)
|
||||||
|
.copyShape(RoundedCornerShape(8.dp)),
|
||||||
content = itemContent,
|
content = itemContent,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
OutlineButton(
|
OutlineButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = buttonModifier,
|
modifier = buttonModifier,
|
||||||
theme = LocalBaseUITheme.current.buttons.outline.copyPadding(contentPadding),
|
theme = LocalBaseUITheme.current.buttons.outline
|
||||||
|
.copyPadding(contentPadding)
|
||||||
|
.copyShape(RoundedCornerShape(8.dp)),
|
||||||
hoverOnly = true,
|
hoverOnly = true,
|
||||||
content = itemContent,
|
content = itemContent,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
package dev.krtirtho.spotube.resources.iconsax
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Iconsax.FluentDismiss: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_FluentDismiss != null) {
|
||||||
|
return _FluentDismiss!!
|
||||||
|
}
|
||||||
|
_FluentDismiss = ImageVector.Builder(
|
||||||
|
name = "FluentDismiss",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).apply {
|
||||||
|
path(fill = SolidColor(Color(0xFF212121))) {
|
||||||
|
moveToRelative(4.397f, 4.554f)
|
||||||
|
lineToRelative(0.073f, -0.084f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.976f, -0.073f)
|
||||||
|
lineToRelative(0.084f, 0.073f)
|
||||||
|
lineTo(12f, 10.939f)
|
||||||
|
lineToRelative(6.47f, -6.47f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = true, isPositiveArc = true, 1.06f, 1.061f)
|
||||||
|
lineTo(13.061f, 12f)
|
||||||
|
lineToRelative(6.47f, 6.47f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.072f, 0.976f)
|
||||||
|
lineToRelative(-0.073f, 0.084f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.976f, 0.073f)
|
||||||
|
lineToRelative(-0.084f, -0.073f)
|
||||||
|
lineTo(12f, 13.061f)
|
||||||
|
lineToRelative(-6.47f, 6.47f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.06f, -1.061f)
|
||||||
|
lineTo(10.939f, 12f)
|
||||||
|
lineToRelative(-6.47f, -6.47f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.072f, -0.976f)
|
||||||
|
lineToRelative(0.073f, -0.084f)
|
||||||
|
lineToRelative(-0.073f, 0.084f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return _FluentDismiss!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private var _FluentDismiss: ImageVector? = null
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package dev.krtirtho.spotube.resources.iconsax
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Iconsax.FluentMaximize: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_FluentMaximize != null) {
|
||||||
|
return _FluentMaximize!!
|
||||||
|
}
|
||||||
|
_FluentMaximize = ImageVector.Builder(
|
||||||
|
name = "FluentMaximize",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).apply {
|
||||||
|
path(fill = SolidColor(Color(0xFF212121))) {
|
||||||
|
moveTo(5.75f, 3f)
|
||||||
|
horizontalLineToRelative(12.5f)
|
||||||
|
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 21f, 5.75f)
|
||||||
|
verticalLineToRelative(12.5f)
|
||||||
|
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.25f, 21f)
|
||||||
|
lineTo(5.75f, 21f)
|
||||||
|
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3f, 18.25f)
|
||||||
|
lineTo(3f, 5.75f)
|
||||||
|
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 5.75f, 3f)
|
||||||
|
close()
|
||||||
|
moveTo(5.75f, 4.5f)
|
||||||
|
curveToRelative(-0.69f, 0f, -1.25f, 0.56f, -1.25f, 1.25f)
|
||||||
|
verticalLineToRelative(12.5f)
|
||||||
|
curveToRelative(0f, 0.69f, 0.56f, 1.25f, 1.25f, 1.25f)
|
||||||
|
horizontalLineToRelative(12.5f)
|
||||||
|
curveToRelative(0.69f, 0f, 1.25f, -0.56f, 1.25f, -1.25f)
|
||||||
|
lineTo(19.5f, 5.75f)
|
||||||
|
curveToRelative(0f, -0.69f, -0.56f, -1.25f, -1.25f, -1.25f)
|
||||||
|
lineTo(5.75f, 4.5f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return _FluentMaximize!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private var _FluentMaximize: ImageVector? = null
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package dev.krtirtho.spotube.resources.iconsax
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Iconsax.FluentMinus: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_FluentMinus != null) {
|
||||||
|
return _FluentMinus!!
|
||||||
|
}
|
||||||
|
_FluentMinus = ImageVector.Builder(
|
||||||
|
name = "FluentMinus",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).apply {
|
||||||
|
path(fill = SolidColor(Color(0xFF212121))) {
|
||||||
|
moveTo(3.755f, 12.5f)
|
||||||
|
horizontalLineToRelative(16.492f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, -1.5f)
|
||||||
|
horizontalLineTo(3.755f)
|
||||||
|
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 1.5f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return _FluentMinus!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private var _FluentMinus: ImageVector? = null
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package dev.krtirtho.spotube.resources.iconsax
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Iconsax.FluentSquareMultiple: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_FluentSquareMultiple != null) {
|
||||||
|
return _FluentSquareMultiple!!
|
||||||
|
}
|
||||||
|
_FluentSquareMultiple = ImageVector.Builder(
|
||||||
|
name = "FluentSquareMultiple",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).apply {
|
||||||
|
path(fill = SolidColor(Color(0xFF212121))) {
|
||||||
|
moveTo(7.518f, 5f)
|
||||||
|
horizontalLineTo(6.009f)
|
||||||
|
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3.24f, -3f)
|
||||||
|
horizontalLineToRelative(8.001f)
|
||||||
|
arcTo(4.75f, 4.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 22f, 6.75f)
|
||||||
|
verticalLineToRelative(8f)
|
||||||
|
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3f, 3.24f)
|
||||||
|
verticalLineToRelative(-1.508f)
|
||||||
|
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.5f, -1.732f)
|
||||||
|
verticalLineToRelative(-8f)
|
||||||
|
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, -3.25f, -3.25f)
|
||||||
|
horizontalLineToRelative(-8f)
|
||||||
|
arcTo(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 7.518f, 5f)
|
||||||
|
close()
|
||||||
|
moveTo(5.25f, 6f)
|
||||||
|
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, 9.25f)
|
||||||
|
verticalLineToRelative(9.5f)
|
||||||
|
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.25f, 22f)
|
||||||
|
horizontalLineToRelative(9.5f)
|
||||||
|
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 18f, 18.75f)
|
||||||
|
verticalLineToRelative(-9.5f)
|
||||||
|
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.75f, 6f)
|
||||||
|
horizontalLineToRelative(-9.5f)
|
||||||
|
close()
|
||||||
|
moveTo(3.5f, 9.25f)
|
||||||
|
curveToRelative(0f, -0.966f, 0.784f, -1.75f, 1.75f, -1.75f)
|
||||||
|
horizontalLineToRelative(9.5f)
|
||||||
|
curveToRelative(0.967f, 0f, 1.75f, 0.784f, 1.75f, 1.75f)
|
||||||
|
verticalLineToRelative(9.5f)
|
||||||
|
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, 1.75f)
|
||||||
|
horizontalLineToRelative(-9.5f)
|
||||||
|
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, -1.75f)
|
||||||
|
verticalLineToRelative(-9.5f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return _FluentSquareMultiple!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private var _FluentSquareMultiple: ImageVector? = null
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package dev.krtirtho.spotube.resources.iconsax
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.PathData
|
||||||
|
import androidx.compose.ui.graphics.vector.group
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Iconsax.IconsaxAdd: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_IncosaxAdd != null) {
|
||||||
|
return _IncosaxAdd!!
|
||||||
|
}
|
||||||
|
_IncosaxAdd = ImageVector.Builder(
|
||||||
|
name = "IncosaxAdd",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).apply {
|
||||||
|
group(
|
||||||
|
clipPathData = PathData {
|
||||||
|
moveTo(0f, 0f)
|
||||||
|
horizontalLineToRelative(24f)
|
||||||
|
verticalLineToRelative(24f)
|
||||||
|
horizontalLineToRelative(-24f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
path(
|
||||||
|
fillAlpha = 0.4f,
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeAlpha = 0.4f,
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(6f, 12f)
|
||||||
|
horizontalLineTo(18f)
|
||||||
|
}
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(12f, 18f)
|
||||||
|
verticalLineTo(6f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return _IncosaxAdd!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private var _IncosaxAdd: ImageVector? = null
|
||||||
@ -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
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
actual class AudioPlayer actual constructor(context: Any) {
|
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
|
||||||
|
|
||||||
actual val context: Any = context
|
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 _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
|
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
|
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
|
||||||
actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
|
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
|
||||||
actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
|
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
|
||||||
actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
|
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
|
||||||
actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
|
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
|
||||||
actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
|
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
|
||||||
actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
|
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
|
||||||
actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
|
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
|
||||||
actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
||||||
actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
|
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
|
||||||
actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||||
actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||||
|
|
||||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
||||||
|
|
||||||
@ -158,36 +158,36 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
return AVPlayerItem(nsUrl)
|
return AVPlayerItem(nsUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun play() {
|
actual override suspend fun play() {
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun pause() {
|
actual override suspend fun pause() {
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun stop() {
|
actual override suspend fun stop() {
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
avPlayer.seekToTime(CMTimeMake(0, 1))
|
avPlayer.seekToTime(CMTimeMake(0, 1))
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
val seconds = position.inWholeMilliseconds / 1000.0
|
val seconds = position.inWholeMilliseconds / 1000.0
|
||||||
val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
|
val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
|
||||||
avPlayer.seekToTime(cmTime)
|
avPlayer.seekToTime(cmTime)
|
||||||
_position.tryEmit(position)
|
_position.tryEmit(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun loop(state: LoopState) {
|
actual override suspend fun loop(state: LoopState) {
|
||||||
_loopState.tryEmit(state)
|
_loopState.tryEmit(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun shuffle(enabled: Boolean) {
|
actual override suspend fun shuffle(enabled: Boolean) {
|
||||||
_shuffleMode.tryEmit(enabled)
|
_shuffleMode.tryEmit(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun load(
|
actual override suspend fun load(
|
||||||
playlist: List<MediaItem>,
|
playlist: List<MediaItem>,
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
startPosition: Int
|
startPosition: Int
|
||||||
@ -226,13 +226,13 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun addMediaItem(mediaItem: MediaItem) {
|
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||||
currentPlaylist.add(mediaItem)
|
currentPlaylist.add(mediaItem)
|
||||||
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
val currentIndex = currentPlaylist.indexOfFirst {
|
||||||
it.url == _currentMediaItem.value?.url
|
it.url == _currentMediaItem.value?.url
|
||||||
}
|
}
|
||||||
@ -245,7 +245,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun removeMediaItem(mediaItem: MediaItem) {
|
actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
|
||||||
val index = urlIndexMap[mediaItem.url] ?: return
|
val index = urlIndexMap[mediaItem.url] ?: return
|
||||||
currentPlaylist.removeAt(index)
|
currentPlaylist.removeAt(index)
|
||||||
urlIndexMap.clear()
|
urlIndexMap.clear()
|
||||||
@ -255,7 +255,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_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
|
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
||||||
val item = currentPlaylist.removeAt(fromIndex)
|
val item = currentPlaylist.removeAt(fromIndex)
|
||||||
currentPlaylist.add(toIndex, item)
|
currentPlaylist.add(toIndex, item)
|
||||||
@ -266,7 +266,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun skipToNext() {
|
actual override suspend fun skipToNext() {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
val currentIndex = currentPlaylist.indexOfFirst {
|
||||||
it.url == _currentMediaItem.value?.url
|
it.url == _currentMediaItem.value?.url
|
||||||
}
|
}
|
||||||
@ -282,7 +282,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun skipToPrevious() {
|
actual override suspend fun skipToPrevious() {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
val currentIndex = currentPlaylist.indexOfFirst {
|
||||||
it.url == _currentMediaItem.value?.url
|
it.url == _currentMediaItem.value?.url
|
||||||
}
|
}
|
||||||
@ -298,7 +298,7 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun jumpTo(index: Int) {
|
actual override suspend fun jumpTo(index: Int) {
|
||||||
if (index in currentPlaylist.indices) {
|
if (index in currentPlaylist.indices) {
|
||||||
val item = currentPlaylist[index]
|
val item = currentPlaylist[index]
|
||||||
val avItem = buildAVPlayerItem(item.url)
|
val avItem = buildAVPlayerItem(item.url)
|
||||||
@ -309,21 +309,21 @@ actual class AudioPlayer actual constructor(context: Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun setVolume(volume: Float) {
|
actual override suspend fun setVolume(volume: Float) {
|
||||||
val clamped = volume.coerceIn(0f, 1f)
|
val clamped = volume.coerceIn(0f, 1f)
|
||||||
avPlayer.volume = clamped
|
avPlayer.volume = clamped
|
||||||
_volume.tryEmit(clamped)
|
_volume.tryEmit(clamped)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun setPlaybackSpeed(speed: Float) {
|
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
||||||
val clamped = speed.coerceIn(0.25f, 4f)
|
val clamped = speed.coerceIn(0.25f, 4f)
|
||||||
avPlayer.rate = clamped
|
avPlayer.rate = clamped
|
||||||
_playbackSpeed.tryEmit(clamped)
|
_playbackSpeed.tryEmit(clamped)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun isDisposed(): Boolean = disposed
|
actual override fun isDisposed(): Boolean = disposed
|
||||||
|
|
||||||
actual fun dispose() {
|
actual override fun dispose() {
|
||||||
disposed = true
|
disposed = true
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
package dev.krtirtho.spotube.core.di
|
package dev.krtirtho.spotube.core.di
|
||||||
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.share.IosShareService
|
import dev.krtirtho.spotube.core.share.IosShareService
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
@ -30,7 +31,7 @@ import org.koin.dsl.module
|
|||||||
actual val platformModules = module {
|
actual val platformModules = module {
|
||||||
singleOf(::Paths)
|
singleOf(::Paths)
|
||||||
singleOf(::WebViewController)
|
singleOf(::WebViewController)
|
||||||
single { AudioPlayer(Unit) }
|
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
||||||
single<LocalMediaDiscoveryService> { IosLocalMediaDiscoveryService() }
|
single<LocalMediaDiscoveryService> { IosLocalMediaDiscoveryService() }
|
||||||
single<ShareService> { IosShareService() }
|
single<ShareService> { IosShareService() }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ import kotlin.time.Duration
|
|||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface, KoinComponent {
|
||||||
actual val context: Any = context
|
actual val context: Any = context
|
||||||
|
|
||||||
private val logger by injectLogger<AudioPlayer>()
|
private val logger by injectLogger<AudioPlayer>()
|
||||||
@ -86,18 +86,18 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
|
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
actual val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
|
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
|
||||||
actual val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
|
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
|
||||||
actual val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
|
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
|
||||||
actual val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
|
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
|
||||||
actual val positionFlow: StateFlow<Duration> = _position.asStateFlow()
|
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
|
||||||
actual val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
|
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
|
||||||
actual val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
|
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
|
||||||
actual val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
|
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
|
||||||
actual val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
||||||
actual val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
|
actual override val volumeFlow: StateFlow<Float> = _volume.asStateFlow()
|
||||||
actual val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||||
actual val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
VLCBundleLoaderGenerated.getVerifiedPath()
|
VLCBundleLoaderGenerated.getVerifiedPath()
|
||||||
@ -241,7 +241,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
startPositionPolling()
|
startPositionPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun play() {
|
actual override suspend fun play() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
if (currentIndex !in currentPlaylist.indices) {
|
if (currentIndex !in currentPlaylist.indices) {
|
||||||
@ -255,14 +255,14 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun pause() {
|
actual override suspend fun pause() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
mediaListPlayer.controls().pause()
|
mediaListPlayer.controls().pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun stop() {
|
actual override suspend fun stop() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
mediaListPlayer.controls().stop()
|
mediaListPlayer.controls().stop()
|
||||||
@ -271,7 +271,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
val maxMs = _duration.value.inWholeMilliseconds
|
val maxMs = _duration.value.inWholeMilliseconds
|
||||||
@ -289,7 +289,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun loop(state: LoopState) {
|
actual override suspend fun loop(state: LoopState) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
val vlcMode = when (state) {
|
val vlcMode = when (state) {
|
||||||
@ -302,12 +302,10 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun shuffle(enabled: Boolean) {
|
actual override suspend fun shuffle(enabled: Boolean) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return
|
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return
|
||||||
|
|
||||||
val wasPlaying = _playerState.value == PlayerState.PLAYING
|
|
||||||
val wasPaused = _playerState.value == PlayerState.PAUSED
|
|
||||||
val currentItem = _currentMediaItem.value
|
val currentItem = _currentMediaItem.value
|
||||||
|
|
||||||
val rebuilt = if (enabled) {
|
val rebuilt = if (enabled) {
|
||||||
@ -331,24 +329,16 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
|
|
||||||
val nextIndex = when {
|
val nextIndex = when {
|
||||||
currentItem == null -> if (currentPlaylist.isEmpty()) -1 else 0
|
currentItem == null -> if (currentPlaylist.isEmpty()) -1 else 0
|
||||||
enabled -> 0
|
|
||||||
else -> currentPlaylist.indexOfFirst { it.url == currentItem.url }
|
else -> currentPlaylist.indexOfFirst { it.url == currentItem.url }
|
||||||
.takeIf { it >= 0 } ?: 0
|
.takeIf { it >= 0 } ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
currentIndex = nextIndex
|
currentIndex = nextIndex
|
||||||
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(nextIndex))
|
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(nextIndex))
|
||||||
|
|
||||||
if (nextIndex >= 0 && (wasPlaying || wasPaused)) {
|
|
||||||
mediaListPlayer.controls().play(nextIndex)
|
|
||||||
if (wasPaused) {
|
|
||||||
mediaListPlayer.controls().setPause(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
|
actual override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
||||||
@ -385,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 {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
originalPlaylist.add(mediaItem)
|
originalPlaylist.add(mediaItem)
|
||||||
@ -399,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 {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
|
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
|
||||||
@ -417,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 {
|
lock.withLock {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
|
|
||||||
@ -447,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 {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
||||||
@ -471,27 +461,31 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun skipToNext() {
|
actual override suspend fun skipToNext() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
val nextIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex)
|
val nextIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex)
|
||||||
logger.d { "skipToNext: currentIndex=$currentIndex, targetIndex=$nextIndex" }
|
logger.d { "skipToNext: currentIndex=$currentIndex, targetIndex=$nextIndex" }
|
||||||
|
currentIndex = nextIndex
|
||||||
|
_currentMediaItem.value = currentPlaylist[nextIndex]
|
||||||
pendingNextIndex = nextIndex
|
pendingNextIndex = nextIndex
|
||||||
mediaListPlayer.controls().playNext()
|
mediaListPlayer.controls().play(nextIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun skipToPrevious() {
|
actual override suspend fun skipToPrevious() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
val prevIndex = (currentIndex - 1).coerceAtLeast(0)
|
val prevIndex = (currentIndex - 1).coerceAtLeast(0)
|
||||||
logger.d { "skipToPrevious: currentIndex=$currentIndex, targetIndex=$prevIndex" }
|
logger.d { "skipToPrevious: currentIndex=$currentIndex, targetIndex=$prevIndex" }
|
||||||
|
currentIndex = prevIndex
|
||||||
|
_currentMediaItem.value = currentPlaylist[prevIndex]
|
||||||
pendingNextIndex = prevIndex
|
pendingNextIndex = prevIndex
|
||||||
mediaListPlayer.controls().playPrevious()
|
mediaListPlayer.controls().play(prevIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun jumpTo(index: Int) {
|
actual override suspend fun jumpTo(index: Int) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed || index !in currentPlaylist.indices) return
|
if (disposed || index !in currentPlaylist.indices) return
|
||||||
currentIndex = index
|
currentIndex = index
|
||||||
@ -500,7 +494,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun setVolume(volume: Float) {
|
actual override suspend fun setVolume(volume: Float) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
val clamped = volume.coerceIn(0f, 1f)
|
val clamped = volume.coerceIn(0f, 1f)
|
||||||
@ -509,7 +503,7 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun setPlaybackSpeed(speed: Float) {
|
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
val clamped = speed.coerceIn(0.25f, 4f)
|
val clamped = speed.coerceIn(0.25f, 4f)
|
||||||
@ -522,9 +516,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 {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
disposed = true
|
disposed = true
|
||||||
@ -554,10 +548,13 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun rebuildVlcMediaListLocked() {
|
private fun rebuildVlcMediaListLocked() {
|
||||||
mediaList.media().clear()
|
val oldList = mediaList
|
||||||
|
mediaList = mediaPlayerFactory.media().newMediaList()
|
||||||
currentPlaylist.forEach { mediaItem ->
|
currentPlaylist.forEach { mediaItem ->
|
||||||
mediaList.media().add(mediaItem.toMrl())
|
mediaList.media().add(mediaItem.toMrl())
|
||||||
}
|
}
|
||||||
|
mediaListPlayer.list().setMediaList(mediaList.newMediaListRef())
|
||||||
|
oldList.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPositionPolling() {
|
private fun startPositionPolling() {
|
||||||
@ -579,8 +576,9 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
|
|||||||
delay(250.milliseconds)
|
delay(250.milliseconds)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logger.e(e) { "Error in position polling loop" }
|
if (!disposed) {
|
||||||
_error.tryEmit(e)
|
logger.e(e) { "Error in position polling loop" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
package dev.krtirtho.spotube.core.di
|
package dev.krtirtho.spotube.core.di
|
||||||
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.share.JvmShareService
|
import dev.krtirtho.spotube.core.share.JvmShareService
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
@ -28,7 +29,7 @@ import org.koin.dsl.module
|
|||||||
|
|
||||||
actual val platformModules = module {
|
actual val platformModules = module {
|
||||||
singleOf(::Paths)
|
singleOf(::Paths)
|
||||||
single { AudioPlayer(Unit) }
|
single<AudioPlayerInterface> { AudioPlayer(Unit) }
|
||||||
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
single<LocalMediaDiscoveryService> { JvmLocalMediaDiscoveryService() }
|
||||||
single<ShareService> { JvmShareService() }
|
single<ShareService> { JvmShareService() }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.window.WindowDraggableArea
|
import androidx.compose.foundation.window.WindowDraggableArea
|
||||||
@ -36,13 +34,12 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.ApplicationScope
|
|
||||||
import androidx.compose.ui.window.WindowPlacement
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
import compose.icons.FeatherIcons
|
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
|
||||||
import compose.icons.feathericons.Maximize2
|
import dev.krtirtho.spotube.resources.iconsax.FluentMaximize
|
||||||
import compose.icons.feathericons.Minimize2
|
import dev.krtirtho.spotube.resources.iconsax.FluentMinus
|
||||||
import compose.icons.feathericons.Minus
|
import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple
|
||||||
import compose.icons.feathericons.X
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@ -79,7 +76,7 @@ actual fun ApplicationMainBar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WindowButtons(modifier: Modifier = Modifier) {
|
private fun WindowButtons(modifier: Modifier = Modifier) {
|
||||||
val window = LocalWindowScope.current.window
|
val windowState = LocalWindowState.current
|
||||||
val applicationScope = LocalApplicationScope.current
|
val applicationScope = LocalApplicationScope.current
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@ -88,27 +85,27 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
|
|||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
window.isMinimized = true
|
windowState.isMinimized = true
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
FeatherIcons.Minus, "Minimize", modifier = Modifier.size(14.dp)
|
Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
window.placement = if (window.placement == WindowPlacement.Maximized) {
|
windowState.placement = if (windowState.placement == WindowPlacement.Maximized) {
|
||||||
WindowPlacement.Floating
|
WindowPlacement.Floating
|
||||||
} else {
|
} else {
|
||||||
WindowPlacement.Maximized
|
WindowPlacement.Maximized
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
if (window.placement == WindowPlacement.Floating) {
|
if (windowState.placement == WindowPlacement.Floating) {
|
||||||
Icon(
|
Icon(
|
||||||
FeatherIcons.Maximize2, "Maximize", modifier = Modifier.size(14.dp)
|
Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
FeatherIcons.Minimize2, "Restore", modifier = Modifier.size(14.dp)
|
Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,7 +114,7 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
|
|||||||
applicationScope.exitApplication()
|
applicationScope.exitApplication()
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
FeatherIcons.X, "Close", modifier = Modifier.size(14.dp)
|
Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,12 @@ package dev.krtirtho.spotube.core.ui.component
|
|||||||
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.window.FrameWindowScope
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
|
||||||
// LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy.
|
// LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy.
|
||||||
val LocalWindowScope = compositionLocalOf<FrameWindowScope> {
|
val LocalWindowScope = compositionLocalOf<FrameWindowScope> {
|
||||||
error("No Window found in LocalWindowScope")
|
error("No Window found in LocalWindowScope")
|
||||||
|
}
|
||||||
|
val LocalWindowState = compositionLocalOf<WindowState> {
|
||||||
|
error("No WindowState found in LocalWindowState")
|
||||||
}
|
}
|
||||||
@ -29,6 +29,7 @@ import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
|||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
|
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
|
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.LocalWindowState
|
||||||
import io.github.vinceglb.filekit.FileKit
|
import io.github.vinceglb.filekit.FileKit
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -66,6 +67,7 @@ fun main() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalApplicationScope provides this@application,
|
LocalApplicationScope provides this@application,
|
||||||
LocalWindowScope provides this@Window,
|
LocalWindowScope provides this@Window,
|
||||||
|
LocalWindowState provides windowState
|
||||||
) {
|
) {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
vlcjBundler = "0.1.0"
|
||||||
uniffi = "0.3.7"
|
uniffi = "0.3.7"
|
||||||
kotlinx-atomicfu = "0.33.0"
|
kotlinx-atomicfu = "0.33.0"
|
||||||
|
mokkery = "3.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
|
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
|
||||||
@ -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" }
|
vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" }
|
||||||
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
|
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
|
||||||
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
|
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
|
||||||
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
|
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
|
||||||
|
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
|
||||||
Loading…
Reference in New Issue
Block a user