mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
8 Commits
ed277f4d02
...
7f55ee64fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f55ee64fb | ||
|
|
b67a4a22b5 | ||
|
|
cfaea59e2f | ||
|
|
3448e16ed2 | ||
|
|
27e591d8a3 | ||
|
|
2af3a41bba | ||
|
|
720ac1136a | ||
|
|
eb30f98056 |
26
.github/workflows/maven-publish.yml
vendored
Normal file
26
.github/workflows/maven-publish.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# .github/workflows/publish.yml
|
||||
|
||||
name: Maven Publish
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Release build and publish
|
||||
runs-on: macOS-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
- name: Publish to MavenCentral
|
||||
run: ./gradlew publishToMavenCentral --no-configuration-cache
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
|
||||
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_SIGNING_KEY_ID }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_KEY_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_KEY_CONTENTS }}
|
||||
68
.github/workflows/spotube-release-binary.yml
vendored
68
.github/workflows/spotube-release-binary.yml
vendored
@ -106,7 +106,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -143,13 +143,6 @@ jobs:
|
||||
- name: Assemble Release
|
||||
run: ./gradlew :composeApp:assembleRelease
|
||||
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Rename APK
|
||||
run: |
|
||||
APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1)
|
||||
@ -161,6 +154,12 @@ jobs:
|
||||
name: android-apk
|
||||
path: Spotube-android-all-arch.apk
|
||||
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
|
||||
build-linux:
|
||||
name: Linux Desktop
|
||||
needs: prepare-deps
|
||||
@ -169,7 +168,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -195,23 +194,14 @@ jobs:
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Package Linux
|
||||
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm :composeApp:packageReleaseAppImage
|
||||
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
timeout-minutes: 15
|
||||
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm
|
||||
|
||||
- name: Rename Linux artifacts
|
||||
run: |
|
||||
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
|
||||
RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1)
|
||||
APPIMAGE_FILE=$(find composeApp/build -name "*.AppImage" -type f | head -1)
|
||||
cp "$DEB_FILE" Spotube-linux-x86_64.deb
|
||||
cp "$RPM_FILE" Spotube-linux-x86_64.rpm
|
||||
cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
@ -225,11 +215,11 @@ jobs:
|
||||
name: linux-rpm
|
||||
path: Spotube-linux-x86_64.rpm
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: Spotube-linux-x86_64.AppImage
|
||||
limit-access-to-actor: true
|
||||
|
||||
build-windows:
|
||||
name: Windows Desktop
|
||||
@ -239,7 +229,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -259,13 +249,6 @@ jobs:
|
||||
- name: Package EXE
|
||||
run: ./gradlew :composeApp:packageReleaseExe
|
||||
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Rename Windows artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
@ -278,6 +261,12 @@ jobs:
|
||||
name: windows-exe
|
||||
path: Spotube-windows-x86_64-setup.exe
|
||||
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
|
||||
build-macos:
|
||||
name: macOS Desktop
|
||||
needs: prepare-deps
|
||||
@ -286,7 +275,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -309,13 +298,6 @@ jobs:
|
||||
- name: Package DMG
|
||||
run: ./gradlew :composeApp:packageReleaseDmg
|
||||
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Rename macOS artifact
|
||||
run: |
|
||||
DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1)
|
||||
@ -327,6 +309,12 @@ jobs:
|
||||
name: macos-dmg
|
||||
path: Spotube-macos-universal.dmg
|
||||
|
||||
- name: Setup tmate session on failure
|
||||
if: ${{ inputs.debug_ssh && failure() }}
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
with:
|
||||
limit-access-to-actor: true
|
||||
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
@ -358,7 +346,7 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
|
||||
@ -45,6 +45,7 @@ actual val platformModules = module {
|
||||
collectionPlaybackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
settingsRepository = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||
@ -52,6 +53,7 @@ class MediaBrowseHelper(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@ -532,7 +534,18 @@ class MediaBrowseHelper(
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
|
||||
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
|
||||
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
|
||||
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0)
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import dev.krtirtho.spotube.MainActivity
|
||||
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.QueueEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -55,7 +56,7 @@ import org.koin.core.component.inject
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
|
||||
private val audioPlayer: AudioPlayer by inject()
|
||||
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
|
||||
private val audioPlayerQueue: AudioPlayerQueue by inject()
|
||||
private val mediaBrowseHelper: MediaBrowseHelper by inject()
|
||||
private lateinit var librarySession: MediaLibrarySession
|
||||
@ -87,9 +88,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
||||
.setSessionActivity(sessionActivity)
|
||||
.build()
|
||||
librarySession =
|
||||
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
||||
.setSessionActivity(sessionActivity)
|
||||
.build()
|
||||
|
||||
addSession(librarySession)
|
||||
|
||||
@ -112,7 +114,8 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = librarySession
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
||||
librarySession
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "onDestroy")
|
||||
@ -270,7 +273,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
} else {
|
||||
results
|
||||
}
|
||||
Log.d(TAG, "onGetSearchResult: query=$query, page=$page, returning ${paged.size} items")
|
||||
Log.d(
|
||||
TAG,
|
||||
"onGetSearchResult: query=$query, page=$page, returning ${paged.size} items"
|
||||
)
|
||||
future.set(LibraryResult.ofItemList(paged, params))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "onGetSearchResult failed for $query", e)
|
||||
@ -352,17 +358,20 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
helper.playPlaylist(playlistId)
|
||||
return
|
||||
}
|
||||
|
||||
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> {
|
||||
val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:")
|
||||
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
||||
helper.playAlbum(albumId)
|
||||
return
|
||||
}
|
||||
|
||||
mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> {
|
||||
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
||||
helper.playSavedTracks()
|
||||
return
|
||||
}
|
||||
|
||||
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ARTIST_TRACKS}:") -> {
|
||||
mediaBrowseHelper.resolveAndPlayFromMediaId(mediaId)
|
||||
return
|
||||
@ -384,17 +393,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> = withContext(Dispatchers.Main) {
|
||||
val count = audioPlayer.player.mediaItemCount
|
||||
val items = mutableListOf<Media3MediaItem>()
|
||||
for (i in 0 until count) {
|
||||
val item = audioPlayer.player.getMediaItemAt(i)
|
||||
if (item != null) {
|
||||
private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> =
|
||||
withContext(Dispatchers.Main) {
|
||||
val count = audioPlayer.player.mediaItemCount
|
||||
val items = mutableListOf<Media3MediaItem>()
|
||||
for (i in 0 until count) {
|
||||
val item = audioPlayer.player.getMediaItemAt(i)
|
||||
items.add(item)
|
||||
}
|
||||
items
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PlaybackService"
|
||||
|
||||
@ -102,6 +102,9 @@
|
||||
<string name="settings_error_whole_number">Enter a valid whole number.</string>
|
||||
<string name="settings_error_port_range">Port must be between 1 and 65535.</string>
|
||||
|
||||
<string name="settings_blacklist_title">Blacklist</string>
|
||||
<string name="settings_blacklist_subtitle">Manage blacklisted tracks and artists</string>
|
||||
|
||||
<string name="settings_desktop_minimize_title">Minimize to tray</string>
|
||||
<string name="settings_desktop_minimize_subtitle">Keep Spotube running in the system tray when the window is minimized.</string>
|
||||
<string name="settings_desktop_discord_title">Discord rich presence</string>
|
||||
|
||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -44,6 +45,7 @@ class DeviceAudioPlayerQueue(
|
||||
private val settingsProvider: SettingsProvider,
|
||||
private val repository: QueueStateRepository,
|
||||
private val pluginProvider: PluginProvider,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
@ -346,7 +348,22 @@ class DeviceAudioPlayerQueue(
|
||||
return
|
||||
}
|
||||
|
||||
val newEntries = recommendations.map { track ->
|
||||
val blacklistedTrackIds = blacklistRepository.blacklistedTracks.first().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.blacklistedArtists.first().map { it.id }.toSet()
|
||||
|
||||
val filteredRecommendations = recommendations.filter { track ->
|
||||
val trackBlacklisted = track.id in blacklistedTrackIds
|
||||
val artistBlacklisted = track.artists.any { it.id in blacklistedArtistIds }
|
||||
!trackBlacklisted && !artistBlacklisted
|
||||
}
|
||||
|
||||
if (filteredRecommendations.isEmpty()) {
|
||||
logger.w { "Endless playback: all recommendations were blacklisted" }
|
||||
isFetchingRecommendations = false
|
||||
return
|
||||
}
|
||||
|
||||
val newEntries = filteredRecommendations.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
|
||||
|
||||
@ -55,6 +55,12 @@ class Database(val paths: Paths) {
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() }
|
||||
)
|
||||
}
|
||||
|
||||
val blacklistDataStore: DataStore<Preferences> by lazy {
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object DatabaseKeys {
|
||||
|
||||
@ -35,6 +35,8 @@ import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||
import dev.krtirtho.spotube.modules.album.AlbumViewModel
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
||||
@ -125,12 +127,20 @@ val sharedModules = module {
|
||||
libraryRepository = get(),
|
||||
playbackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
}
|
||||
|
||||
// Saved Tracks
|
||||
singleOf(::SavedTracksRepository)
|
||||
viewModelOf(::SavedTracksViewModel)
|
||||
viewModel {
|
||||
SavedTracksViewModel(
|
||||
repository = get(),
|
||||
playbackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
}
|
||||
|
||||
// Artist
|
||||
singleOf(::ArtistRepository)
|
||||
@ -141,9 +151,14 @@ val sharedModules = module {
|
||||
libraryRepository = get(),
|
||||
savedTracksRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
}
|
||||
|
||||
// Blacklist
|
||||
singleOf(::BlacklistRepository)
|
||||
viewModelOf(::BlacklistViewModel)
|
||||
|
||||
// Album
|
||||
singleOf(::AlbumRepository)
|
||||
viewModel { (albumId: String) ->
|
||||
@ -152,8 +167,9 @@ val sharedModules = module {
|
||||
repository = get(),
|
||||
savedTracksRepository = get(),
|
||||
playbackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
libraryRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -161,7 +177,15 @@ val sharedModules = module {
|
||||
viewModelOf(::LyricsViewModel)
|
||||
|
||||
// Local playback proxy server
|
||||
singleOf(::CollectionPlaybackHelper)
|
||||
single {
|
||||
CollectionPlaybackHelper(
|
||||
albumRepository = get(),
|
||||
playlistRepository = get(),
|
||||
savedTracksRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
}
|
||||
singleOf(::MatchedTracksRepository)
|
||||
singleOf(::StreamingUrlRepository)
|
||||
singleOf(::AlternativeTracksRepository)
|
||||
@ -170,7 +194,7 @@ val sharedModules = module {
|
||||
}
|
||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||
single<AudioPlayerQueue> {
|
||||
DeviceAudioPlayerQueue(get(), get(), get(), get())
|
||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||
}
|
||||
|
||||
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
||||
|
||||
@ -20,11 +20,14 @@ package dev.krtirtho.spotube.core.navigation
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
||||
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
||||
import dev.krtirtho.spotube.modules.home.HomeScreen
|
||||
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||
import dev.krtirtho.spotube.modules.playlist.PlaylistScreen
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginScreen
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
||||
@ -33,6 +36,7 @@ import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koin.dsl.module
|
||||
import org.koin.dsl.navigation3.navigation
|
||||
|
||||
@ -54,22 +58,25 @@ sealed interface Routes : NavKey {
|
||||
data object Plugins : Routes
|
||||
|
||||
@Serializable
|
||||
data object WebView: Routes
|
||||
data object WebView : Routes
|
||||
|
||||
@Serializable
|
||||
data object Lyrics: Routes
|
||||
data object Lyrics : Routes
|
||||
|
||||
@Serializable
|
||||
data class Playlist(val playlistId: String): Routes
|
||||
data class Playlist(val playlistId: String) : Routes
|
||||
|
||||
@Serializable
|
||||
data class Artist(val artistId: String): Routes
|
||||
data class Artist(val artistId: String) : Routes
|
||||
|
||||
@Serializable
|
||||
data class Album(val albumId: String): Routes
|
||||
data class Album(val albumId: String) : Routes
|
||||
|
||||
@Serializable
|
||||
data object SavedTracks: Routes
|
||||
data object SavedTracks : Routes
|
||||
|
||||
@Serializable
|
||||
data object Blacklist : Routes
|
||||
}
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@ -94,19 +101,68 @@ val navigationModule = module {
|
||||
WebViewScreen(get())
|
||||
}
|
||||
navigation<Routes.Playlist> {
|
||||
PlaylistScreen(it.playlistId)
|
||||
PlaylistScreen(
|
||||
playlistId = it.playlistId,
|
||||
viewModel = koinViewModel(
|
||||
key = it.playlistId,
|
||||
parameters = { parametersOf(it.playlistId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
navigation<Routes.Artist> {
|
||||
ArtistScreen(it.artistId)
|
||||
ArtistScreen(
|
||||
viewModel = koinViewModel(
|
||||
key = it.artistId,
|
||||
parameters = { parametersOf(it.artistId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
navigation<Routes.Album> {
|
||||
AlbumScreen(it.albumId)
|
||||
AlbumScreen(
|
||||
albumId = it.albumId,
|
||||
viewModel = koinViewModel(
|
||||
key = it.albumId,
|
||||
parameters = { parametersOf(it.albumId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
navigation<Routes.Lyrics> {
|
||||
LyricsScreen(viewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.SavedTracks> {
|
||||
SavedTracksScreen()
|
||||
SavedTracksScreen(
|
||||
viewModel = koinViewModel(
|
||||
key = SAVED_TRACKS_COLLECTION_ID,
|
||||
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
shareService = get(),
|
||||
downloadsViewModel = get(),
|
||||
libraryRepository = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
}
|
||||
navigation<Routes.Blacklist> {
|
||||
BlacklistScreen()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -60,6 +60,7 @@ val serializersConfig = SavedStateConfiguration {
|
||||
subclass(Routes.Playlist::class, Routes.Playlist.serializer())
|
||||
subclass(Routes.Artist::class, Routes.Artist.serializer())
|
||||
subclass(Routes.Album::class, Routes.Album.serializer())
|
||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
package dev.krtirtho.spotube.core.paths
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
expect class Paths {
|
||||
expect class Paths() {
|
||||
fun getApplicationCacheDirPath(): String
|
||||
fun getApplicationDataDirPath(): String
|
||||
fun getUserDownloadsDirPath(): String
|
||||
|
||||
@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
|
||||
@ -30,6 +31,7 @@ class CollectionPlaybackHelper(
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
suspend fun playAlbum(albumId: String) {
|
||||
if (audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||
@ -185,7 +187,13 @@ class CollectionPlaybackHelper(
|
||||
pagination?.items?.let { allTracks.addAll(it) }
|
||||
}
|
||||
|
||||
return allTracks.map { track ->
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = allTracks.filter { track ->
|
||||
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
|
||||
}
|
||||
return filteredTracks.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
}
|
||||
@ -200,7 +208,16 @@ class CollectionPlaybackHelper(
|
||||
pagination?.items?.let { allTracks.addAll(it) }
|
||||
}
|
||||
|
||||
return allTracks.map { track ->
|
||||
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
|
||||
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
|
||||
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = allTracks.filter { track ->
|
||||
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
|
||||
}
|
||||
|
||||
return filteredTracks.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
}
|
||||
@ -215,11 +232,25 @@ class CollectionPlaybackHelper(
|
||||
pagination?.items?.let { allTracks.addAll(it) }
|
||||
}
|
||||
|
||||
return allTracks.map { track ->
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = allTracks.filter { track ->
|
||||
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
|
||||
}
|
||||
return filteredTracks.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTrackBlacklisted(
|
||||
track: MetadataTrack,
|
||||
blacklistedTrackIds: Set<String>,
|
||||
blacklistedArtistIds: Set<String>,
|
||||
): Boolean {
|
||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
||||
if (id.isNotBlank() && other.id.isNotBlank()) {
|
||||
return id == other.id
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@ -34,6 +35,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@ -50,17 +52,19 @@ import org.koin.compose.koinInject
|
||||
fun ArtistCard(
|
||||
artist: MetadataArtist.Basic,
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
|
||||
AvatarCard(
|
||||
title = artist.name,
|
||||
subtitle = "Artist",
|
||||
subtitle = if (isBlacklisted) "Blacklisted" else "Artist",
|
||||
imageURL = artist.thumbnails.firstOrNull()?.url,
|
||||
onClick = {
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
modifier = modifier,
|
||||
isBlacklisted = isBlacklisted,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -571,6 +571,7 @@ private fun TrackListRow(
|
||||
isSelected -> rowTheme.background.selected
|
||||
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
||||
isCurrentTrack -> rowTheme.background.focused
|
||||
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
||||
else -> rowTheme.background.normal
|
||||
}
|
||||
val highlightColor = Color.White.copy(
|
||||
|
||||
@ -51,6 +51,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
|
||||
@ -294,7 +295,7 @@ private fun buildTrackMenuItems(
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
||||
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
||||
),
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -37,6 +38,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@ -56,10 +58,18 @@ fun AvatarCard(
|
||||
imageURL: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
val backgroundColor = if (isBlacklisted) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||
.width(160.dp),
|
||||
) {
|
||||
|
||||
@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
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.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
@ -36,6 +37,7 @@ import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
@ -45,18 +47,18 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(albumId: String) {
|
||||
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||
val audioPlayer: AudioPlayer = koinInject()
|
||||
val shareService: ShareService = koinInject()
|
||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||
val libraryRepository: LibraryRepository = koinInject()
|
||||
fun AlbumScreen(
|
||||
albumId: String,
|
||||
viewModel: AlbumViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewModel = koinViewModel<AlbumViewModel>(
|
||||
key = albumId,
|
||||
parameters = { parametersOf(albumId) }
|
||||
)
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
@ -64,6 +66,8 @@ fun AlbumScreen(albumId: String) {
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
@ -77,12 +81,12 @@ fun AlbumScreen(albumId: String) {
|
||||
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,
|
||||
)
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
|
||||
@ -27,6 +27,7 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@ -36,6 +37,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -85,6 +88,7 @@ class AlbumViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val playbackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<AlbumViewModel>()
|
||||
|
||||
@ -93,6 +97,12 @@ class AlbumViewModel(
|
||||
val savedAlbumIds
|
||||
get() = libraryRepository.savedAlbumIdsFlow
|
||||
|
||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
||||
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.pluginManager.selectedMetadataPlugin
|
||||
@ -103,6 +113,12 @@ class AlbumViewModel(
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
blacklistRepository.blacklistedTracks
|
||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
blacklistRepository.blacklistedArtists
|
||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun loadInitialData() = runCatching {
|
||||
@ -220,7 +236,7 @@ class AlbumViewModel(
|
||||
}
|
||||
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
@ -229,18 +245,46 @@ class AlbumViewModel(
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
val trackIds = _blacklistedTrackIds.value
|
||||
val artistIds = _blacklistedArtistIds.value
|
||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
||||
}
|
||||
|
||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
||||
viewModelScope.launch {
|
||||
blacklistRepository.toggleTrack(track)
|
||||
}
|
||||
}
|
||||
|
||||
val savedTrackIds
|
||||
get() = savedTracksRepository.savedTracksIdsFlow
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@ 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.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
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.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
@ -85,36 +85,30 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ArtistScreen(artistId: String) {
|
||||
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||
val audioPlayer: AudioPlayer = koinInject()
|
||||
val shareService: ShareService = koinInject()
|
||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||
val libraryRepository: LibraryRepository = koinInject()
|
||||
fun ArtistScreen(
|
||||
viewModel: ArtistViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewModel = koinViewModel<ArtistViewModel>(
|
||||
key = artistId,
|
||||
parameters = { parametersOf(artistId) }
|
||||
)
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
@ -122,6 +116,8 @@ fun ArtistScreen(artistId: String) {
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
@ -139,7 +135,7 @@ fun ArtistScreen(artistId: String) {
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = false,
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
@ -191,6 +187,8 @@ fun ArtistScreen(artistId: String) {
|
||||
artist = currentState.artist,
|
||||
isSaved = savedArtistIds.contains(currentState.artist.id),
|
||||
onFollowClick = viewModel::toggleSavedArtist,
|
||||
isBlacklisted = currentState.artist.id in blacklistedArtistIds,
|
||||
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) },
|
||||
)
|
||||
}
|
||||
|
||||
@ -398,6 +396,8 @@ private fun ArtistHeaderCard(
|
||||
artist: MetadataArtist.Detailed,
|
||||
isSaved: Boolean,
|
||||
onFollowClick: () -> Unit,
|
||||
isBlacklisted: Boolean,
|
||||
onBlacklistClick: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val isCompact = maxWidth < 600.dp
|
||||
@ -432,6 +432,8 @@ private fun ArtistHeaderCard(
|
||||
ArtistHeaderActions(
|
||||
isSaved = isSaved,
|
||||
onFollowClick = onFollowClick,
|
||||
isBlacklisted = isBlacklisted,
|
||||
onBlacklistClick = onBlacklistClick,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@ -459,6 +461,8 @@ private fun ArtistHeaderCard(
|
||||
ArtistHeaderActions(
|
||||
isSaved = isSaved,
|
||||
onFollowClick = onFollowClick,
|
||||
isBlacklisted = isBlacklisted,
|
||||
onBlacklistClick = onBlacklistClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -547,6 +551,8 @@ private fun ArtistMeta(
|
||||
private fun ArtistHeaderActions(
|
||||
isSaved: Boolean,
|
||||
onFollowClick: () -> Unit,
|
||||
isBlacklisted: Boolean,
|
||||
onBlacklistClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@ -561,6 +567,23 @@ private fun ArtistHeaderActions(
|
||||
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
if (isBlacklisted) {
|
||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxUserRemove,
|
||||
contentDescription = "Remove from blacklist",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxUserRemove,
|
||||
contentDescription = "Add to blacklist",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -28,11 +28,14 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -61,6 +64,7 @@ class ArtistViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<ArtistViewModel>()
|
||||
|
||||
@ -73,6 +77,12 @@ class ArtistViewModel(
|
||||
val savedTrackIds
|
||||
get() = savedTracksRepository.savedTracksIdsFlow
|
||||
|
||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
||||
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
libraryRepository.savedArtistIdsFlow.collect {
|
||||
@ -82,6 +92,12 @@ class ArtistViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
blacklistRepository.blacklistedTracks
|
||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
blacklistRepository.blacklistedArtists
|
||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
loadOverview()
|
||||
}
|
||||
|
||||
@ -101,6 +117,28 @@ class ArtistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun isArtistBlacklisted(artistId: String): Boolean {
|
||||
return _blacklistedArtistIds.value.contains(artistId)
|
||||
}
|
||||
|
||||
fun toggleArtistBlacklist(artist: MetadataArtist) {
|
||||
viewModelScope.launch {
|
||||
blacklistRepository.toggleArtist(artist)
|
||||
}
|
||||
}
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
val trackIds = _blacklistedTrackIds.value
|
||||
val artistIds = _blacklistedArtistIds.value
|
||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
||||
}
|
||||
|
||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
||||
viewModelScope.launch {
|
||||
blacklistRepository.toggleTrack(track)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMoreAlbums() {
|
||||
val currentState = _state.value
|
||||
if (currentState !is ArtistScreenState.Loaded) return
|
||||
@ -211,17 +249,32 @@ class ArtistViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
@ -267,7 +320,7 @@ class ArtistViewModel(
|
||||
}
|
||||
}
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
@ -303,12 +356,23 @@ class ArtistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveTopTrackEntries(): List<QueueEntry> {
|
||||
private suspend fun resolveTopTrackEntries(): List<QueueEntry> {
|
||||
val currentState = _state.value
|
||||
if (currentState !is ArtistScreenState.Loaded) return emptyList()
|
||||
return currentState.topTracks.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
|
||||
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
|
||||
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
|
||||
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
|
||||
|
||||
return currentState.topTracks
|
||||
.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingmor 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.blacklist
|
||||
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
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.db.Database
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
open class BlacklistRepository(
|
||||
private val database: Database,
|
||||
) {
|
||||
companion object {
|
||||
private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks")
|
||||
private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists")
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
open val blacklistedTracks: Flow<List<MetadataTrack>> = database.blacklistDataStore.data
|
||||
.map { prefs ->
|
||||
val tracksJson = prefs[BLACKLISTED_TRACKS_KEY]
|
||||
if (tracksJson != null) {
|
||||
try {
|
||||
json.decodeFromString<List<MetadataTrack>>(tracksJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
open val blacklistedArtists: Flow<List<MetadataArtist>> = database.blacklistDataStore.data
|
||||
.map { prefs ->
|
||||
val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY]
|
||||
if (artistsJson != null) {
|
||||
try {
|
||||
json.decodeFromString<List<MetadataArtist>>(artistsJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
open suspend fun toggleTrack(track: MetadataTrack) {
|
||||
val currentTracks = getTracksSnapshot().toMutableList()
|
||||
val existingIndex = currentTracks.indexOfFirst { it.id == track.id }
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
currentTracks.removeAt(existingIndex)
|
||||
} else {
|
||||
currentTracks.add(track)
|
||||
}
|
||||
|
||||
database.blacklistDataStore.edit { prefs ->
|
||||
prefs[BLACKLISTED_TRACKS_KEY] = json.encodeToString(currentTracks)
|
||||
}
|
||||
}
|
||||
|
||||
open suspend fun toggleArtist(artist: MetadataArtist) {
|
||||
val currentArtists = getArtistsSnapshot().toMutableList()
|
||||
val existingIndex = currentArtists.indexOfFirst { it.id == artist.id }
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
currentArtists.removeAt(existingIndex)
|
||||
} else {
|
||||
currentArtists.add(artist)
|
||||
}
|
||||
|
||||
database.blacklistDataStore.edit { prefs ->
|
||||
prefs[BLACKLISTED_ARTISTS_KEY] = json.encodeToString(currentArtists)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTracksSnapshot(): List<MetadataTrack> {
|
||||
return database.blacklistDataStore.data.map { prefs ->
|
||||
val tracksJson = prefs[BLACKLISTED_TRACKS_KEY]
|
||||
if (tracksJson != null) {
|
||||
try {
|
||||
json.decodeFromString<List<MetadataTrack>>(tracksJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}.first()
|
||||
}
|
||||
|
||||
suspend fun getArtistsSnapshot(): List<MetadataArtist> {
|
||||
return database.blacklistDataStore.data.map { prefs ->
|
||||
val artistsJson = prefs[BLACKLISTED_ARTISTS_KEY]
|
||||
if (artistsJson != null) {
|
||||
try {
|
||||
json.decodeFromString<List<MetadataArtist>>(artistsJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}.first()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingmor 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.blacklist
|
||||
|
||||
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.fillMaxSize
|
||||
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.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
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.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun BlacklistScreen() {
|
||||
val viewModel = koinViewModel<BlacklistViewModel>()
|
||||
val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle()
|
||||
val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
||||
val normalizedQuery = remember(filterQuery) { filterQuery.trim().lowercase() }
|
||||
|
||||
val filteredTracks = remember(blacklistedTracks, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
blacklistedTracks
|
||||
} else {
|
||||
blacklistedTracks.filter { track ->
|
||||
val searchableText = buildString {
|
||||
append(track.title).append(' ')
|
||||
append(track.album?.title.orEmpty()).append(' ')
|
||||
append(track.artists.joinToString(" ") { it.name })
|
||||
}.lowercase()
|
||||
searchableText.contains(normalizedQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filteredArtists = remember(blacklistedArtists, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
blacklistedArtists
|
||||
} else {
|
||||
blacklistedArtists.filter { artist ->
|
||||
artist.name.lowercase().contains(normalizedQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty()
|
||||
val hasActiveFilter = normalizedQuery.isNotBlank()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text("Blacklist") },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = filterQuery,
|
||||
onValueChange = { filterQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Filter blacklist...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (isEmpty) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (hasActiveFilter) {
|
||||
"No blacklisted items match '$filterQuery'"
|
||||
} else {
|
||||
"No blacklisted items"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
bottom = 16.dp + shellBottomInset,
|
||||
),
|
||||
) {
|
||||
if (filteredTracks.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Tracks",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(filteredTracks, key = { "track_${it.id}" }) { track ->
|
||||
BlacklistedTrackRow(
|
||||
track = track,
|
||||
onRemove = { viewModel.toggleTrack(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredArtists.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Artists",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(filteredArtists, key = { "artist_${it.id}" }) { artist ->
|
||||
BlacklistedArtistRow(
|
||||
artist = artist,
|
||||
onRemove = { viewModel.toggleArtist(artist) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlacklistedTrackRow(
|
||||
track: MetadataTrack,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicCircle,
|
||||
contentDescription = "Track",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
AsyncImage(
|
||||
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.shimmerApply(),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = track.artists.joinToString(", ") { it.name },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
GhostIconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicSquareRemoveFilled,
|
||||
contentDescription = "Remove from blacklist",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlacklistedArtistRow(
|
||||
artist: MetadataArtist,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = "Artist",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val imageUrl = artist.thumbnails.firstOrNull()?.url
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = artist.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = artist.name,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = artist.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "Artist",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
GhostIconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxUserRemove,
|
||||
contentDescription = "Remove from blacklist",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingmor 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.blacklist
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BlacklistViewModel(
|
||||
private val repository: BlacklistRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val blacklistedTracks: StateFlow<List<MetadataTrack>> = repository.blacklistedTracks
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val blacklistedArtists: StateFlow<List<MetadataArtist>> = repository.blacklistedArtists
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
fun toggleTrack(track: MetadataTrack) {
|
||||
viewModelScope.launch {
|
||||
repository.toggleTrack(track)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleArtist(artist: MetadataArtist) {
|
||||
viewModelScope.launch {
|
||||
repository.toggleArtist(artist)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
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.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
@ -57,18 +58,17 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun PlaylistScreen(playlistId: String) {
|
||||
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||
val audioPlayer: AudioPlayer = koinInject()
|
||||
val shareService: ShareService = koinInject()
|
||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||
val libraryRepository: LibraryRepository = koinInject()
|
||||
fun PlaylistScreen(
|
||||
playlistId: String,
|
||||
viewModel: PlaylistViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewModel = koinViewModel<PlaylistViewModel>(
|
||||
key = playlistId,
|
||||
parameters = { parametersOf(playlistId) }
|
||||
)
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
@ -109,18 +109,20 @@ fun PlaylistScreen(playlistId: String) {
|
||||
playerState == PlayerState.PLAYING
|
||||
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
|
||||
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,
|
||||
)
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
val errorMessage = (state as? PlaylistScreenState.Error)?.message
|
||||
|
||||
@ -28,6 +28,7 @@ import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@ -37,6 +38,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -86,6 +89,7 @@ class PlaylistViewModel(
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val playbackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<PlaylistViewModel>()
|
||||
|
||||
@ -97,6 +101,12 @@ class PlaylistViewModel(
|
||||
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
||||
|
||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
||||
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.pluginManager.selectedMetadataPlugin
|
||||
@ -108,6 +118,12 @@ class PlaylistViewModel(
|
||||
loadCurrentUser()
|
||||
}
|
||||
}
|
||||
blacklistRepository.blacklistedTracks
|
||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
blacklistRepository.blacklistedArtists
|
||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun loadCurrentUser() {
|
||||
@ -265,23 +281,51 @@ class PlaylistViewModel(
|
||||
}
|
||||
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
val trackIds = _blacklistedTrackIds.value
|
||||
val artistIds = _blacklistedArtistIds.value
|
||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
||||
}
|
||||
|
||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
||||
viewModelScope.launch {
|
||||
blacklistRepository.toggleTrack(track)
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
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.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||
@ -40,6 +41,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
@ -48,23 +50,23 @@ import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.liked_tracks
|
||||
|
||||
@Composable
|
||||
fun SavedTracksScreen() {
|
||||
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||
val audioPlayer: AudioPlayer = koinInject()
|
||||
val shareService: ShareService = koinInject()
|
||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||
val libraryRepository: LibraryRepository = koinInject()
|
||||
fun SavedTracksScreen(
|
||||
viewModel: SavedTracksViewModel,
|
||||
audioPlayerQueue: AudioPlayerQueue,
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
shareService: ShareService,
|
||||
downloadsViewModel: DownloadsViewModel,
|
||||
libraryRepository: LibraryRepository,
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewModel = koinViewModel<SavedTracksViewModel>(
|
||||
key = SAVED_TRACKS_COLLECTION_ID,
|
||||
parameters = { parametersOf() }
|
||||
)
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
@ -78,12 +80,12 @@ fun SavedTracksScreen() {
|
||||
val queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet()
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = true,
|
||||
isBlacklisted = false,
|
||||
)
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = true,
|
||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
}
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
|
||||
@ -35,6 +35,7 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -43,6 +44,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.component.KoinComponent
|
||||
@ -84,6 +87,7 @@ class SavedTracksViewModel(
|
||||
private val repository: SavedTracksRepository,
|
||||
private val playbackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<SavedTracksViewModel>()
|
||||
|
||||
@ -92,6 +96,12 @@ class SavedTracksViewModel(
|
||||
val savedTrackIdsFlow: StateFlow<Set<String>>
|
||||
get() = repository.savedTracksIdsFlow
|
||||
|
||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
||||
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
@ -104,6 +114,12 @@ class SavedTracksViewModel(
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
blacklistRepository.blacklistedTracks
|
||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
blacklistRepository.blacklistedArtists
|
||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun loadInitialData() {
|
||||
@ -195,23 +211,51 @@ class SavedTracksViewModel(
|
||||
|
||||
is TrackOptionsAction.ToggleFavorite -> {}
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
val trackIds = _blacklistedTrackIds.value
|
||||
val artistIds = _blacklistedArtistIds.value
|
||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
||||
}
|
||||
|
||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
||||
viewModelScope.launch {
|
||||
blacklistRepository.toggleTrack(track)
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,6 +84,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||
@ -98,6 +100,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||
import dev.krtirtho.spotube.core.ui.component.UserCard
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
@ -120,6 +123,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
val shareService: ShareService = koinInject()
|
||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||
val libraryRepository: LibraryRepository = koinInject()
|
||||
val blacklistRepository: BlacklistRepository = koinInject()
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val selectedType = state.selectedSearchType
|
||||
val scope = rememberCoroutineScope()
|
||||
@ -128,6 +133,21 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList())
|
||||
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
val trackIds = blacklistedTracks.map { it.id }.toSet()
|
||||
val artistIds = blacklistedArtists.map { it.id }.toSet()
|
||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
||||
}
|
||||
|
||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
||||
scope.launch {
|
||||
blacklistRepository.toggleTrack(track)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
currentUserId = libraryRepository.currentUser()?.id
|
||||
}
|
||||
@ -180,7 +200,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
|
||||
is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id)
|
||||
is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track)
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
||||
is TrackOptionsAction.Share -> {
|
||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||
if (uri != null) {
|
||||
@ -206,7 +226,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = false,
|
||||
isBlacklisted = isTrackBlacklisted(track),
|
||||
)
|
||||
}
|
||||
|
||||
@ -222,20 +242,36 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = false,
|
||||
isBlacklisted = isTrackBlacklisted(track),
|
||||
)
|
||||
}
|
||||
|
||||
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
||||
scope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||
scope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
@ -315,6 +351,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
onAlbumClick = { album ->
|
||||
navigationCommands.navigateTo(Routes.Album(album.id))
|
||||
},
|
||||
onArtistsOverflowClick = { track ->
|
||||
// No-op for now, could show a dialog with all artists
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
@ -335,6 +380,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
onAlbumClick = { album ->
|
||||
navigationCommands.navigateTo(Routes.Album(album.id))
|
||||
},
|
||||
onArtistsOverflowClick = { track ->
|
||||
// No-op for now, could show a dialog with all artists
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
@ -607,6 +661,9 @@ private fun SearchAllTab(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (query.isBlank()) {
|
||||
@ -661,6 +718,9 @@ private fun SearchAllTab(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
|
||||
headerContent = {
|
||||
if (showTracks) {
|
||||
@ -745,6 +805,9 @@ private fun SearchTracksTab(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (query.isBlank()) {
|
||||
@ -771,6 +834,9 @@ private fun SearchTracksTab(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
simplified = true,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@ -105,6 +105,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
playbackSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
)
|
||||
if (settingsState != null)
|
||||
cacheSection(
|
||||
|
||||
@ -18,20 +18,27 @@
|
||||
package dev.krtirtho.spotube.modules.settings.sections
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.Cast
|
||||
import compose.icons.feathericons.Radio
|
||||
import compose.icons.feathericons.Repeat
|
||||
import compose.icons.feathericons.Server
|
||||
import compose.icons.feathericons.Sliders
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
|
||||
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
||||
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
||||
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
||||
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow
|
||||
@ -41,6 +48,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
internal fun LazyListScope.playbackSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
navigatorCommands: NavigationCommands,
|
||||
) {
|
||||
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
|
||||
val streamingQualities = availableAudioQualities(
|
||||
@ -159,6 +167,28 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
SettingCardItem(
|
||||
title = stringResource(Res.string.settings_blacklist_title),
|
||||
subtitle = stringResource(Res.string.settings_blacklist_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxForbidden,
|
||||
stringResource(Res.string.settings_blacklist_title),
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.ChevronRight,
|
||||
contentDescription = stringResource(Res.string.settings_blacklist_title),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
navigatorCommands.navigateTo(Routes.Blacklist)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
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.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.IconsaxForbidden: ImageVector
|
||||
get() {
|
||||
if (_IconsaxForbidden != null) {
|
||||
return _IconsaxForbidden!!
|
||||
}
|
||||
_IconsaxForbidden = ImageVector.Builder(
|
||||
name = "IconsaxForbidden",
|
||||
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(
|
||||
fill = SolidColor(Color.White),
|
||||
fillAlpha = 0.4f,
|
||||
strokeAlpha = 0.4f
|
||||
) {
|
||||
moveTo(14.9f, 2f)
|
||||
horizontalLineTo(9.1f)
|
||||
curveTo(8.42f, 2f, 7.46f, 2.4f, 6.98f, 2.88f)
|
||||
lineTo(2.88f, 6.98f)
|
||||
curveTo(2.4f, 7.46f, 2f, 8.42f, 2f, 9.1f)
|
||||
verticalLineTo(14.9f)
|
||||
curveTo(2f, 15.58f, 2.4f, 16.54f, 2.88f, 17.02f)
|
||||
lineTo(6.98f, 21.12f)
|
||||
curveTo(7.46f, 21.6f, 8.42f, 22f, 9.1f, 22f)
|
||||
horizontalLineTo(14.9f)
|
||||
curveTo(15.58f, 22f, 16.54f, 21.6f, 17.02f, 21.12f)
|
||||
lineTo(21.12f, 17.02f)
|
||||
curveTo(21.6f, 16.54f, 22f, 15.58f, 22f, 14.9f)
|
||||
verticalLineTo(9.1f)
|
||||
curveTo(22f, 8.42f, 21.6f, 7.46f, 21.12f, 6.98f)
|
||||
lineTo(17.02f, 2.88f)
|
||||
curveTo(16.54f, 2.4f, 15.58f, 2f, 14.9f, 2f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(3.02f, 21.751f)
|
||||
curveTo(2.83f, 21.751f, 2.64f, 21.681f, 2.49f, 21.531f)
|
||||
curveTo(2.2f, 21.241f, 2.2f, 20.761f, 2.49f, 20.471f)
|
||||
lineTo(20.47f, 2.491f)
|
||||
curveTo(20.76f, 2.201f, 21.24f, 2.201f, 21.53f, 2.491f)
|
||||
curveTo(21.82f, 2.781f, 21.82f, 3.261f, 21.53f, 3.551f)
|
||||
lineTo(3.55f, 21.531f)
|
||||
curveTo(3.41f, 21.681f, 3.22f, 21.751f, 3.02f, 21.751f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _IconsaxForbidden!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _IconsaxForbidden: ImageVector? = null
|
||||
@ -0,0 +1,129 @@
|
||||
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.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.IconsaxMusicSquareRemoveFilled: ImageVector
|
||||
get() {
|
||||
if (_IconsaxMusicSquareRemoveFilled != null) {
|
||||
return _IconsaxMusicSquareRemoveFilled!!
|
||||
}
|
||||
_IconsaxMusicSquareRemoveFilled = ImageVector.Builder(
|
||||
name = "IconsaxMusicSquareRemoveFilled",
|
||||
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(fill = SolidColor(Color.White)) {
|
||||
moveTo(21.97f, 16.33f)
|
||||
curveTo(21.25f, 15.51f, 20.18f, 15f, 19f, 15f)
|
||||
curveTo(16.79f, 15f, 15f, 16.79f, 15f, 19f)
|
||||
curveTo(15f, 19.75f, 15.21f, 20.46f, 15.58f, 21.06f)
|
||||
curveTo(15.78f, 21.4f, 16.04f, 21.71f, 16.34f, 21.97f)
|
||||
curveTo(17.04f, 22.61f, 17.97f, 23f, 19f, 23f)
|
||||
curveTo(20.46f, 23f, 21.73f, 22.22f, 22.42f, 21.06f)
|
||||
curveTo(22.79f, 20.46f, 23f, 19.75f, 23f, 19f)
|
||||
curveTo(23f, 17.98f, 22.61f, 17.04f, 21.97f, 16.33f)
|
||||
close()
|
||||
moveTo(20.6f, 20.58f)
|
||||
curveTo(20.45f, 20.73f, 20.26f, 20.8f, 20.07f, 20.8f)
|
||||
curveTo(19.88f, 20.8f, 19.69f, 20.73f, 19.54f, 20.58f)
|
||||
lineTo(19.01f, 20.05f)
|
||||
lineTo(18.46f, 20.6f)
|
||||
curveTo(18.31f, 20.75f, 18.12f, 20.82f, 17.93f, 20.82f)
|
||||
curveTo(17.74f, 20.82f, 17.55f, 20.75f, 17.4f, 20.6f)
|
||||
curveTo(17.11f, 20.31f, 17.11f, 19.83f, 17.4f, 19.54f)
|
||||
lineTo(17.95f, 18.99f)
|
||||
lineTo(17.42f, 18.46f)
|
||||
curveTo(17.13f, 18.17f, 17.13f, 17.69f, 17.42f, 17.4f)
|
||||
curveTo(17.71f, 17.11f, 18.19f, 17.11f, 18.48f, 17.4f)
|
||||
lineTo(19.01f, 17.93f)
|
||||
lineTo(19.51f, 17.43f)
|
||||
curveTo(19.8f, 17.14f, 20.28f, 17.14f, 20.57f, 17.43f)
|
||||
curveTo(20.86f, 17.72f, 20.86f, 18.2f, 20.57f, 18.49f)
|
||||
lineTo(20.07f, 18.99f)
|
||||
lineTo(20.6f, 19.52f)
|
||||
curveTo(20.89f, 19.81f, 20.89f, 20.28f, 20.6f, 20.58f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(12.23f, 12.07f)
|
||||
curveTo(11.82f, 12.07f, 11.49f, 12.4f, 11.49f, 12.81f)
|
||||
curveTo(11.49f, 13.22f, 11.82f, 13.55f, 12.23f, 13.55f)
|
||||
curveTo(12.64f, 13.55f, 12.97f, 13.22f, 12.97f, 12.81f)
|
||||
curveTo(12.97f, 12.4f, 12.64f, 12.07f, 12.23f, 12.07f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(6.76f, 13.06f)
|
||||
curveTo(6.35f, 13.06f, 6.02f, 13.39f, 6.02f, 13.8f)
|
||||
curveTo(6.02f, 14.21f, 6.35f, 14.54f, 6.76f, 14.54f)
|
||||
curveTo(7.17f, 14.54f, 7.5f, 14.21f, 7.5f, 13.8f)
|
||||
curveTo(7.5f, 13.39f, 7.17f, 13.06f, 6.76f, 13.06f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(16.19f, 2f)
|
||||
horizontalLineTo(7.81f)
|
||||
curveTo(7.53f, 2f, 7.26f, 2.01f, 7f, 2.05f)
|
||||
curveTo(3.85f, 2.34f, 2f, 4.45f, 2f, 7.81f)
|
||||
verticalLineTo(16.19f)
|
||||
curveTo(2f, 19.55f, 3.85f, 21.66f, 7f, 21.95f)
|
||||
curveTo(7.26f, 21.99f, 7.53f, 22f, 7.81f, 22f)
|
||||
horizontalLineTo(13.5f)
|
||||
curveTo(13.89f, 22f, 14.14f, 21.56f, 13.99f, 21.2f)
|
||||
curveTo(13.7f, 20.51f, 13.5f, 19.71f, 13.5f, 19f)
|
||||
curveTo(13.5f, 15.97f, 15.97f, 13.5f, 19f, 13.5f)
|
||||
curveTo(19.76f, 13.5f, 20.5f, 13.65f, 21.18f, 13.95f)
|
||||
curveTo(21.55f, 14.11f, 22f, 13.86f, 22f, 13.46f)
|
||||
verticalLineTo(7.81f)
|
||||
curveTo(22f, 4.17f, 19.83f, 2f, 16.19f, 2f)
|
||||
close()
|
||||
moveTo(14.48f, 8.05f)
|
||||
verticalLineTo(12.81f)
|
||||
curveTo(14.48f, 12.82f, 14.47f, 12.83f, 14.47f, 12.85f)
|
||||
curveTo(14.45f, 14.07f, 13.46f, 15.06f, 12.23f, 15.06f)
|
||||
curveTo(10.99f, 15.06f, 9.99f, 14.05f, 9.99f, 12.82f)
|
||||
curveTo(9.99f, 11.58f, 11f, 10.58f, 12.23f, 10.58f)
|
||||
curveTo(12.49f, 10.58f, 12.74f, 10.63f, 12.98f, 10.72f)
|
||||
verticalLineTo(9.03f)
|
||||
lineTo(9.01f, 10.11f)
|
||||
verticalLineTo(13.81f)
|
||||
curveTo(9.01f, 13.82f, 9f, 13.83f, 9f, 13.84f)
|
||||
curveTo(8.98f, 15.06f, 7.99f, 16.04f, 6.76f, 16.04f)
|
||||
curveTo(5.52f, 16.04f, 4.52f, 15.03f, 4.52f, 13.8f)
|
||||
curveTo(4.52f, 12.57f, 5.53f, 11.56f, 6.76f, 11.56f)
|
||||
curveTo(7.02f, 11.56f, 7.27f, 11.61f, 7.5f, 11.7f)
|
||||
verticalLineTo(9.54f)
|
||||
verticalLineTo(7.79f)
|
||||
curveTo(7.5f, 6.86f, 8.08f, 6.11f, 8.97f, 5.87f)
|
||||
lineTo(11.95f, 5.05f)
|
||||
curveTo(12.88f, 4.8f, 13.45f, 5.05f, 13.77f, 5.29f)
|
||||
curveTo(14.23f, 5.64f, 14.46f, 6.21f, 14.46f, 6.97f)
|
||||
verticalLineTo(8.05f)
|
||||
horizontalLineTo(14.48f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _IconsaxMusicSquareRemoveFilled!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _IconsaxMusicSquareRemoveFilled: ImageVector? = null
|
||||
@ -0,0 +1,98 @@
|
||||
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.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.IconsaxUserRemove: ImageVector
|
||||
get() {
|
||||
if (_IconsaxUserRemove != null) {
|
||||
return _IconsaxUserRemove!!
|
||||
}
|
||||
_IconsaxUserRemove = ImageVector.Builder(
|
||||
name = "IconsaxUserRemove",
|
||||
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(
|
||||
fill = SolidColor(Color.White),
|
||||
fillAlpha = 0.4f,
|
||||
strokeAlpha = 0.4f
|
||||
) {
|
||||
moveTo(21.09f, 21.5f)
|
||||
curveTo(21.09f, 21.78f, 20.87f, 22f, 20.59f, 22f)
|
||||
horizontalLineTo(3.41f)
|
||||
curveTo(3.13f, 22f, 2.91f, 21.78f, 2.91f, 21.5f)
|
||||
curveTo(2.91f, 17.36f, 6.99f, 14f, 12f, 14f)
|
||||
curveTo(13.03f, 14f, 14.03f, 14.14f, 14.95f, 14.41f)
|
||||
curveTo(14.36f, 15.11f, 14f, 16.02f, 14f, 17f)
|
||||
curveTo(14f, 17.75f, 14.21f, 18.46f, 14.58f, 19.06f)
|
||||
curveTo(14.78f, 19.4f, 15.04f, 19.71f, 15.34f, 19.97f)
|
||||
curveTo(16.04f, 20.61f, 16.97f, 21f, 18f, 21f)
|
||||
curveTo(19.12f, 21f, 20.13f, 20.54f, 20.85f, 19.8f)
|
||||
curveTo(21.01f, 20.34f, 21.09f, 20.91f, 21.09f, 21.5f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(21.88f, 16.04f)
|
||||
curveTo(21.78f, 15.65f, 21.62f, 15.26f, 21.4f, 14.91f)
|
||||
curveTo(21.25f, 14.65f, 21.05f, 14.4f, 20.83f, 14.17f)
|
||||
curveTo(20.11f, 13.45f, 19.17f, 13.06f, 18.21f, 13.01f)
|
||||
curveTo(17.12f, 12.94f, 16.01f, 13.34f, 15.17f, 14.17f)
|
||||
curveTo(14.38f, 14.96f, 13.98f, 16.01f, 14f, 17.06f)
|
||||
curveTo(14.01f, 18.06f, 14.41f, 19.06f, 15.17f, 19.83f)
|
||||
curveTo(15.7f, 20.36f, 16.35f, 20.71f, 17.04f, 20.87f)
|
||||
curveTo(17.42f, 20.97f, 17.82f, 21.01f, 18.22f, 20.98f)
|
||||
curveTo(19.17f, 20.94f, 20.1f, 20.56f, 20.83f, 19.83f)
|
||||
curveTo(21.86f, 18.8f, 22.21f, 17.35f, 21.88f, 16.04f)
|
||||
close()
|
||||
moveTo(19.6f, 18.6f)
|
||||
curveTo(19.31f, 18.89f, 18.83f, 18.89f, 18.54f, 18.6f)
|
||||
lineTo(17.99f, 18.05f)
|
||||
lineTo(17.46f, 18.58f)
|
||||
curveTo(17.17f, 18.87f, 16.69f, 18.87f, 16.4f, 18.58f)
|
||||
curveTo(16.11f, 18.28f, 16.11f, 17.81f, 16.4f, 17.52f)
|
||||
lineTo(16.93f, 16.99f)
|
||||
lineTo(16.42f, 16.49f)
|
||||
curveTo(16.13f, 16.19f, 16.13f, 15.72f, 16.42f, 15.42f)
|
||||
curveTo(16.72f, 15.13f, 17.19f, 15.13f, 17.49f, 15.42f)
|
||||
lineTo(17.99f, 15.93f)
|
||||
lineTo(18.52f, 15.4f)
|
||||
curveTo(18.81f, 15.11f, 19.28f, 15.11f, 19.58f, 15.4f)
|
||||
curveTo(19.87f, 15.69f, 19.87f, 16.17f, 19.58f, 16.46f)
|
||||
lineTo(19.05f, 16.99f)
|
||||
lineTo(19.6f, 17.54f)
|
||||
curveTo(19.89f, 17.83f, 19.89f, 18.31f, 19.6f, 18.6f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(12f, 12f)
|
||||
curveTo(14.761f, 12f, 17f, 9.761f, 17f, 7f)
|
||||
curveTo(17f, 4.239f, 14.761f, 2f, 12f, 2f)
|
||||
curveTo(9.239f, 2f, 7f, 4.239f, 7f, 7f)
|
||||
curveTo(7f, 9.761f, 9.239f, 12f, 12f, 12f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _IconsaxUserRemove!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _IconsaxUserRemove: ImageVector? = null
|
||||
@ -28,6 +28,7 @@ class DeviceAudioPlayerQueueTest {
|
||||
private lateinit var fakeSettings: FakeSettingsProvider
|
||||
private lateinit var fakeRepo: FakeAudioPlayerQueueRepository
|
||||
private lateinit var fakePlugin: FakePluginProvider
|
||||
private lateinit var fakeBlacklist: FakeBlacklistRepository
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
@ -40,6 +41,7 @@ class DeviceAudioPlayerQueueTest {
|
||||
fakeSettings = FakeSettingsProvider()
|
||||
fakeRepo = FakeAudioPlayerQueueRepository()
|
||||
fakePlugin = FakePluginProvider()
|
||||
fakeBlacklist = FakeBlacklistRepository()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
@ -48,7 +50,7 @@ class DeviceAudioPlayerQueueTest {
|
||||
}
|
||||
|
||||
private fun createQueue(): DeviceAudioPlayerQueue {
|
||||
return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin)
|
||||
return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin, fakeBlacklist)
|
||||
}
|
||||
|
||||
private fun streamingTrack(
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
package dev.krtirtho.spotube.core.audioplayer
|
||||
|
||||
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.db.Database
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class FakeBlacklistRepository : BlacklistRepository(
|
||||
database = Database(Paths())
|
||||
) {
|
||||
private val _blacklistedTracks = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
||||
override val blacklistedTracks: Flow<List<MetadataTrack>> = _blacklistedTracks.asStateFlow()
|
||||
|
||||
private val _blacklistedArtists = MutableStateFlow<List<MetadataArtist>>(emptyList())
|
||||
override val blacklistedArtists: Flow<List<MetadataArtist>> = _blacklistedArtists.asStateFlow()
|
||||
|
||||
override suspend fun toggleTrack(track: MetadataTrack) {
|
||||
val current = _blacklistedTracks.value.toMutableList()
|
||||
val idx = current.indexOfFirst { it.id == track.id }
|
||||
if (idx >= 0) current.removeAt(idx) else current.add(track)
|
||||
_blacklistedTracks.value = current
|
||||
}
|
||||
|
||||
override suspend fun toggleArtist(artist: MetadataArtist) {
|
||||
val current = _blacklistedArtists.value.toMutableList()
|
||||
val idx = current.indexOfFirst { it.id == artist.id }
|
||||
if (idx >= 0) current.removeAt(idx) else current.add(artist)
|
||||
_blacklistedArtists.value = current
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import java.awt.BorderLayout
|
||||
@ -38,8 +40,6 @@ import java.awt.Image
|
||||
import java.awt.SystemTray
|
||||
import java.awt.Toolkit
|
||||
import java.awt.TrayIcon
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.WindowAdapter
|
||||
@ -50,10 +50,8 @@ import javax.swing.BoxLayout
|
||||
import javax.swing.JCheckBoxMenuItem
|
||||
import javax.swing.JDialog
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.JMenu
|
||||
import javax.swing.JMenuItem
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JPopupMenu
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.UIManager
|
||||
|
||||
@ -71,26 +69,87 @@ class SystemTrayService(
|
||||
private var menuDialog: TrayMenuDialog? = null
|
||||
|
||||
private var windowVisible: Boolean = true
|
||||
private var onToggleWindowVisibility: (() -> Unit)? = null
|
||||
private var onExit: (() -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var started = false
|
||||
private var onToggleWindowVisibility: () -> Unit = {}
|
||||
private var onExit: () -> Unit = {}
|
||||
|
||||
private val volumeStep = 0.05f
|
||||
private var trayEnabled = false
|
||||
|
||||
fun start(
|
||||
@Volatile
|
||||
private var currentState: TrayState? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
settingsProvider.settingsState
|
||||
.map { it?.minimizeToTray ?: false }
|
||||
.distinctUntilChanged()
|
||||
.collect { enabled ->
|
||||
trayEnabled = enabled
|
||||
if (enabled) {
|
||||
ensureTrayCreated()
|
||||
} else {
|
||||
removeTray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.loopStateFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
audioPlayer.volumeFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
savedTracksRepository.savedTracksIdsFlow,
|
||||
) { values ->
|
||||
TrayState(
|
||||
playerState = values[0] as PlayerState,
|
||||
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
loopState = values[2] as LoopState,
|
||||
shuffleEnabled = values[3] as Boolean,
|
||||
volume = values[4] as Float,
|
||||
currentEntry = values[5] as QueueEntry?,
|
||||
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
|
||||
)
|
||||
}.collect { state ->
|
||||
currentState = state
|
||||
SwingUtilities.invokeLater {
|
||||
updateTooltip(state)
|
||||
menuDialog?.let { dialog ->
|
||||
val panel = dialog.contentPane.getComponent(0) as? JPanel
|
||||
if (panel != null) rebuildMenuPanel(panel, dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCallbacks(
|
||||
onToggleWindowVisibility: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
this.onToggleWindowVisibility = onToggleWindowVisibility
|
||||
this.onExit = onExit
|
||||
}
|
||||
|
||||
if (started) {
|
||||
return
|
||||
fun hideWindow() {
|
||||
if (windowVisible) {
|
||||
windowVisible = false
|
||||
onToggleWindowVisibility()
|
||||
}
|
||||
started = true
|
||||
}
|
||||
|
||||
fun showWindow() {
|
||||
if (!windowVisible) {
|
||||
windowVisible = true
|
||||
onToggleWindowVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureTrayCreated() {
|
||||
if (trayIcon != null) return
|
||||
if (!SystemTray.isSupported()) {
|
||||
logger.w { "System tray is not supported on this platform" }
|
||||
return
|
||||
@ -104,25 +163,39 @@ class SystemTrayService(
|
||||
icon.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
|
||||
onToggleWindowVisibility.invoke()
|
||||
if (windowVisible) hideWindow() else showWindow()
|
||||
} else if (e.button == MouseEvent.BUTTON3) {
|
||||
showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val systemTray = SystemTray.getSystemTray()
|
||||
systemTray.add(icon)
|
||||
this.trayIcon = icon
|
||||
|
||||
logger.i { "System tray initialized" }
|
||||
observeState()
|
||||
SystemTray.getSystemTray().add(icon)
|
||||
trayIcon = icon
|
||||
logger.i { "System tray created" }
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to initialize system tray" }
|
||||
logger.e(e) { "Failed to create system tray" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeTray() {
|
||||
menuDialog?.dispose()
|
||||
menuDialog = null
|
||||
trayIcon?.let {
|
||||
try {
|
||||
SystemTray.getSystemTray().remove(it)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to remove tray icon" }
|
||||
}
|
||||
}
|
||||
trayIcon = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
removeTray()
|
||||
}
|
||||
|
||||
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
|
||||
val dialog = menuDialog
|
||||
if (dialog != null && dialog.isVisible) {
|
||||
@ -135,7 +208,6 @@ class SystemTrayService(
|
||||
isUndecorated = true
|
||||
isAlwaysOnTop = true
|
||||
focusableWindowState = true
|
||||
setAutoRequestFocus(true)
|
||||
background = Color(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
@ -190,7 +262,7 @@ class SystemTrayService(
|
||||
panel.add(createSeparator())
|
||||
|
||||
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
|
||||
onToggleWindowVisibility?.invoke()
|
||||
if (windowVisible) hideWindow() else showWindow()
|
||||
})
|
||||
panel.add(createSeparator())
|
||||
|
||||
@ -219,18 +291,12 @@ class SystemTrayService(
|
||||
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
|
||||
|
||||
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
|
||||
listOf(loopNone, loopOne, loopAll).forEach { item ->
|
||||
item.isOpaque = true
|
||||
val normalBackground = item.background
|
||||
val normalBg = item.background
|
||||
item.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
item.background = hoverBackground
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
item.background = normalBackground
|
||||
}
|
||||
override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground }
|
||||
override fun mouseExited(e: MouseEvent) { item.background = normalBg }
|
||||
})
|
||||
}
|
||||
|
||||
@ -279,53 +345,16 @@ class SystemTrayService(
|
||||
})
|
||||
panel.add(createSeparator())
|
||||
|
||||
panel.add(createMenuItem("Exit") { onExit?.invoke() })
|
||||
panel.add(createMenuItem("Exit") { onExit() })
|
||||
|
||||
panel.revalidate()
|
||||
panel.repaint()
|
||||
dialog.pack()
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var currentState: TrayState? = null
|
||||
|
||||
private fun observeState() {
|
||||
scope.launch {
|
||||
combine(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.loopStateFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
audioPlayer.volumeFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
savedTracksRepository.savedTracksIdsFlow,
|
||||
) { values ->
|
||||
TrayState(
|
||||
playerState = values[0] as PlayerState,
|
||||
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
loopState = values[2] as LoopState,
|
||||
shuffleEnabled = values[3] as Boolean,
|
||||
volume = values[4] as Float,
|
||||
currentEntry = values[5] as QueueEntry?,
|
||||
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
|
||||
)
|
||||
}.collect { state ->
|
||||
currentState = state
|
||||
SwingUtilities.invokeLater {
|
||||
updateTooltip(state)
|
||||
menuDialog?.let { dialog ->
|
||||
val panel = dialog.contentPane.getComponent(0) as? JPanel
|
||||
if (panel != null) rebuildMenuPanel(panel, dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTooltip(state: TrayState) {
|
||||
val icon = trayIcon ?: return
|
||||
val media = state.mediaItem
|
||||
icon.toolTip = if (media != null) {
|
||||
trayIcon?.toolTip = if (media != null) {
|
||||
"Spotube - ${media.title} by ${media.artist}"
|
||||
} else {
|
||||
"Spotube"
|
||||
@ -338,20 +367,11 @@ class SystemTrayService(
|
||||
isOpaque = true
|
||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||
addActionListener { action() }
|
||||
|
||||
val normalBackground = background
|
||||
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
|
||||
val normalBg = background
|
||||
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
if (isEnabled) {
|
||||
background = hoverBackground
|
||||
}
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
background = normalBackground
|
||||
}
|
||||
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
||||
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -361,20 +381,11 @@ class SystemTrayService(
|
||||
isOpaque = true
|
||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||
addActionListener { action() }
|
||||
|
||||
val normalBackground = background
|
||||
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
|
||||
val normalBg = background
|
||||
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
if (isEnabled) {
|
||||
background = hoverBackground
|
||||
}
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
background = normalBackground
|
||||
}
|
||||
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
||||
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -396,26 +407,7 @@ class SystemTrayService(
|
||||
}
|
||||
|
||||
private fun createSeparator(): javax.swing.JSeparator {
|
||||
return javax.swing.JSeparator().apply {
|
||||
maximumSize = Dimension(Int.MAX_VALUE, 2)
|
||||
}
|
||||
}
|
||||
|
||||
fun setWindowVisible(visible: Boolean) {
|
||||
windowVisible = visible
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
menuDialog?.dispose()
|
||||
menuDialog = null
|
||||
trayIcon?.let {
|
||||
try {
|
||||
SystemTray.getSystemTray().remove(it)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to remove tray icon" }
|
||||
}
|
||||
}
|
||||
trayIcon = null
|
||||
return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) }
|
||||
}
|
||||
|
||||
private fun createTrayIconImage(): Image {
|
||||
|
||||
@ -84,52 +84,34 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
|
||||
val windowState = LocalWindowState.current
|
||||
val applicationScope = LocalApplicationScope.current
|
||||
val settingsProvider = koinInject<SettingsProvider>()
|
||||
val systemTrayService = koinInject<SystemTrayService>()
|
||||
val trayService = koinInject<SystemTrayService>()
|
||||
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
||||
val minimizeToTray = settings?.minimizeToTray ?: false
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
windowState.isMinimized = true
|
||||
}) {
|
||||
Icon(
|
||||
Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)
|
||||
)
|
||||
IconButton(onClick = { windowState.isMinimized = true }) {
|
||||
Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
windowState.placement = if (windowState.placement == WindowPlacement.Maximized) {
|
||||
WindowPlacement.Floating
|
||||
} else {
|
||||
WindowPlacement.Maximized
|
||||
}
|
||||
}) {
|
||||
IconButton(onClick = {
|
||||
windowState.placement = if (windowState.placement == WindowPlacement.Maximized)
|
||||
WindowPlacement.Floating else WindowPlacement.Maximized
|
||||
}) {
|
||||
if (windowState.placement == WindowPlacement.Floating) {
|
||||
Icon(
|
||||
Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp))
|
||||
} else {
|
||||
Icon(
|
||||
Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (minimizeToTray) {
|
||||
windowState.isMinimized = true
|
||||
systemTrayService.setWindowVisible(false)
|
||||
} else {
|
||||
applicationScope.exitApplication()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)
|
||||
)
|
||||
IconButton(onClick = {
|
||||
if (settings?.minimizeToTray == true) {
|
||||
trayService.hideWindow()
|
||||
} else {
|
||||
applicationScope.exitApplication()
|
||||
}
|
||||
}) {
|
||||
Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,9 @@ package dev.krtirtho.spotube
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
@ -68,22 +71,33 @@ fun main() {
|
||||
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
||||
val minimizeToTray = settings?.minimizeToTray ?: false
|
||||
|
||||
val systemTrayService = KoinServicesProvider.systemTrayService
|
||||
val trayService = KoinServicesProvider.systemTrayService
|
||||
|
||||
var isWindowVisible by remember { mutableStateOf(true) }
|
||||
|
||||
trayService.setCallbacks(
|
||||
onToggleWindowVisibility = { isWindowVisible = !isWindowVisible },
|
||||
onExit = {
|
||||
trayService.close()
|
||||
appScope.cancel()
|
||||
exitApplication()
|
||||
}
|
||||
)
|
||||
|
||||
Window(
|
||||
state = windowState,
|
||||
onCloseRequest = {
|
||||
if (minimizeToTray) {
|
||||
windowState.isMinimized = true
|
||||
systemTrayService.setWindowVisible(false)
|
||||
isWindowVisible = false
|
||||
} else {
|
||||
trayService.close()
|
||||
appScope.cancel()
|
||||
exitApplication()
|
||||
}
|
||||
},
|
||||
title = "Spotube",
|
||||
decoration = WindowDecoration.Undecorated(),
|
||||
visible = true,
|
||||
visible = isWindowVisible,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalApplicationScope provides this@application,
|
||||
@ -93,17 +107,5 @@ fun main() {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
systemTrayService.start(
|
||||
onToggleWindowVisibility = {
|
||||
windowState.isMinimized = !windowState.isMinimized
|
||||
systemTrayService.setWindowVisible(!windowState.isMinimized)
|
||||
},
|
||||
onExit = {
|
||||
systemTrayService.close()
|
||||
appScope.cancel()
|
||||
exitApplication()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ spotube-gradle = "0.1.0"
|
||||
cryptography = "0.6.0"
|
||||
reorderable = "3.1.0"
|
||||
vlcjBundler = "0.1.0"
|
||||
vanniktechMavenPublish = "0.37.0"
|
||||
uniffi = "0.3.7"
|
||||
kotlinx-atomicfu = "0.33.0"
|
||||
mokkery = "3.3.0"
|
||||
@ -181,4 +182,5 @@ vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "v
|
||||
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
|
||||
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
|
||||
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
|
||||
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
|
||||
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
|
||||
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
|
||||
@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.mavenPublish)
|
||||
alias(libs.plugins.vanniktechMavenPublish)
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
@ -32,7 +32,7 @@ kotlin {
|
||||
// which platforms this KMP module supports.
|
||||
// See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets
|
||||
androidLibrary {
|
||||
namespace = "dev.krtirtho.plugin_interfaces"
|
||||
namespace = "dev.krtirtho.spotube.plugin_interfaces"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
|
||||
@ -91,4 +91,39 @@ kotlin {
|
||||
|
||||
jsMain.dependencies {}
|
||||
}
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
publishToMavenCentral()
|
||||
signAllPublications()
|
||||
coordinates(
|
||||
group.toString(),
|
||||
"plugin_interfaces",
|
||||
version.toString()
|
||||
)
|
||||
pom {
|
||||
name = "plugin_interfaces"
|
||||
description = "Plugin interfaces for Spotube"
|
||||
inceptionYear = "2026"
|
||||
url = "https://github.com/KRTirtho/spotube"
|
||||
licenses {
|
||||
license {
|
||||
name = "AGPL-3.0-or-later"
|
||||
url = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
|
||||
distribution = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
|
||||
}
|
||||
}
|
||||
developers {
|
||||
developer {
|
||||
id = "KRTirtho"
|
||||
name = "Kingkor Roy Tirtho"
|
||||
url = "https://github.com/KRTirtho/"
|
||||
}
|
||||
}
|
||||
scm {
|
||||
url = "https://github.com/KRTirtho/spotube"
|
||||
connection = "scm:git:git://github.com/KRTirtho/spotube.git"
|
||||
developerConnection = "scm:git:ssh://git@github.com/KRTirtho/spotube.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user