Compare commits

..

7 Commits

Author SHA1 Message Date
dependabot[bot]
7f55ee64fb
Merge eb30f98056 into b67a4a22b5 2026-07-22 16:30:52 -06:00
Kingkor Roy Tirtho
b67a4a22b5 feat(plugin_interfaces): update Maven publishing configuration and add GitHub Actions workflow 2026-07-22 23:07:38 +06:00
Kingkor Roy Tirtho
cfaea59e2f feat(systemtray): self-manage tray lifecycle via Koin DI
SystemTrayService now observes settings internally and creates/removes
the tray icon when minimizeToTray toggles. No more external start/stop
calls from composables — eliminates infinite tray icon recreation.

- SystemTrayService init block subscribes to settings changes
- Close button uses hideWindow() when tray enabled
- Double-click uses hideWindow()/showWindow() for consistent state
- main.kt stripped of LaunchedEffect/remember tray management
- ApplicationMainBar close button checks minimizeToTray setting
2026-07-22 16:54:11 +06:00
Kingkor Roy Tirtho
3448e16ed2 feat: implement blacklist functionality for tracks and artists with UI updates 2026-07-22 16:23:02 +06:00
Kingkor Roy Tirtho
27e591d8a3 cd: remove AppImage upload steps from Linux build process 2026-07-22 01:11:53 +06:00
Kingkor Roy Tirtho
2af3a41bba cd: remove AppImage packaging and upload steps from Linux build process 2026-07-22 01:06:50 +06:00
Kingkor Roy Tirtho
720ac1136a cd: move ssh to end 2026-07-21 23:53:49 +06:00
41 changed files with 1697 additions and 334 deletions

26
.github/workflows/maven-publish.yml vendored Normal file
View 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 }}

View File

@ -143,13 +143,6 @@ jobs:
- name: Assemble Release - name: Assemble Release
run: ./gradlew :composeApp:assembleRelease 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 - name: Rename APK
run: | run: |
APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1) APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1)
@ -161,6 +154,12 @@ jobs:
name: android-apk name: android-apk
path: Spotube-android-all-arch.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: build-linux:
name: Linux Desktop name: Linux Desktop
needs: prepare-deps needs: prepare-deps
@ -195,23 +194,14 @@ jobs:
run: chmod +x gradlew run: chmod +x gradlew
- name: Package Linux - name: Package Linux
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm :composeApp:packageReleaseAppImage run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm
- 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 Linux artifacts - name: Rename Linux artifacts
run: | run: |
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1) DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build -name "*.rpm" -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 "$DEB_FILE" Spotube-linux-x86_64.deb
cp "$RPM_FILE" Spotube-linux-x86_64.rpm cp "$RPM_FILE" Spotube-linux-x86_64.rpm
cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage
- name: Upload DEB - name: Upload DEB
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -225,11 +215,11 @@ jobs:
name: linux-rpm name: linux-rpm
path: Spotube-linux-x86_64.rpm path: Spotube-linux-x86_64.rpm
- name: Upload AppImage - name: Setup tmate session on failure
uses: actions/upload-artifact@v4 if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with: with:
name: linux-appimage limit-access-to-actor: true
path: Spotube-linux-x86_64.AppImage
build-windows: build-windows:
name: Windows Desktop name: Windows Desktop
@ -259,13 +249,6 @@ jobs:
- name: Package EXE - name: Package EXE
run: ./gradlew :composeApp:packageReleaseExe 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 - name: Rename Windows artifacts
shell: pwsh shell: pwsh
run: | run: |
@ -278,6 +261,12 @@ jobs:
name: windows-exe name: windows-exe
path: Spotube-windows-x86_64-setup.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: build-macos:
name: macOS Desktop name: macOS Desktop
needs: prepare-deps needs: prepare-deps
@ -309,13 +298,6 @@ jobs:
- name: Package DMG - name: Package DMG
run: ./gradlew :composeApp:packageReleaseDmg 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 - name: Rename macOS artifact
run: | run: |
DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1) DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1)
@ -327,6 +309,12 @@ jobs:
name: macos-dmg name: macos-dmg
path: Spotube-macos-universal.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: create-release:
name: Create GitHub Release name: Create GitHub Release
if: ${{ !inputs.dry_run }} if: ${{ !inputs.dry_run }}

View File

@ -45,6 +45,7 @@ actual val platformModules = module {
collectionPlaybackHelper = get(), collectionPlaybackHelper = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
settingsRepository = get(), settingsRepository = get(),
blacklistRepository = get(),
) )
} }
} }

View File

@ -32,6 +32,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.modules.album.AlbumRepository 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.home.HomeScreenRepository
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
@ -52,6 +53,7 @@ class MediaBrowseHelper(
private val collectionPlaybackHelper: CollectionPlaybackHelper, private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val blacklistRepository: BlacklistRepository,
) { ) {
companion object { companion object {
@ -532,7 +534,18 @@ class MediaBrowseHelper(
} catch (_: Exception) { } catch (_: Exception) {
return 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()) { if (entries.isNotEmpty()) {
audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0) audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0)
} }

View File

@ -42,6 +42,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture import com.google.common.util.concurrent.SettableFuture
import dev.krtirtho.spotube.MainActivity import dev.krtirtho.spotube.MainActivity
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -55,7 +56,7 @@ import org.koin.core.component.inject
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class PlaybackService : MediaLibraryService(), KoinComponent { 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 audioPlayerQueue: AudioPlayerQueue by inject()
private val mediaBrowseHelper: MediaBrowseHelper by inject() private val mediaBrowseHelper: MediaBrowseHelper by inject()
private lateinit var librarySession: MediaLibrarySession private lateinit var librarySession: MediaLibrarySession
@ -87,9 +88,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) librarySession =
.setSessionActivity(sessionActivity) MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
.build() .setSessionActivity(sessionActivity)
.build()
addSession(librarySession) 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() { override fun onDestroy() {
Log.i(TAG, "onDestroy") Log.i(TAG, "onDestroy")
@ -270,7 +273,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
} else { } else {
results 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)) future.set(LibraryResult.ofItemList(paged, params))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "onGetSearchResult failed for $query", e) Log.e(TAG, "onGetSearchResult failed for $query", e)
@ -352,17 +358,20 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
helper.playPlaylist(playlistId) helper.playPlaylist(playlistId)
return return
} }
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> { mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> {
val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:")
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
helper.playAlbum(albumId) helper.playAlbum(albumId)
return return
} }
mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> { mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> {
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
helper.playSavedTracks() helper.playSavedTracks()
return return
} }
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ARTIST_TRACKS}:") -> { mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ARTIST_TRACKS}:") -> {
mediaBrowseHelper.resolveAndPlayFromMediaId(mediaId) mediaBrowseHelper.resolveAndPlayFromMediaId(mediaId)
return return
@ -384,17 +393,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
} }
} }
private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> = withContext(Dispatchers.Main) { private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> =
val count = audioPlayer.player.mediaItemCount withContext(Dispatchers.Main) {
val items = mutableListOf<Media3MediaItem>() val count = audioPlayer.player.mediaItemCount
for (i in 0 until count) { val items = mutableListOf<Media3MediaItem>()
val item = audioPlayer.player.getMediaItemAt(i) for (i in 0 until count) {
if (item != null) { val item = audioPlayer.player.getMediaItemAt(i)
items.add(item) items.add(item)
} }
items
} }
items
}
companion object { companion object {
private const val TAG = "PlaybackService" private const val TAG = "PlaybackService"

View File

@ -102,6 +102,9 @@
<string name="settings_error_whole_number">Enter a valid whole number.</string> <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_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_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_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> <string name="settings_desktop_discord_title">Discord rich presence</string>

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.plugin.PluginProvider import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -44,6 +45,7 @@ class DeviceAudioPlayerQueue(
private val settingsProvider: SettingsProvider, private val settingsProvider: SettingsProvider,
private val repository: QueueStateRepository, private val repository: QueueStateRepository,
private val pluginProvider: PluginProvider, private val pluginProvider: PluginProvider,
private val blacklistRepository: BlacklistRepository,
) : AudioPlayerQueue, KoinComponent { ) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
@ -346,7 +348,22 @@ class DeviceAudioPlayerQueue(
return 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 = "") QueueEntry.StreamingTrack(track = track, url = "")
} }

View File

@ -55,6 +55,12 @@ class Database(val paths: Paths) {
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() } 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 { object DatabaseKeys {

View File

@ -35,6 +35,8 @@ import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.album.AlbumViewModel import dev.krtirtho.spotube.modules.album.AlbumViewModel
import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.artist.ArtistRepository
import dev.krtirtho.spotube.modules.artist.ArtistViewModel 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.DownloadManager
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.home.HomeScreenRepository import dev.krtirtho.spotube.modules.home.HomeScreenRepository
@ -125,12 +127,20 @@ val sharedModules = module {
libraryRepository = get(), libraryRepository = get(),
playbackHelper = get(), playbackHelper = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(),
) )
} }
// Saved Tracks // Saved Tracks
singleOf(::SavedTracksRepository) singleOf(::SavedTracksRepository)
viewModelOf(::SavedTracksViewModel) viewModel {
SavedTracksViewModel(
repository = get(),
playbackHelper = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
)
}
// Artist // Artist
singleOf(::ArtistRepository) singleOf(::ArtistRepository)
@ -141,9 +151,14 @@ val sharedModules = module {
libraryRepository = get(), libraryRepository = get(),
savedTracksRepository = get(), savedTracksRepository = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(),
) )
} }
// Blacklist
singleOf(::BlacklistRepository)
viewModelOf(::BlacklistViewModel)
// Album // Album
singleOf(::AlbumRepository) singleOf(::AlbumRepository)
viewModel { (albumId: String) -> viewModel { (albumId: String) ->
@ -152,8 +167,9 @@ val sharedModules = module {
repository = get(), repository = get(),
savedTracksRepository = get(), savedTracksRepository = get(),
playbackHelper = get(), playbackHelper = get(),
audioPlayerQueue = get(),
libraryRepository = get(), libraryRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
) )
} }
@ -161,7 +177,15 @@ val sharedModules = module {
viewModelOf(::LyricsViewModel) viewModelOf(::LyricsViewModel)
// Local playback proxy server // Local playback proxy server
singleOf(::CollectionPlaybackHelper) single {
CollectionPlaybackHelper(
albumRepository = get(),
playlistRepository = get(),
savedTracksRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
)
}
singleOf(::MatchedTracksRepository) singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository) singleOf(::StreamingUrlRepository)
singleOf(::AlternativeTracksRepository) singleOf(::AlternativeTracksRepository)
@ -170,7 +194,7 @@ val sharedModules = module {
} }
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() } singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> { single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get()) DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
} }
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }

View File

@ -20,11 +20,14 @@ package dev.krtirtho.spotube.core.navigation
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.artist.ArtistScreen
import dev.krtirtho.spotube.modules.album.AlbumScreen 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.home.HomeScreen
import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.library.LibraryScreen
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
import dev.krtirtho.spotube.modules.playlist.PlaylistScreen import dev.krtirtho.spotube.modules.playlist.PlaylistScreen
import dev.krtirtho.spotube.modules.plugin.PluginScreen 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.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.search.SearchScreen
import dev.krtirtho.spotube.modules.settings.SettingsScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen
@ -33,6 +36,7 @@ import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.dsl.navigation3.navigation import org.koin.dsl.navigation3.navigation
@ -54,22 +58,25 @@ sealed interface Routes : NavKey {
data object Plugins : Routes data object Plugins : Routes
@Serializable @Serializable
data object WebView: Routes data object WebView : Routes
@Serializable @Serializable
data object Lyrics: Routes data object Lyrics : Routes
@Serializable @Serializable
data class Playlist(val playlistId: String): Routes data class Playlist(val playlistId: String) : Routes
@Serializable @Serializable
data class Artist(val artistId: String): Routes data class Artist(val artistId: String) : Routes
@Serializable @Serializable
data class Album(val albumId: String): Routes data class Album(val albumId: String) : Routes
@Serializable @Serializable
data object SavedTracks: Routes data object SavedTracks : Routes
@Serializable
data object Blacklist : Routes
} }
@OptIn(KoinExperimentalAPI::class) @OptIn(KoinExperimentalAPI::class)
@ -94,19 +101,68 @@ val navigationModule = module {
WebViewScreen(get()) WebViewScreen(get())
} }
navigation<Routes.Playlist> { 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> { 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> { 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> { navigation<Routes.Lyrics> {
LyricsScreen(viewModel = koinViewModel()) LyricsScreen(viewModel = koinViewModel())
} }
navigation<Routes.SavedTracks> { 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()
} }
} }

View File

@ -60,6 +60,7 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Playlist::class, Routes.Playlist.serializer()) subclass(Routes.Playlist::class, Routes.Playlist.serializer())
subclass(Routes.Artist::class, Routes.Artist.serializer()) subclass(Routes.Artist::class, Routes.Artist.serializer())
subclass(Routes.Album::class, Routes.Album.serializer()) subclass(Routes.Album::class, Routes.Album.serializer())
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
} }
} }
} }

View File

@ -18,7 +18,7 @@
package dev.krtirtho.spotube.core.paths package dev.krtirtho.spotube.core.paths
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect class Paths { expect class Paths() {
fun getApplicationCacheDirPath(): String fun getApplicationCacheDirPath(): String
fun getApplicationDataDirPath(): String fun getApplicationDataDirPath(): String
fun getUserDownloadsDirPath(): String fun getUserDownloadsDirPath(): String

View File

@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.album.AlbumRepository 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.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
@ -30,6 +31,7 @@ class CollectionPlaybackHelper(
private val playlistRepository: PlaylistRepository, private val playlistRepository: PlaylistRepository,
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) { ) {
suspend fun playAlbum(albumId: String) { suspend fun playAlbum(albumId: String) {
if (audioPlayerQueue.isAlbumPlaying(albumId)) return if (audioPlayerQueue.isAlbumPlaying(albumId)) return
@ -185,7 +187,13 @@ class CollectionPlaybackHelper(
pagination?.items?.let { allTracks.addAll(it) } 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 = "") QueueEntry.StreamingTrack(track = track, url = "")
} }
} }
@ -200,7 +208,16 @@ class CollectionPlaybackHelper(
pagination?.items?.let { allTracks.addAll(it) } 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 = "") QueueEntry.StreamingTrack(track = track, url = "")
} }
} }
@ -215,11 +232,25 @@ class CollectionPlaybackHelper(
pagination?.items?.let { allTracks.addAll(it) } 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 = "") 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 { private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
if (id.isNotBlank() && other.id.isNotBlank()) { if (id.isNotBlank() && other.id.isNotBlank()) {
return id == other.id return id == other.id

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -34,6 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -50,17 +52,19 @@ import org.koin.compose.koinInject
fun ArtistCard( fun ArtistCard(
artist: MetadataArtist.Basic, artist: MetadataArtist.Basic,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false,
) { ) {
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
AvatarCard( AvatarCard(
title = artist.name, title = artist.name,
subtitle = "Artist", subtitle = if (isBlacklisted) "Blacklisted" else "Artist",
imageURL = artist.thumbnails.firstOrNull()?.url, imageURL = artist.thumbnails.firstOrNull()?.url,
onClick = { onClick = {
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
modifier = modifier, modifier = modifier,
isBlacklisted = isBlacklisted,
) )
} }

View File

@ -571,6 +571,7 @@ private fun TrackListRow(
isSelected -> rowTheme.background.selected isSelected -> rowTheme.background.selected
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
isCurrentTrack -> rowTheme.background.focused isCurrentTrack -> rowTheme.background.focused
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
else -> rowTheme.background.normal else -> rowTheme.background.normal
} }
val highlightColor = Color.White.copy( val highlightColor = Color.White.copy(

View File

@ -51,6 +51,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove 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.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
@ -294,7 +295,7 @@ private fun buildTrackMenuItems(
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemove, icon = Iconsax.IconsaxMusicSquareRemoveFilled,
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }, onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
), ),

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.ui.component.cards package dev.krtirtho.spotube.core.ui.component.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -37,6 +38,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -56,10 +58,18 @@ fun AvatarCard(
imageURL: String? = null, imageURL: String? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false,
) { ) {
val backgroundColor = if (isBlacklisted) {
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
} else {
Color.Transparent
}
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(onClick = { onClick.invoke() }, enabled = true) .clickable(onClick = { onClick.invoke() }, enabled = true)
.width(160.dp), .width(160.dp),
) { ) {

View File

@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -45,18 +47,18 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@Composable @Composable
fun AlbumScreen(albumId: String) { fun AlbumScreen(
val audioPlayerQueue: AudioPlayerQueue = koinInject() albumId: String,
val audioPlayer: AudioPlayer = koinInject() viewModel: AlbumViewModel,
val shareService: ShareService = koinInject() audioPlayerQueue: AudioPlayerQueue,
val downloadsViewModel: DownloadsViewModel = koinViewModel() audioPlayer: AudioPlayerInterface,
val libraryRepository: LibraryRepository = koinInject() shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewModel = koinViewModel<AlbumViewModel>(
key = albumId,
parameters = { parametersOf(albumId) }
)
val navigationCommands = koinInject<NavigationCommands>()
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
@ -64,6 +66,8 @@ fun AlbumScreen(albumId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.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 showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
@ -77,12 +81,12 @@ fun AlbumScreen(albumId: String) {
val queueTrackIds = queue.mapNotNull { entry -> val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id (entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet() }.toSet()
return TrackOptionsState( return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id), isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = false, isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
) )
} }
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {

View File

@ -27,6 +27,7 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction 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.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -36,6 +37,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -85,6 +88,7 @@ class AlbumViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>() private val logger by injectLogger<AlbumViewModel>()
@ -93,6 +97,12 @@ class AlbumViewModel(
val savedAlbumIds val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow 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 { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -103,6 +113,12 @@ class AlbumViewModel(
loadInitialData() 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 { private suspend fun loadInitialData() = runCatching {
@ -220,7 +236,7 @@ class AlbumViewModel(
} }
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {} is TrackOptionsAction.AddToPlaylist -> {}
} }
@ -229,18 +245,46 @@ class AlbumViewModel(
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) audioPlayerQueue.addAllToQueue(entries)
} }
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) 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 val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow get() = savedTracksRepository.savedTracksIdsFlow

View File

@ -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.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree 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.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch 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 import kotlin.math.roundToInt
@Composable @Composable
fun ArtistScreen(artistId: String) { fun ArtistScreen(
val audioPlayerQueue: AudioPlayerQueue = koinInject() viewModel: ArtistViewModel,
val audioPlayer: AudioPlayer = koinInject() audioPlayerQueue: AudioPlayerQueue,
val shareService: ShareService = koinInject() audioPlayer: AudioPlayerInterface,
val downloadsViewModel: DownloadsViewModel = koinViewModel() shareService: ShareService,
val libraryRepository: LibraryRepository = koinInject() downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewModel = koinViewModel<ArtistViewModel>(
key = artistId,
parameters = { parametersOf(artistId) }
)
val navigationCommands = koinInject<NavigationCommands>()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
@ -122,6 +116,8 @@ fun ArtistScreen(artistId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.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 showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
@ -139,7 +135,7 @@ fun ArtistScreen(artistId: String) {
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id), 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, artist = currentState.artist,
isSaved = savedArtistIds.contains(currentState.artist.id), isSaved = savedArtistIds.contains(currentState.artist.id),
onFollowClick = viewModel::toggleSavedArtist, onFollowClick = viewModel::toggleSavedArtist,
isBlacklisted = currentState.artist.id in blacklistedArtistIds,
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) },
) )
} }
@ -398,6 +396,8 @@ private fun ArtistHeaderCard(
artist: MetadataArtist.Detailed, artist: MetadataArtist.Detailed,
isSaved: Boolean, isSaved: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean,
onBlacklistClick: () -> Unit,
) { ) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp val isCompact = maxWidth < 600.dp
@ -432,6 +432,8 @@ private fun ArtistHeaderCard(
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick,
) )
} }
} else { } else {
@ -459,6 +461,8 @@ private fun ArtistHeaderCard(
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick,
) )
} }
} }
@ -547,6 +551,8 @@ private fun ArtistMeta(
private fun ArtistHeaderActions( private fun ArtistHeaderActions(
isSaved: Boolean, isSaved: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean,
onBlacklistClick: () -> Unit,
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
@ -561,6 +567,23 @@ private fun ArtistHeaderActions(
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) 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",
)
}
}
} }
} }

View File

@ -28,11 +28,14 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction 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.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -61,6 +64,7 @@ class ArtistViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>() private val logger by injectLogger<ArtistViewModel>()
@ -73,6 +77,12 @@ class ArtistViewModel(
val savedTrackIds val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow 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 { init {
viewModelScope.launch { viewModelScope.launch {
libraryRepository.savedArtistIdsFlow.collect { 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() 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() { fun loadMoreAlbums() {
val currentState = _state.value val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return if (currentState !is ArtistScreenState.Loaded) return
@ -211,17 +249,32 @@ class ArtistViewModel(
) )
} }
} }
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) audioPlayerQueue.addAllToQueue(entries)
} }
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) audioPlayerQueue.addAllAfterCurrent(entries)
} }
} }
@ -267,7 +320,7 @@ class ArtistViewModel(
} }
} }
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {} is TrackOptionsAction.AddToPlaylist -> {}
} }
@ -303,12 +356,23 @@ class ArtistViewModel(
} }
} }
private fun resolveTopTrackEntries(): List<QueueEntry> { private suspend fun resolveTopTrackEntries(): List<QueueEntry> {
val currentState = _state.value val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return emptyList() 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 { private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {

View File

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

View File

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

View File

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

View File

@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
@ -57,18 +58,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@Composable @Composable
fun PlaylistScreen(playlistId: String) { fun PlaylistScreen(
val audioPlayerQueue: AudioPlayerQueue = koinInject() playlistId: String,
val audioPlayer: AudioPlayer = koinInject() viewModel: PlaylistViewModel,
val shareService: ShareService = koinInject() audioPlayerQueue: AudioPlayerQueue,
val downloadsViewModel: DownloadsViewModel = koinViewModel() audioPlayer: AudioPlayerInterface,
val libraryRepository: LibraryRepository = koinInject() shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewModel = koinViewModel<PlaylistViewModel>(
key = playlistId,
parameters = { parametersOf(playlistId) }
)
val navigationCommands = koinInject<NavigationCommands>()
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
@ -109,18 +109,20 @@ fun PlaylistScreen(playlistId: String) {
playerState == PlayerState.PLAYING playerState == PlayerState.PLAYING
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry -> val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id (entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet() }.toSet()
return TrackOptionsState( return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id), isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = false, isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
) )
} }
val errorMessage = (state as? PlaylistScreenState.Error)?.message val errorMessage = (state as? PlaylistScreenState.Error)?.message

View File

@ -28,6 +28,7 @@ import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -37,6 +38,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -86,6 +89,7 @@ class PlaylistViewModel(
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<PlaylistViewModel>() private val logger by injectLogger<PlaylistViewModel>()
@ -97,6 +101,12 @@ class PlaylistViewModel(
private val _currentUserId = MutableStateFlow<String?>(null) private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -108,6 +118,12 @@ class PlaylistViewModel(
loadCurrentUser() 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() { private suspend fun loadCurrentUser() {
@ -265,23 +281,51 @@ class PlaylistViewModel(
} }
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {} 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>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) audioPlayerQueue.addAllToQueue(entries)
} }
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) audioPlayerQueue.addAllAfterCurrent(entries)
} }
} }

View File

@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry 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.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -48,23 +50,23 @@ import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.liked_tracks import spotube.composeapp.generated.resources.liked_tracks
@Composable @Composable
fun SavedTracksScreen() { fun SavedTracksScreen(
val audioPlayerQueue: AudioPlayerQueue = koinInject() viewModel: SavedTracksViewModel,
val audioPlayer: AudioPlayer = koinInject() audioPlayerQueue: AudioPlayerQueue,
val shareService: ShareService = koinInject() audioPlayer: AudioPlayerInterface,
val downloadsViewModel: DownloadsViewModel = koinViewModel() shareService: ShareService,
val libraryRepository: LibraryRepository = koinInject() downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope() 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 state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.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 showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
@ -78,12 +80,12 @@ fun SavedTracksScreen() {
val queueTrackIds = queue.mapNotNull { entry -> val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id (entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet() }.toSet()
return TrackOptionsState( return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = true, isFavorite = true,
isBlacklisted = false, isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
) )
} }
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {

View File

@ -35,6 +35,7 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -43,6 +44,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -84,6 +87,7 @@ class SavedTracksViewModel(
private val repository: SavedTracksRepository, private val repository: SavedTracksRepository,
private val playbackHelper: CollectionPlaybackHelper, private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val logger by injectLogger<SavedTracksViewModel>() private val logger by injectLogger<SavedTracksViewModel>()
@ -92,6 +96,12 @@ class SavedTracksViewModel(
val savedTrackIdsFlow: StateFlow<Set<String>> val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow 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 { init {
viewModelScope.launch { viewModelScope.launch {
combine( combine(
@ -104,6 +114,12 @@ class SavedTracksViewModel(
loadInitialData() 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() { private suspend fun loadInitialData() {
@ -195,23 +211,51 @@ class SavedTracksViewModel(
is TrackOptionsAction.ToggleFavorite -> {} is TrackOptionsAction.ToggleFavorite -> {}
is TrackOptionsAction.Download -> {} is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {} is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {} 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>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) audioPlayerQueue.addAllToQueue(entries)
} }
} }
fun playTracksNext(tracks: List<MetadataTrack>) { fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch { 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) audioPlayerQueue.addAllAfterCurrent(entries)
} }
} }

View File

@ -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.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
import dev.krtirtho.spotube.core.ui.base.ChipTab 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.UserCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree 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.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -120,6 +123,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject() val libraryRepository: LibraryRepository = koinInject()
val blacklistRepository: BlacklistRepository = koinInject()
val navigationCommands: NavigationCommands = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val selectedType = state.selectedSearchType val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -128,6 +133,21 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
val 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) { LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id currentUserId = libraryRepository.currentUser()?.id
} }
@ -180,7 +200,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id) is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id)
is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track) is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track)
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> { is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() } val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) { if (uri != null) {
@ -206,7 +226,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id), isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = false, isBlacklisted = isTrackBlacklisted(track),
) )
} }
@ -222,20 +242,36 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id), isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = false, isBlacklisted = isTrackBlacklisted(track),
) )
} }
fun bulkAddToQueue(tracks: List<MetadataTrack>) { fun bulkAddToQueue(tracks: List<MetadataTrack>) {
scope.launch { 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) audioPlayerQueue.addAllToQueue(entries)
} }
} }
fun bulkPlayNext(tracks: List<MetadataTrack>) { fun bulkPlayNext(tracks: List<MetadataTrack>) {
scope.launch { 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) audioPlayerQueue.addAllAfterCurrent(entries)
} }
} }
@ -315,6 +351,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true 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 modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
) )
@ -335,6 +380,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true 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 modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
) )
@ -607,6 +661,9 @@ private fun SearchAllTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (query.isBlank()) { if (query.isBlank()) {
@ -661,6 +718,9 @@ private fun SearchAllTab(
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
headerContent = { headerContent = {
if (showTracks) { if (showTracks) {
@ -745,6 +805,9 @@ private fun SearchTracksTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (query.isBlank()) { if (query.isBlank()) {
@ -771,6 +834,9 @@ private fun SearchTracksTab(
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
simplified = true, simplified = true,
modifier = modifier, modifier = modifier,
) )

View File

@ -105,6 +105,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
playbackSection( playbackSection(
settings = settingsState!!, settings = settingsState!!,
settingsViewModel = settingsViewModel, settingsViewModel = settingsViewModel,
navigatorCommands = navigatorCommands,
) )
if (settingsState != null) if (settingsState != null)
cacheSection( cacheSection(

View File

@ -18,20 +18,27 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.Cast import compose.icons.feathericons.Cast
import compose.icons.feathericons.Radio import compose.icons.feathericons.Radio
import compose.icons.feathericons.Repeat import compose.icons.feathericons.Repeat
import compose.icons.feathericons.Server import compose.icons.feathericons.Server
import compose.icons.feathericons.Sliders import compose.icons.feathericons.Sliders
import spotube.composeapp.generated.resources.* 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.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard 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.SwitchSettingCard
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax 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.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow
@ -41,6 +48,7 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.playbackSection( internal fun LazyListScope.playbackSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
navigatorCommands: NavigationCommands,
) { ) {
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
val streamingQualities = availableAudioQualities( 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)
},
)
},
) )
) )
} }

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ class DeviceAudioPlayerQueueTest {
private lateinit var fakeSettings: FakeSettingsProvider private lateinit var fakeSettings: FakeSettingsProvider
private lateinit var fakeRepo: FakeAudioPlayerQueueRepository private lateinit var fakeRepo: FakeAudioPlayerQueueRepository
private lateinit var fakePlugin: FakePluginProvider private lateinit var fakePlugin: FakePluginProvider
private lateinit var fakeBlacklist: FakeBlacklistRepository
@BeforeTest @BeforeTest
fun setup() { fun setup() {
@ -40,6 +41,7 @@ class DeviceAudioPlayerQueueTest {
fakeSettings = FakeSettingsProvider() fakeSettings = FakeSettingsProvider()
fakeRepo = FakeAudioPlayerQueueRepository() fakeRepo = FakeAudioPlayerQueueRepository()
fakePlugin = FakePluginProvider() fakePlugin = FakePluginProvider()
fakeBlacklist = FakeBlacklistRepository()
} }
@AfterTest @AfterTest
@ -48,7 +50,7 @@ class DeviceAudioPlayerQueueTest {
} }
private fun createQueue(): DeviceAudioPlayerQueue { private fun createQueue(): DeviceAudioPlayerQueue {
return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin) return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin, fakeBlacklist)
} }
private fun streamingTrack( private fun streamingTrack(

View File

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

View File

@ -29,6 +29,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import java.awt.BorderLayout import java.awt.BorderLayout
@ -38,8 +40,6 @@ import java.awt.Image
import java.awt.SystemTray import java.awt.SystemTray
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.TrayIcon import java.awt.TrayIcon
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.awt.event.WindowAdapter import java.awt.event.WindowAdapter
@ -50,10 +50,8 @@ import javax.swing.BoxLayout
import javax.swing.JCheckBoxMenuItem import javax.swing.JCheckBoxMenuItem
import javax.swing.JDialog import javax.swing.JDialog
import javax.swing.JLabel import javax.swing.JLabel
import javax.swing.JMenu
import javax.swing.JMenuItem import javax.swing.JMenuItem
import javax.swing.JPanel import javax.swing.JPanel
import javax.swing.JPopupMenu
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import javax.swing.UIManager import javax.swing.UIManager
@ -71,26 +69,87 @@ class SystemTrayService(
private var menuDialog: TrayMenuDialog? = null private var menuDialog: TrayMenuDialog? = null
private var windowVisible: Boolean = true private var windowVisible: Boolean = true
private var onToggleWindowVisibility: (() -> Unit)? = null
private var onExit: (() -> Unit)? = null
@Volatile private var onToggleWindowVisibility: () -> Unit = {}
private var started = false private var onExit: () -> Unit = {}
private val volumeStep = 0.05f 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, onToggleWindowVisibility: () -> Unit,
onExit: () -> Unit, onExit: () -> Unit,
) { ) {
this.onToggleWindowVisibility = onToggleWindowVisibility this.onToggleWindowVisibility = onToggleWindowVisibility
this.onExit = onExit this.onExit = onExit
}
if (started) { fun hideWindow() {
return if (windowVisible) {
windowVisible = false
onToggleWindowVisibility()
} }
started = true }
fun showWindow() {
if (!windowVisible) {
windowVisible = true
onToggleWindowVisibility()
}
}
private fun ensureTrayCreated() {
if (trayIcon != null) return
if (!SystemTray.isSupported()) { if (!SystemTray.isSupported()) {
logger.w { "System tray is not supported on this platform" } logger.w { "System tray is not supported on this platform" }
return return
@ -104,25 +163,39 @@ class SystemTrayService(
icon.addMouseListener(object : MouseAdapter() { icon.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { override fun mouseClicked(e: MouseEvent) {
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) { if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
onToggleWindowVisibility.invoke() if (windowVisible) hideWindow() else showWindow()
} else if (e.button == MouseEvent.BUTTON3) { } else if (e.button == MouseEvent.BUTTON3) {
showMenuDialog(icon, e.xOnScreen, e.yOnScreen) showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
} }
} }
}) })
val systemTray = SystemTray.getSystemTray() SystemTray.getSystemTray().add(icon)
systemTray.add(icon) trayIcon = icon
this.trayIcon = icon logger.i { "System tray created" }
logger.i { "System tray initialized" }
observeState()
} catch (e: Exception) { } 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) { private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
val dialog = menuDialog val dialog = menuDialog
if (dialog != null && dialog.isVisible) { if (dialog != null && dialog.isVisible) {
@ -135,7 +208,6 @@ class SystemTrayService(
isUndecorated = true isUndecorated = true
isAlwaysOnTop = true isAlwaysOnTop = true
focusableWindowState = true focusableWindowState = true
setAutoRequestFocus(true)
background = Color(0, 0, 0, 0) background = Color(0, 0, 0, 0)
} }
@ -190,7 +262,7 @@ class SystemTrayService(
panel.add(createSeparator()) panel.add(createSeparator())
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") { panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
onToggleWindowVisibility?.invoke() if (windowVisible) hideWindow() else showWindow()
}) })
panel.add(createSeparator()) panel.add(createSeparator())
@ -219,18 +291,12 @@ class SystemTrayService(
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL) val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175) val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
listOf(loopNone, loopOne, loopAll).forEach { item -> listOf(loopNone, loopOne, loopAll).forEach { item ->
item.isOpaque = true item.isOpaque = true
val normalBackground = item.background val normalBg = item.background
item.addMouseListener(object : MouseAdapter() { item.addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground }
item.background = hoverBackground override fun mouseExited(e: MouseEvent) { item.background = normalBg }
}
override fun mouseExited(e: MouseEvent) {
item.background = normalBackground
}
}) })
} }
@ -279,53 +345,16 @@ class SystemTrayService(
}) })
panel.add(createSeparator()) panel.add(createSeparator())
panel.add(createMenuItem("Exit") { onExit?.invoke() }) panel.add(createMenuItem("Exit") { onExit() })
panel.revalidate() panel.revalidate()
panel.repaint() panel.repaint()
dialog.pack() 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) { private fun updateTooltip(state: TrayState) {
val icon = trayIcon ?: return
val media = state.mediaItem val media = state.mediaItem
icon.toolTip = if (media != null) { trayIcon?.toolTip = if (media != null) {
"Spotube - ${media.title} by ${media.artist}" "Spotube - ${media.title} by ${media.artist}"
} else { } else {
"Spotube" "Spotube"
@ -338,20 +367,11 @@ class SystemTrayService(
isOpaque = true isOpaque = true
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
addActionListener { action() } addActionListener { action() }
val normalBg = background
val normalBackground = background val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() { addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
if (isEnabled) { override fun mouseExited(e: MouseEvent) { background = normalBg }
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
}) })
} }
} }
@ -361,20 +381,11 @@ class SystemTrayService(
isOpaque = true isOpaque = true
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height) maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
addActionListener { action() } addActionListener { action() }
val normalBg = background
val normalBackground = background val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() { addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) { override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
if (isEnabled) { override fun mouseExited(e: MouseEvent) { background = normalBg }
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
}) })
} }
} }
@ -396,26 +407,7 @@ class SystemTrayService(
} }
private fun createSeparator(): javax.swing.JSeparator { private fun createSeparator(): javax.swing.JSeparator {
return javax.swing.JSeparator().apply { return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) }
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
} }
private fun createTrayIconImage(): Image { private fun createTrayIconImage(): Image {

View File

@ -84,52 +84,34 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
val windowState = LocalWindowState.current val windowState = LocalWindowState.current
val applicationScope = LocalApplicationScope.current val applicationScope = LocalApplicationScope.current
val settingsProvider = koinInject<SettingsProvider>() val settingsProvider = koinInject<SettingsProvider>()
val systemTrayService = koinInject<SystemTrayService>() val trayService = koinInject<SystemTrayService>()
val settings by settingsProvider.settingsState.collectAsState(initial = null) val settings by settingsProvider.settingsState.collectAsState(initial = null)
val minimizeToTray = settings?.minimizeToTray ?: false
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
IconButton( IconButton(onClick = { windowState.isMinimized = true }) {
onClick = { Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp))
windowState.isMinimized = true
}) {
Icon(
Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)
)
} }
IconButton( IconButton(onClick = {
onClick = { windowState.placement = if (windowState.placement == WindowPlacement.Maximized)
windowState.placement = if (windowState.placement == WindowPlacement.Maximized) { WindowPlacement.Floating else WindowPlacement.Maximized
WindowPlacement.Floating }) {
} else {
WindowPlacement.Maximized
}
}) {
if (windowState.placement == WindowPlacement.Floating) { if (windowState.placement == WindowPlacement.Floating) {
Icon( Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp))
Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)
)
} else { } else {
Icon( Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp))
Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)
)
} }
} }
IconButton( IconButton(onClick = {
onClick = { if (settings?.minimizeToTray == true) {
if (minimizeToTray) { trayService.hideWindow()
windowState.isMinimized = true } else {
systemTrayService.setWindowVisible(false) applicationScope.exitApplication()
} else { }
applicationScope.exitApplication() }) {
} Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp))
}) {
Icon(
Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)
)
} }
} }
} }

View File

@ -20,6 +20,9 @@ package dev.krtirtho.spotube
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.ExperimentalComposeUiApi
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
@ -68,22 +71,33 @@ fun main() {
val settings by settingsProvider.settingsState.collectAsState(initial = null) val settings by settingsProvider.settingsState.collectAsState(initial = null)
val minimizeToTray = settings?.minimizeToTray ?: false 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( Window(
state = windowState, state = windowState,
onCloseRequest = { onCloseRequest = {
if (minimizeToTray) { if (minimizeToTray) {
windowState.isMinimized = true isWindowVisible = false
systemTrayService.setWindowVisible(false)
} else { } else {
trayService.close()
appScope.cancel() appScope.cancel()
exitApplication() exitApplication()
} }
}, },
title = "Spotube", title = "Spotube",
decoration = WindowDecoration.Undecorated(), decoration = WindowDecoration.Undecorated(),
visible = true, visible = isWindowVisible,
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalApplicationScope provides this@application, LocalApplicationScope provides this@application,
@ -93,17 +107,5 @@ fun main() {
App() App()
} }
} }
systemTrayService.start(
onToggleWindowVisibility = {
windowState.isMinimized = !windowState.isMinimized
systemTrayService.setWindowVisible(!windowState.isMinimized)
},
onExit = {
systemTrayService.close()
appScope.cancel()
exitApplication()
}
)
} }
} }

View File

@ -72,6 +72,7 @@ spotube-gradle = "0.1.0"
cryptography = "0.6.0" cryptography = "0.6.0"
reorderable = "3.1.0" reorderable = "3.1.0"
vlcjBundler = "0.1.0" vlcjBundler = "0.1.0"
vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7" uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0" kotlinx-atomicfu = "0.33.0"
mokkery = "3.3.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" } kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }

View File

@ -16,7 +16,7 @@
*/ */
plugins { plugins {
alias(libs.plugins.mavenPublish) alias(libs.plugins.vanniktechMavenPublish)
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
@ -32,7 +32,7 @@ kotlin {
// which platforms this KMP module supports. // which platforms this KMP module supports.
// See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets // See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets
androidLibrary { androidLibrary {
namespace = "dev.krtirtho.plugin_interfaces" namespace = "dev.krtirtho.spotube.plugin_interfaces"
compileSdk = libs.versions.android.compileSdk.get().toInt() compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
@ -91,4 +91,39 @@ kotlin {
jsMain.dependencies {} 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"
}
}
} }