Compare commits

...

8 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
dependabot[bot]
eb30f98056
chore(deps): bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 05:08:47 +00:00
41 changed files with 1702 additions and 339 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

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

View File

@ -45,6 +45,7 @@ actual val platformModules = module {
collectionPlaybackHelper = get(),
audioPlayerQueue = 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.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
@ -52,6 +53,7 @@ class MediaBrowseHelper(
private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository,
private val blacklistRepository: BlacklistRepository,
) {
companion object {
@ -532,7 +534,18 @@ class MediaBrowseHelper(
} catch (_: Exception) {
return
}
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
if (entries.isNotEmpty()) {
audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0)
}

View File

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

View File

@ -102,6 +102,9 @@
<string name="settings_error_whole_number">Enter a valid whole number.</string>
<string name="settings_error_port_range">Port must be between 1 and 65535.</string>
<string name="settings_blacklist_title">Blacklist</string>
<string name="settings_blacklist_subtitle">Manage blacklisted tracks and artists</string>
<string name="settings_desktop_minimize_title">Minimize to tray</string>
<string name="settings_desktop_minimize_subtitle">Keep Spotube running in the system tray when the window is minimized.</string>
<string name="settings_desktop_discord_title">Discord rich presence</string>

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CoroutineScope
@ -44,6 +45,7 @@ class DeviceAudioPlayerQueue(
private val settingsProvider: SettingsProvider,
private val repository: QueueStateRepository,
private val pluginProvider: PluginProvider,
private val blacklistRepository: BlacklistRepository,
) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>()
@ -346,7 +348,22 @@ class DeviceAudioPlayerQueue(
return
}
val newEntries = recommendations.map { track ->
val blacklistedTrackIds = blacklistRepository.blacklistedTracks.first().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.blacklistedArtists.first().map { it.id }.toSet()
val filteredRecommendations = recommendations.filter { track ->
val trackBlacklisted = track.id in blacklistedTrackIds
val artistBlacklisted = track.artists.any { it.id in blacklistedArtistIds }
!trackBlacklisted && !artistBlacklisted
}
if (filteredRecommendations.isEmpty()) {
logger.w { "Endless playback: all recommendations were blacklisted" }
isFetchingRecommendations = false
return
}
val newEntries = filteredRecommendations.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}

View File

@ -55,6 +55,12 @@ class Database(val paths: Paths) {
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() }
)
}
val blacklistDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
)
}
}
object DatabaseKeys {

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

View File

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

View File

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

View File

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

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.QueueEntry
import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
@ -30,6 +31,7 @@ class CollectionPlaybackHelper(
private val playlistRepository: PlaylistRepository,
private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) {
suspend fun playAlbum(albumId: String) {
if (audioPlayerQueue.isAlbumPlaying(albumId)) return
@ -185,7 +187,13 @@ class CollectionPlaybackHelper(
pagination?.items?.let { allTracks.addAll(it) }
}
return allTracks.map { track ->
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = allTracks.filter { track ->
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
}
return filteredTracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
}
@ -200,7 +208,16 @@ class CollectionPlaybackHelper(
pagination?.items?.let { allTracks.addAll(it) }
}
return allTracks.map { track ->
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
val filteredTracks = allTracks.filter { track ->
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
}
return filteredTracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
}
@ -215,11 +232,25 @@ class CollectionPlaybackHelper(
pagination?.items?.let { allTracks.addAll(it) }
}
return allTracks.map { track ->
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = allTracks.filter { track ->
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
}
return filteredTracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
}
private fun isTrackBlacklisted(
track: MetadataTrack,
blacklistedTrackIds: Set<String>,
blacklistedArtistIds: Set<String>,
): Boolean {
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
}
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
if (id.isNotBlank() && other.id.isNotBlank()) {
return id == other.id

View File

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

View File

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

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

View File

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

View File

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

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.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -36,6 +37,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -85,6 +88,7 @@ class AlbumViewModel(
private val libraryRepository: LibraryRepository,
private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>()
@ -93,6 +97,12 @@ class AlbumViewModel(
val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
init {
viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin
@ -103,6 +113,12 @@ class AlbumViewModel(
loadInitialData()
}
}
blacklistRepository.blacklistedTracks
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
.launchIn(viewModelScope)
blacklistRepository.blacklistedArtists
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
.launchIn(viewModelScope)
}
private suspend fun loadInitialData() = runCatching {
@ -220,7 +236,7 @@ class AlbumViewModel(
}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
@ -229,18 +245,46 @@ class AlbumViewModel(
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value
return track.id in trackIds || track.artists.any { it.id in artistIds }
}
fun toggleTrackBlacklist(track: MetadataTrack) {
viewModelScope.launch {
blacklistRepository.toggleTrack(track)
}
}
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow

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

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.di.injectLogger
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -61,6 +64,7 @@ class ArtistViewModel(
private val libraryRepository: LibraryRepository,
private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>()
@ -73,6 +77,12 @@ class ArtistViewModel(
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
init {
viewModelScope.launch {
libraryRepository.savedArtistIdsFlow.collect {
@ -82,6 +92,12 @@ class ArtistViewModel(
}
}
}
blacklistRepository.blacklistedTracks
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
.launchIn(viewModelScope)
blacklistRepository.blacklistedArtists
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
.launchIn(viewModelScope)
loadOverview()
}
@ -101,6 +117,28 @@ class ArtistViewModel(
}
}
fun isArtistBlacklisted(artistId: String): Boolean {
return _blacklistedArtistIds.value.contains(artistId)
}
fun toggleArtistBlacklist(artist: MetadataArtist) {
viewModelScope.launch {
blacklistRepository.toggleArtist(artist)
}
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value
return track.id in trackIds || track.artists.any { it.id in artistIds }
}
fun toggleTrackBlacklist(track: MetadataTrack) {
viewModelScope.launch {
blacklistRepository.toggleTrack(track)
}
}
fun loadMoreAlbums() {
val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return
@ -211,17 +249,32 @@ class ArtistViewModel(
)
}
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
@ -267,7 +320,7 @@ class ArtistViewModel(
}
}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
@ -303,12 +356,23 @@ class ArtistViewModel(
}
}
private fun resolveTopTrackEntries(): List<QueueEntry> {
private suspend fun resolveTopTrackEntries(): List<QueueEntry> {
val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return emptyList()
return currentState.topTracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
return currentState.topTracks
.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
}
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {

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

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.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -37,6 +38,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -86,6 +89,7 @@ class PlaylistViewModel(
private val savedTracksRepository: SavedTracksRepository,
private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<PlaylistViewModel>()
@ -97,6 +101,12 @@ class PlaylistViewModel(
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
init {
viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin
@ -108,6 +118,12 @@ class PlaylistViewModel(
loadCurrentUser()
}
}
blacklistRepository.blacklistedTracks
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
.launchIn(viewModelScope)
blacklistRepository.blacklistedArtists
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
.launchIn(viewModelScope)
}
private suspend fun loadCurrentUser() {
@ -265,23 +281,51 @@ class PlaylistViewModel(
}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value
return track.id in trackIds || track.artists.any { it.id in artistIds }
}
fun toggleTrackBlacklist(track: MetadataTrack) {
viewModelScope.launch {
blacklistRepository.toggleTrack(track)
}
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
}

View File

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

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.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -43,6 +44,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent
@ -84,6 +87,7 @@ class SavedTracksViewModel(
private val repository: SavedTracksRepository,
private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<SavedTracksViewModel>()
@ -92,6 +96,12 @@ class SavedTracksViewModel(
val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
init {
viewModelScope.launch {
combine(
@ -104,6 +114,12 @@ class SavedTracksViewModel(
loadInitialData()
}
}
blacklistRepository.blacklistedTracks
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
.launchIn(viewModelScope)
blacklistRepository.blacklistedArtists
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
.launchIn(viewModelScope)
}
private suspend fun loadInitialData() {
@ -195,23 +211,51 @@ class SavedTracksViewModel(
is TrackOptionsAction.ToggleFavorite -> {}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value
return track.id in trackIds || track.artists.any { it.id in artistIds }
}
fun toggleTrackBlacklist(track: MetadataTrack) {
viewModelScope.launch {
blacklistRepository.toggleTrack(track)
}
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
}

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.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
import dev.krtirtho.spotube.core.ui.base.ChipTab
@ -98,6 +100,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.component.UserCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -120,6 +123,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val blacklistRepository: BlacklistRepository = koinInject()
val navigationCommands: NavigationCommands = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle()
val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope()
@ -128,6 +133,21 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList())
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList())
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = blacklistedTracks.map { it.id }.toSet()
val artistIds = blacklistedArtists.map { it.id }.toSet()
return track.id in trackIds || track.artists.any { it.id in artistIds }
}
fun toggleTrackBlacklist(track: MetadataTrack) {
scope.launch {
blacklistRepository.toggleTrack(track)
}
}
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
@ -180,7 +200,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id)
is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track)
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
@ -206,7 +226,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = false,
isBlacklisted = isTrackBlacklisted(track),
)
}
@ -222,20 +242,36 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = false,
isBlacklisted = isTrackBlacklisted(track),
)
}
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
scope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
}
fun bulkPlayNext(tracks: List<MetadataTrack>) {
scope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
@ -315,6 +351,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
onAlbumClick = { album ->
navigationCommands.navigateTo(Routes.Album(album.id))
},
onArtistsOverflowClick = { track ->
// No-op for now, could show a dialog with all artists
},
modifier = Modifier
.fillMaxSize(),
)
@ -335,6 +380,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
onAlbumClick = { album ->
navigationCommands.navigateTo(Routes.Album(album.id))
},
onArtistsOverflowClick = { track ->
// No-op for now, could show a dialog with all artists
},
modifier = Modifier
.fillMaxSize(),
)
@ -607,6 +661,9 @@ private fun SearchAllTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) {
@ -661,6 +718,9 @@ private fun SearchAllTab(
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
headerContent = {
if (showTracks) {
@ -745,6 +805,9 @@ private fun SearchTracksTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) {
@ -771,6 +834,9 @@ private fun SearchTracksTab(
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
simplified = true,
modifier = modifier,
)

View File

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

View File

@ -18,20 +18,27 @@
package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.Cast
import compose.icons.feathericons.Radio
import compose.icons.feathericons.Repeat
import compose.icons.feathericons.Server
import compose.icons.feathericons.Sliders
import spotube.composeapp.generated.resources.*
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow
@ -41,6 +48,7 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.playbackSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
navigatorCommands: NavigationCommands,
) {
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
val streamingQualities = availableAudioQualities(
@ -159,6 +167,28 @@ internal fun LazyListScope.playbackSection(
}
)
},
{
SettingCardItem(
title = stringResource(Res.string.settings_blacklist_title),
subtitle = stringResource(Res.string.settings_blacklist_subtitle),
icon = {
SettingsItemIcon(
Iconsax.IconsaxForbidden,
stringResource(Res.string.settings_blacklist_title),
)
},
trailingContent = {
Icon(
imageVector = FeatherIcons.ChevronRight,
contentDescription = stringResource(Res.string.settings_blacklist_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = {
navigatorCommands.navigateTo(Routes.Blacklist)
},
)
},
)
)
}

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

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.SupervisorJob
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import java.awt.BorderLayout
@ -38,8 +40,6 @@ import java.awt.Image
import java.awt.SystemTray
import java.awt.Toolkit
import java.awt.TrayIcon
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.WindowAdapter
@ -50,10 +50,8 @@ import javax.swing.BoxLayout
import javax.swing.JCheckBoxMenuItem
import javax.swing.JDialog
import javax.swing.JLabel
import javax.swing.JMenu
import javax.swing.JMenuItem
import javax.swing.JPanel
import javax.swing.JPopupMenu
import javax.swing.SwingUtilities
import javax.swing.UIManager
@ -71,26 +69,87 @@ class SystemTrayService(
private var menuDialog: TrayMenuDialog? = null
private var windowVisible: Boolean = true
private var onToggleWindowVisibility: (() -> Unit)? = null
private var onExit: (() -> Unit)? = null
@Volatile
private var started = false
private var onToggleWindowVisibility: () -> Unit = {}
private var onExit: () -> Unit = {}
private val volumeStep = 0.05f
private var trayEnabled = false
fun start(
@Volatile
private var currentState: TrayState? = null
init {
scope.launch {
settingsProvider.settingsState
.map { it?.minimizeToTray ?: false }
.distinctUntilChanged()
.collect { enabled ->
trayEnabled = enabled
if (enabled) {
ensureTrayCreated()
} else {
removeTray()
}
}
}
scope.launch {
combine(
audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow,
audioPlayer.loopStateFlow,
audioPlayer.shuffleModeFlow,
audioPlayer.volumeFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
) { values ->
TrayState(
playerState = values[0] as PlayerState,
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
loopState = values[2] as LoopState,
shuffleEnabled = values[3] as Boolean,
volume = values[4] as Float,
currentEntry = values[5] as QueueEntry?,
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
)
}.collect { state ->
currentState = state
SwingUtilities.invokeLater {
updateTooltip(state)
menuDialog?.let { dialog ->
val panel = dialog.contentPane.getComponent(0) as? JPanel
if (panel != null) rebuildMenuPanel(panel, dialog)
}
}
}
}
}
fun setCallbacks(
onToggleWindowVisibility: () -> Unit,
onExit: () -> Unit,
) {
this.onToggleWindowVisibility = onToggleWindowVisibility
this.onExit = onExit
}
if (started) {
return
fun hideWindow() {
if (windowVisible) {
windowVisible = false
onToggleWindowVisibility()
}
started = true
}
fun showWindow() {
if (!windowVisible) {
windowVisible = true
onToggleWindowVisibility()
}
}
private fun ensureTrayCreated() {
if (trayIcon != null) return
if (!SystemTray.isSupported()) {
logger.w { "System tray is not supported on this platform" }
return
@ -104,25 +163,39 @@ class SystemTrayService(
icon.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
onToggleWindowVisibility.invoke()
if (windowVisible) hideWindow() else showWindow()
} else if (e.button == MouseEvent.BUTTON3) {
showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
}
}
})
val systemTray = SystemTray.getSystemTray()
systemTray.add(icon)
this.trayIcon = icon
logger.i { "System tray initialized" }
observeState()
SystemTray.getSystemTray().add(icon)
trayIcon = icon
logger.i { "System tray created" }
} catch (e: Exception) {
logger.e(e) { "Failed to initialize system tray" }
logger.e(e) { "Failed to create system tray" }
}
}
}
private fun removeTray() {
menuDialog?.dispose()
menuDialog = null
trayIcon?.let {
try {
SystemTray.getSystemTray().remove(it)
} catch (e: Exception) {
logger.e(e) { "Failed to remove tray icon" }
}
}
trayIcon = null
}
override fun close() {
removeTray()
}
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
val dialog = menuDialog
if (dialog != null && dialog.isVisible) {
@ -135,7 +208,6 @@ class SystemTrayService(
isUndecorated = true
isAlwaysOnTop = true
focusableWindowState = true
setAutoRequestFocus(true)
background = Color(0, 0, 0, 0)
}
@ -190,7 +262,7 @@ class SystemTrayService(
panel.add(createSeparator())
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
onToggleWindowVisibility?.invoke()
if (windowVisible) hideWindow() else showWindow()
})
panel.add(createSeparator())
@ -219,18 +291,12 @@ class SystemTrayService(
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
listOf(loopNone, loopOne, loopAll).forEach { item ->
item.isOpaque = true
val normalBackground = item.background
val normalBg = item.background
item.addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
item.background = hoverBackground
}
override fun mouseExited(e: MouseEvent) {
item.background = normalBackground
}
override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground }
override fun mouseExited(e: MouseEvent) { item.background = normalBg }
})
}
@ -279,53 +345,16 @@ class SystemTrayService(
})
panel.add(createSeparator())
panel.add(createMenuItem("Exit") { onExit?.invoke() })
panel.add(createMenuItem("Exit") { onExit() })
panel.revalidate()
panel.repaint()
dialog.pack()
}
@Volatile
private var currentState: TrayState? = null
private fun observeState() {
scope.launch {
combine(
audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow,
audioPlayer.loopStateFlow,
audioPlayer.shuffleModeFlow,
audioPlayer.volumeFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
) { values ->
TrayState(
playerState = values[0] as PlayerState,
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
loopState = values[2] as LoopState,
shuffleEnabled = values[3] as Boolean,
volume = values[4] as Float,
currentEntry = values[5] as QueueEntry?,
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
)
}.collect { state ->
currentState = state
SwingUtilities.invokeLater {
updateTooltip(state)
menuDialog?.let { dialog ->
val panel = dialog.contentPane.getComponent(0) as? JPanel
if (panel != null) rebuildMenuPanel(panel, dialog)
}
}
}
}
}
private fun updateTooltip(state: TrayState) {
val icon = trayIcon ?: return
val media = state.mediaItem
icon.toolTip = if (media != null) {
trayIcon?.toolTip = if (media != null) {
"Spotube - ${media.title} by ${media.artist}"
} else {
"Spotube"
@ -338,20 +367,11 @@ class SystemTrayService(
isOpaque = true
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
addActionListener { action() }
val normalBackground = background
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
val normalBg = background
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
if (isEnabled) {
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
override fun mouseExited(e: MouseEvent) { background = normalBg }
})
}
}
@ -361,20 +381,11 @@ class SystemTrayService(
isOpaque = true
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
addActionListener { action() }
val normalBackground = background
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
val normalBg = background
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
if (isEnabled) {
background = hoverBackground
}
}
override fun mouseExited(e: MouseEvent) {
background = normalBackground
}
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
override fun mouseExited(e: MouseEvent) { background = normalBg }
})
}
}
@ -396,26 +407,7 @@ class SystemTrayService(
}
private fun createSeparator(): javax.swing.JSeparator {
return javax.swing.JSeparator().apply {
maximumSize = Dimension(Int.MAX_VALUE, 2)
}
}
fun setWindowVisible(visible: Boolean) {
windowVisible = visible
}
override fun close() {
menuDialog?.dispose()
menuDialog = null
trayIcon?.let {
try {
SystemTray.getSystemTray().remove(it)
} catch (e: Exception) {
logger.e(e) { "Failed to remove tray icon" }
}
}
trayIcon = null
return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) }
}
private fun createTrayIconImage(): Image {

View File

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

View File

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

View File

@ -72,6 +72,7 @@ spotube-gradle = "0.1.0"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.1.0"
vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0"
mokkery = "3.3.0"
@ -181,4 +182,5 @@ vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "v
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }

View File

@ -16,7 +16,7 @@
*/
plugins {
alias(libs.plugins.mavenPublish)
alias(libs.plugins.vanniktechMavenPublish)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.kotlinSerialization)
@ -32,7 +32,7 @@ kotlin {
// which platforms this KMP module supports.
// See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets
androidLibrary {
namespace = "dev.krtirtho.plugin_interfaces"
namespace = "dev.krtirtho.spotube.plugin_interfaces"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
@ -91,4 +91,39 @@ kotlin {
jsMain.dependencies {}
}
}
mavenPublishing {
publishToMavenCentral()
signAllPublications()
coordinates(
group.toString(),
"plugin_interfaces",
version.toString()
)
pom {
name = "plugin_interfaces"
description = "Plugin interfaces for Spotube"
inceptionYear = "2026"
url = "https://github.com/KRTirtho/spotube"
licenses {
license {
name = "AGPL-3.0-or-later"
url = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
distribution = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
}
}
developers {
developer {
id = "KRTirtho"
name = "Kingkor Roy Tirtho"
url = "https://github.com/KRTirtho/"
}
}
scm {
url = "https://github.com/KRTirtho/spotube"
connection = "scm:git:git://github.com/KRTirtho/spotube.git"
developerConnection = "scm:git:ssh://git@github.com/KRTirtho/spotube.git"
}
}
}