mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 22:54:00 +00:00
Compare commits
1 Commits
8e004af30b
...
0f1aba18e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f1aba18e3 |
13
.github/apache_header.txt
vendored
Normal file
13
.github/apache_header.txt
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
16
.github/dependabot.yml
vendored
Normal file
16
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
enable-beta-ecosystems: true
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pub"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
target-branch: "dev"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
target-branch: "dev"
|
||||||
26
.github/workflows/maven-publish.yml
vendored
26
.github/workflows/maven-publish.yml
vendored
@ -1,26 +0,0 @@
|
|||||||
# .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 }}
|
|
||||||
58
.github/workflows/spotube-release-binary.yml
vendored
58
.github/workflows/spotube-release-binary.yml
vendored
@ -143,6 +143,13 @@ jobs:
|
|||||||
- name: Assemble Release
|
- name: Assemble Release
|
||||||
run: ./gradlew :composeApp:assembleRelease
|
run: ./gradlew :composeApp:assembleRelease
|
||||||
|
|
||||||
|
- name: Setup tmate session on failure
|
||||||
|
if: ${{ inputs.debug_ssh && failure() }}
|
||||||
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
with:
|
||||||
|
limit-access-to-actor: true
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1)
|
APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1)
|
||||||
@ -154,12 +161,6 @@ jobs:
|
|||||||
name: android-apk
|
name: android-apk
|
||||||
path: Spotube-android-all-arch.apk
|
path: Spotube-android-all-arch.apk
|
||||||
|
|
||||||
- name: Setup tmate session on failure
|
|
||||||
if: ${{ inputs.debug_ssh && failure() }}
|
|
||||||
uses: mxschmitt/action-tmate@v3
|
|
||||||
with:
|
|
||||||
limit-access-to-actor: true
|
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Linux Desktop
|
name: Linux Desktop
|
||||||
needs: prepare-deps
|
needs: prepare-deps
|
||||||
@ -194,14 +195,23 @@ jobs:
|
|||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Package Linux
|
- name: Package Linux
|
||||||
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm
|
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
|
||||||
|
|
||||||
- name: Rename Linux artifacts
|
- name: Rename Linux artifacts
|
||||||
run: |
|
run: |
|
||||||
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
|
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
|
||||||
RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1)
|
RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1)
|
||||||
|
APPIMAGE_FILE=$(find composeApp/build -name "*.AppImage" -type f | head -1)
|
||||||
cp "$DEB_FILE" Spotube-linux-x86_64.deb
|
cp "$DEB_FILE" Spotube-linux-x86_64.deb
|
||||||
cp "$RPM_FILE" Spotube-linux-x86_64.rpm
|
cp "$RPM_FILE" Spotube-linux-x86_64.rpm
|
||||||
|
cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage
|
||||||
|
|
||||||
- name: Upload DEB
|
- name: Upload DEB
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@ -215,11 +225,11 @@ jobs:
|
|||||||
name: linux-rpm
|
name: linux-rpm
|
||||||
path: Spotube-linux-x86_64.rpm
|
path: Spotube-linux-x86_64.rpm
|
||||||
|
|
||||||
- name: Setup tmate session on failure
|
- name: Upload AppImage
|
||||||
if: ${{ inputs.debug_ssh && failure() }}
|
uses: actions/upload-artifact@v4
|
||||||
uses: mxschmitt/action-tmate@v3
|
|
||||||
with:
|
with:
|
||||||
limit-access-to-actor: true
|
name: linux-appimage
|
||||||
|
path: Spotube-linux-x86_64.AppImage
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Windows Desktop
|
name: Windows Desktop
|
||||||
@ -249,6 +259,13 @@ jobs:
|
|||||||
- name: Package EXE
|
- name: Package EXE
|
||||||
run: ./gradlew :composeApp:packageReleaseExe
|
run: ./gradlew :composeApp:packageReleaseExe
|
||||||
|
|
||||||
|
- name: Setup tmate session on failure
|
||||||
|
if: ${{ inputs.debug_ssh && failure() }}
|
||||||
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
with:
|
||||||
|
limit-access-to-actor: true
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
- name: Rename Windows artifacts
|
- name: Rename Windows artifacts
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@ -261,12 +278,6 @@ jobs:
|
|||||||
name: windows-exe
|
name: windows-exe
|
||||||
path: Spotube-windows-x86_64-setup.exe
|
path: Spotube-windows-x86_64-setup.exe
|
||||||
|
|
||||||
- name: Setup tmate session on failure
|
|
||||||
if: ${{ inputs.debug_ssh && failure() }}
|
|
||||||
uses: mxschmitt/action-tmate@v3
|
|
||||||
with:
|
|
||||||
limit-access-to-actor: true
|
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
name: macOS Desktop
|
name: macOS Desktop
|
||||||
needs: prepare-deps
|
needs: prepare-deps
|
||||||
@ -298,6 +309,13 @@ jobs:
|
|||||||
- name: Package DMG
|
- name: Package DMG
|
||||||
run: ./gradlew :composeApp:packageReleaseDmg
|
run: ./gradlew :composeApp:packageReleaseDmg
|
||||||
|
|
||||||
|
- name: Setup tmate session on failure
|
||||||
|
if: ${{ inputs.debug_ssh && failure() }}
|
||||||
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
with:
|
||||||
|
limit-access-to-actor: true
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
- name: Rename macOS artifact
|
- name: Rename macOS artifact
|
||||||
run: |
|
run: |
|
||||||
DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1)
|
DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1)
|
||||||
@ -309,12 +327,6 @@ jobs:
|
|||||||
name: macos-dmg
|
name: macos-dmg
|
||||||
path: Spotube-macos-universal.dmg
|
path: Spotube-macos-universal.dmg
|
||||||
|
|
||||||
- name: Setup tmate session on failure
|
|
||||||
if: ${{ inputs.debug_ssh && failure() }}
|
|
||||||
uses: mxschmitt/action-tmate@v3
|
|
||||||
with:
|
|
||||||
limit-access-to-actor: true
|
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
name: Create GitHub Release
|
name: Create GitHub Release
|
||||||
if: ${{ !inputs.dry_run }}
|
if: ${{ !inputs.dry_run }}
|
||||||
|
|||||||
@ -17,23 +17,11 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube
|
package dev.krtirtho.spotube
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import org.koin.core.context.GlobalContext
|
|
||||||
|
|
||||||
class AndroidPlatform : Platform {
|
class AndroidPlatform : Platform {
|
||||||
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
||||||
override val type: PlatformType = PlatformType.Android
|
override val type: PlatformType = PlatformType.Android
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun getPlatform(): Platform = AndroidPlatform()
|
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||||
|
|
||||||
actual fun openUrlInBrowser(url: String) {
|
|
||||||
val context = GlobalContext.get().get<Context>()
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
@ -45,7 +45,6 @@ actual val platformModules = module {
|
|||||||
collectionPlaybackHelper = get(),
|
collectionPlaybackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
settingsRepository = get(),
|
settingsRepository = get(),
|
||||||
blacklistRepository = get(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import android.webkit.CookieManager
|
import io.github.kdroidfilter.webview.web.WebViewState
|
||||||
import android.webkit.WebStorage
|
|
||||||
import android.webkit.WebView
|
actual fun platformWebviewConfig(webView: WebViewState) {
|
||||||
import io.github.kdroidfilter.webview.web.WebViewState
|
webView.webView?.nativeWebView?.settings?.domStorageEnabled = true
|
||||||
|
|
||||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
|
||||||
val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return
|
|
||||||
nativeWebView.settings.domStorageEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
|
||||||
if (pluginId == null) return
|
|
||||||
|
|
||||||
CookieManager.getInstance().removeAllCookies(null)
|
|
||||||
CookieManager.getInstance().flush()
|
|
||||||
WebStorage.getInstance().deleteAllData()
|
|
||||||
}
|
}
|
||||||
@ -32,7 +32,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
@ -53,7 +52,6 @@ class MediaBrowseHelper(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -534,18 +532,7 @@ class MediaBrowseHelper(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
|
|
||||||
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
|
|
||||||
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
|
|
||||||
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0)
|
audioPlayerQueue.load(entries, autoPlay = true, startPosition = 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,6 @@ import com.google.common.util.concurrent.ListenableFuture
|
|||||||
import com.google.common.util.concurrent.SettableFuture
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
import dev.krtirtho.spotube.MainActivity
|
import dev.krtirtho.spotube.MainActivity
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -56,7 +55,7 @@ import org.koin.core.component.inject
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class PlaybackService : MediaLibraryService(), KoinComponent {
|
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||||
|
|
||||||
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
|
private val audioPlayer: AudioPlayer by inject()
|
||||||
private val audioPlayerQueue: AudioPlayerQueue by inject()
|
private val audioPlayerQueue: AudioPlayerQueue by inject()
|
||||||
private val mediaBrowseHelper: MediaBrowseHelper by inject()
|
private val mediaBrowseHelper: MediaBrowseHelper by inject()
|
||||||
private lateinit var librarySession: MediaLibrarySession
|
private lateinit var librarySession: MediaLibrarySession
|
||||||
@ -88,10 +87,9 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
librarySession =
|
librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
||||||
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
.setSessionActivity(sessionActivity)
|
||||||
.setSessionActivity(sessionActivity)
|
.build()
|
||||||
.build()
|
|
||||||
|
|
||||||
addSession(librarySession)
|
addSession(librarySession)
|
||||||
|
|
||||||
@ -114,8 +112,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = librarySession
|
||||||
librarySession
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.i(TAG, "onDestroy")
|
Log.i(TAG, "onDestroy")
|
||||||
@ -273,10 +270,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
} else {
|
} else {
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
Log.d(
|
Log.d(TAG, "onGetSearchResult: query=$query, page=$page, returning ${paged.size} items")
|
||||||
TAG,
|
|
||||||
"onGetSearchResult: query=$query, page=$page, returning ${paged.size} items"
|
|
||||||
)
|
|
||||||
future.set(LibraryResult.ofItemList(paged, params))
|
future.set(LibraryResult.ofItemList(paged, params))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "onGetSearchResult failed for $query", e)
|
Log.e(TAG, "onGetSearchResult failed for $query", e)
|
||||||
@ -358,20 +352,17 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
helper.playPlaylist(playlistId)
|
helper.playPlaylist(playlistId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> {
|
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> {
|
||||||
val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:")
|
val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:")
|
||||||
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
||||||
helper.playAlbum(albumId)
|
helper.playAlbum(albumId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> {
|
mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> {
|
||||||
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
||||||
helper.playSavedTracks()
|
helper.playSavedTracks()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ARTIST_TRACKS}:") -> {
|
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ARTIST_TRACKS}:") -> {
|
||||||
mediaBrowseHelper.resolveAndPlayFromMediaId(mediaId)
|
mediaBrowseHelper.resolveAndPlayFromMediaId(mediaId)
|
||||||
return
|
return
|
||||||
@ -393,16 +384,17 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> =
|
private suspend fun buildCurrentMediaItemList(): List<Media3MediaItem> = withContext(Dispatchers.Main) {
|
||||||
withContext(Dispatchers.Main) {
|
val count = audioPlayer.player.mediaItemCount
|
||||||
val count = audioPlayer.player.mediaItemCount
|
val items = mutableListOf<Media3MediaItem>()
|
||||||
val items = mutableListOf<Media3MediaItem>()
|
for (i in 0 until count) {
|
||||||
for (i in 0 until count) {
|
val item = audioPlayer.player.getMediaItemAt(i)
|
||||||
val item = audioPlayer.player.getMediaItemAt(i)
|
if (item != null) {
|
||||||
items.add(item)
|
items.add(item)
|
||||||
}
|
}
|
||||||
items
|
|
||||||
}
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PlaybackService"
|
private const val TAG = "PlaybackService"
|
||||||
|
|||||||
@ -102,9 +102,6 @@
|
|||||||
<string name="settings_error_whole_number">Enter a valid whole number.</string>
|
<string name="settings_error_whole_number">Enter a valid whole number.</string>
|
||||||
<string name="settings_error_port_range">Port must be between 1 and 65535.</string>
|
<string name="settings_error_port_range">Port must be between 1 and 65535.</string>
|
||||||
|
|
||||||
<string name="settings_blacklist_title">Blacklist</string>
|
|
||||||
<string name="settings_blacklist_subtitle">Manage blacklisted tracks and artists</string>
|
|
||||||
|
|
||||||
<string name="settings_desktop_minimize_title">Minimize to tray</string>
|
<string name="settings_desktop_minimize_title">Minimize to tray</string>
|
||||||
<string name="settings_desktop_minimize_subtitle">Keep Spotube running in the system tray when the window is minimized.</string>
|
<string name="settings_desktop_minimize_subtitle">Keep Spotube running in the system tray when the window is minimized.</string>
|
||||||
<string name="settings_desktop_discord_title">Discord rich presence</string>
|
<string name="settings_desktop_discord_title">Discord rich presence</string>
|
||||||
@ -151,7 +148,6 @@
|
|||||||
<string name="plugin_action_logout">Logout</string>
|
<string name="plugin_action_logout">Logout</string>
|
||||||
<string name="plugin_section_url_title">Download from URL</string>
|
<string name="plugin_section_url_title">Download from URL</string>
|
||||||
<string name="plugin_section_file_title">Install from file</string>
|
<string name="plugin_section_file_title">Install from file</string>
|
||||||
<string name="plugin_section_install">Install</string>
|
|
||||||
|
|
||||||
<string name="plugin_permissions_author_version">• %1$s</string>
|
<string name="plugin_permissions_author_version">• %1$s</string>
|
||||||
<string name="plugin_permissions_requested_title">Permissions requested</string>
|
<string name="plugin_permissions_requested_title">Permissions requested</string>
|
||||||
|
|||||||
@ -28,8 +28,6 @@ interface Platform {
|
|||||||
|
|
||||||
expect fun getPlatform(): Platform
|
expect fun getPlatform(): Platform
|
||||||
|
|
||||||
expect fun openUrlInBrowser(url: String)
|
|
||||||
|
|
||||||
fun Platform.isDesktop(): Boolean {
|
fun Platform.isDesktop(): Boolean {
|
||||||
return type == PlatformType.Windows ||
|
return type == PlatformType.Windows ||
|
||||||
type == PlatformType.Linux ||
|
type == PlatformType.Linux ||
|
||||||
|
|||||||
@ -19,7 +19,6 @@ package dev.krtirtho.spotube.core.audioplayer
|
|||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -45,7 +44,6 @@ class DeviceAudioPlayerQueue(
|
|||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider,
|
||||||
private val repository: QueueStateRepository,
|
private val repository: QueueStateRepository,
|
||||||
private val pluginProvider: PluginProvider,
|
private val pluginProvider: PluginProvider,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
|
||||||
) : AudioPlayerQueue, KoinComponent {
|
) : AudioPlayerQueue, KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
@ -348,22 +346,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val blacklistedTrackIds = blacklistRepository.blacklistedTracks.first().map { it.id }.toSet()
|
val newEntries = recommendations.map { track ->
|
||||||
val blacklistedArtistIds = blacklistRepository.blacklistedArtists.first().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredRecommendations = recommendations.filter { track ->
|
|
||||||
val trackBlacklisted = track.id in blacklistedTrackIds
|
|
||||||
val artistBlacklisted = track.artists.any { it.id in blacklistedArtistIds }
|
|
||||||
!trackBlacklisted && !artistBlacklisted
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredRecommendations.isEmpty()) {
|
|
||||||
logger.w { "Endless playback: all recommendations were blacklisted" }
|
|
||||||
isFetchingRecommendations = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val newEntries = filteredRecommendations.map { track ->
|
|
||||||
QueueEntry.StreamingTrack(track = track, url = "")
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,12 +55,6 @@ class Database(val paths: Paths) {
|
|||||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() }
|
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val blacklistDataStore: DataStore<Preferences> by lazy {
|
|
||||||
PreferenceDataStoreFactory.createWithPath(
|
|
||||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object DatabaseKeys {
|
object DatabaseKeys {
|
||||||
|
|||||||
@ -35,8 +35,6 @@ import dev.krtirtho.spotube.modules.album.AlbumRepository
|
|||||||
import dev.krtirtho.spotube.modules.album.AlbumViewModel
|
import dev.krtirtho.spotube.modules.album.AlbumViewModel
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
|
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
||||||
@ -53,7 +51,6 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
|
|||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
|
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginDiscoverViewModel
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
@ -109,7 +106,6 @@ val sharedModules = module {
|
|||||||
|
|
||||||
// Plugin system
|
// Plugin system
|
||||||
singleOf(::PluginManager) { bind<PluginProvider>() }
|
singleOf(::PluginManager) { bind<PluginProvider>() }
|
||||||
viewModelOf(::PluginDiscoverViewModel)
|
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
singleOf(::SettingsRepository)
|
singleOf(::SettingsRepository)
|
||||||
@ -129,20 +125,12 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
playbackHelper = get(),
|
playbackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved Tracks
|
// Saved Tracks
|
||||||
singleOf(::SavedTracksRepository)
|
singleOf(::SavedTracksRepository)
|
||||||
viewModel {
|
viewModelOf(::SavedTracksViewModel)
|
||||||
SavedTracksViewModel(
|
|
||||||
repository = get(),
|
|
||||||
playbackHelper = get(),
|
|
||||||
audioPlayerQueue = get(),
|
|
||||||
blacklistRepository = get(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
singleOf(::ArtistRepository)
|
singleOf(::ArtistRepository)
|
||||||
@ -153,14 +141,9 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blacklist
|
|
||||||
singleOf(::BlacklistRepository)
|
|
||||||
viewModelOf(::BlacklistViewModel)
|
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
singleOf(::AlbumRepository)
|
singleOf(::AlbumRepository)
|
||||||
viewModel { (albumId: String) ->
|
viewModel { (albumId: String) ->
|
||||||
@ -169,9 +152,8 @@ val sharedModules = module {
|
|||||||
repository = get(),
|
repository = get(),
|
||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
playbackHelper = get(),
|
playbackHelper = get(),
|
||||||
libraryRepository = get(),
|
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
libraryRepository = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,15 +161,7 @@ val sharedModules = module {
|
|||||||
viewModelOf(::LyricsViewModel)
|
viewModelOf(::LyricsViewModel)
|
||||||
|
|
||||||
// Local playback proxy server
|
// Local playback proxy server
|
||||||
single {
|
singleOf(::CollectionPlaybackHelper)
|
||||||
CollectionPlaybackHelper(
|
|
||||||
albumRepository = get(),
|
|
||||||
playlistRepository = get(),
|
|
||||||
savedTracksRepository = get(),
|
|
||||||
audioPlayerQueue = get(),
|
|
||||||
blacklistRepository = get(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
singleOf(::MatchedTracksRepository)
|
singleOf(::MatchedTracksRepository)
|
||||||
singleOf(::StreamingUrlRepository)
|
singleOf(::StreamingUrlRepository)
|
||||||
singleOf(::AlternativeTracksRepository)
|
singleOf(::AlternativeTracksRepository)
|
||||||
@ -196,7 +170,7 @@ val sharedModules = module {
|
|||||||
}
|
}
|
||||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
DeviceAudioPlayerQueue(get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
|
|
||||||
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.extras
|
|
||||||
|
|
||||||
fun String.kebabToTitleCase(): String {
|
|
||||||
return this.split("-").joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } }
|
|
||||||
}
|
|
||||||
@ -20,14 +20,11 @@ package dev.krtirtho.spotube.core.navigation
|
|||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreen
|
import dev.krtirtho.spotube.modules.home.HomeScreen
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistScreen
|
import dev.krtirtho.spotube.modules.playlist.PlaylistScreen
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginScreen
|
import dev.krtirtho.spotube.modules.plugin.PluginScreen
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
||||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
||||||
@ -36,7 +33,6 @@ import kotlinx.serialization.Serializable
|
|||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.annotation.KoinExperimentalAPI
|
import org.koin.core.annotation.KoinExperimentalAPI
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.core.parameter.parametersOf
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.dsl.navigation3.navigation
|
import org.koin.dsl.navigation3.navigation
|
||||||
|
|
||||||
@ -58,25 +54,22 @@ sealed interface Routes : NavKey {
|
|||||||
data object Plugins : Routes
|
data object Plugins : Routes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object WebView : Routes
|
data object WebView: Routes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Lyrics : Routes
|
data object Lyrics: Routes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Playlist(val playlistId: String) : Routes
|
data class Playlist(val playlistId: String): Routes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Artist(val artistId: String) : Routes
|
data class Artist(val artistId: String): Routes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Album(val albumId: String) : Routes
|
data class Album(val albumId: String): Routes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object SavedTracks : Routes
|
data object SavedTracks: Routes
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Blacklist : Routes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(KoinExperimentalAPI::class)
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
@ -101,68 +94,19 @@ val navigationModule = module {
|
|||||||
WebViewScreen(get())
|
WebViewScreen(get())
|
||||||
}
|
}
|
||||||
navigation<Routes.Playlist> {
|
navigation<Routes.Playlist> {
|
||||||
PlaylistScreen(
|
PlaylistScreen(it.playlistId)
|
||||||
playlistId = it.playlistId,
|
|
||||||
viewModel = koinViewModel(
|
|
||||||
key = it.playlistId,
|
|
||||||
parameters = { parametersOf(it.playlistId) }
|
|
||||||
),
|
|
||||||
audioPlayerQueue = get(),
|
|
||||||
audioPlayer = get(),
|
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Artist> {
|
navigation<Routes.Artist> {
|
||||||
ArtistScreen(
|
ArtistScreen(it.artistId)
|
||||||
viewModel = koinViewModel(
|
|
||||||
key = it.artistId,
|
|
||||||
parameters = { parametersOf(it.artistId) }
|
|
||||||
),
|
|
||||||
audioPlayerQueue = get(),
|
|
||||||
audioPlayer = get(),
|
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Album> {
|
navigation<Routes.Album> {
|
||||||
AlbumScreen(
|
AlbumScreen(it.albumId)
|
||||||
albumId = it.albumId,
|
|
||||||
viewModel = koinViewModel(
|
|
||||||
key = it.albumId,
|
|
||||||
parameters = { parametersOf(it.albumId) }
|
|
||||||
),
|
|
||||||
audioPlayerQueue = get(),
|
|
||||||
audioPlayer = get(),
|
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Lyrics> {
|
navigation<Routes.Lyrics> {
|
||||||
LyricsScreen(viewModel = koinViewModel())
|
LyricsScreen(viewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
navigation<Routes.SavedTracks> {
|
navigation<Routes.SavedTracks> {
|
||||||
SavedTracksScreen(
|
SavedTracksScreen()
|
||||||
viewModel = koinViewModel(
|
|
||||||
key = SAVED_TRACKS_COLLECTION_ID,
|
|
||||||
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
|
|
||||||
),
|
|
||||||
audioPlayerQueue = get(),
|
|
||||||
audioPlayer = get(),
|
|
||||||
shareService = get(),
|
|
||||||
downloadsViewModel = get(),
|
|
||||||
libraryRepository = get(),
|
|
||||||
navigationCommands = get(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
navigation<Routes.Blacklist> {
|
|
||||||
BlacklistScreen()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,6 @@ val serializersConfig = SavedStateConfiguration {
|
|||||||
subclass(Routes.Playlist::class, Routes.Playlist.serializer())
|
subclass(Routes.Playlist::class, Routes.Playlist.serializer())
|
||||||
subclass(Routes.Artist::class, Routes.Artist.serializer())
|
subclass(Routes.Artist::class, Routes.Artist.serializer())
|
||||||
subclass(Routes.Album::class, Routes.Album.serializer())
|
subclass(Routes.Album::class, Routes.Album.serializer())
|
||||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
package dev.krtirtho.spotube.core.paths
|
package dev.krtirtho.spotube.core.paths
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
expect class Paths() {
|
expect class Paths {
|
||||||
fun getApplicationCacheDirPath(): String
|
fun getApplicationCacheDirPath(): String
|
||||||
fun getApplicationDataDirPath(): String
|
fun getApplicationDataDirPath(): String
|
||||||
fun getUserDownloadsDirPath(): String
|
fun getUserDownloadsDirPath(): String
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
|
|
||||||
@ -31,7 +30,6 @@ class CollectionPlaybackHelper(
|
|||||||
private val playlistRepository: PlaylistRepository,
|
private val playlistRepository: PlaylistRepository,
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
|
||||||
) {
|
) {
|
||||||
suspend fun playAlbum(albumId: String) {
|
suspend fun playAlbum(albumId: String) {
|
||||||
if (audioPlayerQueue.isAlbumPlaying(albumId)) return
|
if (audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||||
@ -187,13 +185,7 @@ class CollectionPlaybackHelper(
|
|||||||
pagination?.items?.let { allTracks.addAll(it) }
|
pagination?.items?.let { allTracks.addAll(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
return allTracks.map { track ->
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = allTracks.filter { track ->
|
|
||||||
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
|
|
||||||
}
|
|
||||||
return filteredTracks.map { track ->
|
|
||||||
QueueEntry.StreamingTrack(track = track, url = "")
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -208,16 +200,7 @@ class CollectionPlaybackHelper(
|
|||||||
pagination?.items?.let { allTracks.addAll(it) }
|
pagination?.items?.let { allTracks.addAll(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
|
return allTracks.map { track ->
|
||||||
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
|
|
||||||
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
|
|
||||||
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = allTracks.filter { track ->
|
|
||||||
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredTracks.map { track ->
|
|
||||||
QueueEntry.StreamingTrack(track = track, url = "")
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -232,25 +215,11 @@ class CollectionPlaybackHelper(
|
|||||||
pagination?.items?.let { allTracks.addAll(it) }
|
pagination?.items?.let { allTracks.addAll(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
return allTracks.map { track ->
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = allTracks.filter { track ->
|
|
||||||
!isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds)
|
|
||||||
}
|
|
||||||
return filteredTracks.map { track ->
|
|
||||||
QueueEntry.StreamingTrack(track = track, url = "")
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isTrackBlacklisted(
|
|
||||||
track: MetadataTrack,
|
|
||||||
blacklistedTrackIds: Set<String>,
|
|
||||||
blacklistedArtistIds: Set<String>,
|
|
||||||
): Boolean {
|
|
||||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
||||||
if (id.isNotBlank() && other.id.isNotBlank()) {
|
if (id.isNotBlank() && other.id.isNotBlank()) {
|
||||||
return id == other.id
|
return id == other.id
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@ -35,7 +34,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@ -52,19 +50,17 @@ import org.koin.compose.koinInject
|
|||||||
fun ArtistCard(
|
fun ArtistCard(
|
||||||
artist: MetadataArtist.Basic,
|
artist: MetadataArtist.Basic,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isBlacklisted: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val navigationCommands = koinInject<NavigationCommands>()
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
|
|
||||||
AvatarCard(
|
AvatarCard(
|
||||||
title = artist.name,
|
title = artist.name,
|
||||||
subtitle = if (isBlacklisted) "Blacklisted" else "Artist",
|
subtitle = "Artist",
|
||||||
imageURL = artist.thumbnails.firstOrNull()?.url,
|
imageURL = artist.thumbnails.firstOrNull()?.url,
|
||||||
onClick = {
|
onClick = {
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
isBlacklisted = isBlacklisted,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -571,7 +571,6 @@ private fun TrackListRow(
|
|||||||
isSelected -> rowTheme.background.selected
|
isSelected -> rowTheme.background.selected
|
||||||
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
||||||
isCurrentTrack -> rowTheme.background.focused
|
isCurrentTrack -> rowTheme.background.focused
|
||||||
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
|
||||||
else -> rowTheme.background.normal
|
else -> rowTheme.background.normal
|
||||||
}
|
}
|
||||||
val highlightColor = Color.White.copy(
|
val highlightColor = Color.White.copy(
|
||||||
|
|||||||
@ -51,7 +51,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||||
|
|
||||||
@ -295,7 +294,7 @@ private fun buildTrackMenuItems(
|
|||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||||
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
||||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
||||||
),
|
),
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component.cards
|
package dev.krtirtho.spotube.core.ui.component.cards
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -38,7 +37,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@ -58,18 +56,10 @@ fun AvatarCard(
|
|||||||
imageURL: String? = null,
|
imageURL: String? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isBlacklisted: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isBlacklisted) {
|
|
||||||
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
|
||||||
} else {
|
|
||||||
Color.Transparent
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(backgroundColor)
|
|
||||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||||
.width(160.dp),
|
.width(160.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,164 +1,157 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import io.github.kdroidfilter.webview.web.WebContent
|
import io.github.kdroidfilter.webview.web.WebContent
|
||||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||||
import io.github.kdroidfilter.webview.cookie.CookieManager
|
import io.github.kdroidfilter.webview.cookie.CookieManager
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
|
class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
|
||||||
private val logger by injectLogger<WebViewController>()
|
private val logger by injectLogger<WebViewController>()
|
||||||
private var cookieManager: CookieManager? = null
|
private var cookieManager: CookieManager? = null
|
||||||
private val urlFlow = MutableStateFlow("")
|
private val urlFlow = MutableStateFlow("")
|
||||||
private val webViewCreated = MutableSharedFlow<Unit>(replay = 1)
|
private val webViewCreated = MutableSharedFlow<Unit>(replay = 1)
|
||||||
var currentPluginId: String? = null
|
|
||||||
private set
|
suspend fun getCookies(url: String): List<Cookie> {
|
||||||
|
if (cookieManager == null) {
|
||||||
suspend fun getCookies(url: String): List<Cookie> {
|
logger.w { "CookieManager is not initialized. Returning empty cookie list." }
|
||||||
if (cookieManager == null) {
|
return emptyList()
|
||||||
logger.w { "CookieManager is not initialized. Returning empty cookie list." }
|
}
|
||||||
return emptyList()
|
val cookies = cookieManager!!.getCookies(url)
|
||||||
}
|
val cookieList = mutableListOf<Cookie>()
|
||||||
val cookies = cookieManager!!.getCookies(url)
|
cookies.forEach {
|
||||||
val cookieList = mutableListOf<Cookie>()
|
cookieList.add(
|
||||||
cookies.forEach {
|
Cookie(
|
||||||
cookieList.add(
|
name = it.name,
|
||||||
Cookie(
|
value = it.value,
|
||||||
name = it.name,
|
domain = it.domain ?: "",
|
||||||
value = it.value,
|
path = it.path,
|
||||||
domain = it.domain ?: "",
|
expiresAt = it.expiresDate,
|
||||||
path = it.path,
|
secure = it.isSecure ?: false,
|
||||||
expiresAt = it.expiresDate,
|
httpOnly = it.isHttpOnly ?: false
|
||||||
secure = it.isSecure ?: false,
|
)
|
||||||
httpOnly = it.isHttpOnly ?: false
|
)
|
||||||
)
|
}
|
||||||
)
|
return cookieList
|
||||||
}
|
}
|
||||||
return cookieList
|
|
||||||
}
|
private var content: String? = null
|
||||||
|
private var isHtmlContent: Boolean = false
|
||||||
private var content: String? = null
|
fun getContent(): String? = content
|
||||||
private var isHtmlContent: Boolean = false
|
fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent {
|
||||||
fun getContent(): String? = content
|
if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
|
||||||
fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent {
|
if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
|
||||||
if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
|
return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
|
||||||
if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
|
}
|
||||||
return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
|
|
||||||
}
|
var webViewNavigator: WebViewNavigator? = null
|
||||||
|
|
||||||
var webViewNavigator: WebViewNavigator? = null
|
fun emitUrlChange(url: String) {
|
||||||
|
urlFlow.value = url
|
||||||
fun emitUrlChange(url: String) {
|
}
|
||||||
urlFlow.value = url
|
|
||||||
}
|
fun emitWebViewCreated() {
|
||||||
|
webViewCreated.tryEmit(Unit)
|
||||||
fun emitWebViewCreated() {
|
}
|
||||||
webViewCreated.tryEmit(Unit)
|
|
||||||
}
|
fun setCookieManager(cookieManager: CookieManager) {
|
||||||
|
this.cookieManager = cookieManager
|
||||||
fun setCookieManager(cookieManager: CookieManager) {
|
}
|
||||||
this.cookieManager = cookieManager
|
|
||||||
}
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun closeWebview() {
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
cookieManager = null
|
||||||
fun closeWebview() {
|
content = null
|
||||||
cookieManager = null
|
isHtmlContent = false
|
||||||
content = null
|
webViewNavigator = null
|
||||||
isHtmlContent = false
|
navigationCommands.pop(Routes.WebView)
|
||||||
webViewNavigator = null
|
urlFlow.value = ""
|
||||||
navigationCommands.pop(Routes.WebView)
|
webViewCreated.resetReplayCache()
|
||||||
urlFlow.value = ""
|
_postMessagesFlow.resetReplayCache()
|
||||||
webViewCreated.resetReplayCache()
|
}
|
||||||
_postMessagesFlow.resetReplayCache()
|
|
||||||
}
|
fun dispose() {
|
||||||
|
cookieManager = null
|
||||||
fun dispose() {
|
content = null
|
||||||
cookieManager = null
|
isHtmlContent = false
|
||||||
content = null
|
webViewNavigator = null
|
||||||
isHtmlContent = false
|
}
|
||||||
webViewNavigator = null
|
|
||||||
}
|
fun navigateTo(url: String) {
|
||||||
|
if (this.content != null) {
|
||||||
fun navigateTo(url: String, pluginId: String) {
|
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
||||||
if (this.content != null) {
|
}
|
||||||
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
this.content = url
|
||||||
}
|
this.isHtmlContent = false
|
||||||
this.currentPluginId = pluginId
|
navigationCommands.navigateTo(Routes.WebView)
|
||||||
this.content = url
|
}
|
||||||
this.isHtmlContent = false
|
|
||||||
navigationCommands.navigateTo(Routes.WebView)
|
fun navigateToHTML(html: String) {
|
||||||
}
|
if (this.content != null) {
|
||||||
|
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
||||||
fun navigateToHTML(html: String, pluginId: String) {
|
}
|
||||||
if (this.content != null) {
|
this.content = html
|
||||||
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
this.isHtmlContent = true
|
||||||
}
|
navigationCommands.navigateTo(Routes.WebView)
|
||||||
this.currentPluginId = pluginId
|
}
|
||||||
this.content = html
|
|
||||||
this.isHtmlContent = true
|
suspend fun evaluateJavascript(jsCode: String): String? {
|
||||||
navigationCommands.navigateTo(Routes.WebView)
|
if (webViewNavigator == null) {
|
||||||
}
|
throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
|
||||||
|
}
|
||||||
suspend fun evaluateJavascript(jsCode: String): String? {
|
val completer = CompletableDeferred<String?>()
|
||||||
if (webViewNavigator == null) {
|
try {
|
||||||
throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
|
webViewNavigator?.evaluateJavaScript(jsCode) { result ->
|
||||||
}
|
completer.complete(result)
|
||||||
val completer = CompletableDeferred<String?>()
|
}
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
webViewNavigator?.evaluateJavaScript(jsCode) { result ->
|
completer.completeExceptionally(e)
|
||||||
completer.complete(result)
|
throw e
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
return completer.await()
|
||||||
completer.completeExceptionally(e)
|
}
|
||||||
throw e
|
|
||||||
}
|
suspend fun clearData() {
|
||||||
return completer.await()
|
cookieManager?.removeAllCookies()
|
||||||
}
|
cookieManager = null
|
||||||
|
content = null
|
||||||
suspend fun clearData(pluginId: String? = null) {
|
isHtmlContent = false
|
||||||
cookieManager?.removeAllCookies()
|
webViewNavigator = null
|
||||||
val targetPluginId = pluginId ?: currentPluginId
|
}
|
||||||
platformClearWebviewData(targetPluginId)
|
|
||||||
cookieManager = null
|
val urlChangedFlow = urlFlow.asStateFlow()
|
||||||
content = null
|
val webviewCreatedFlow = webViewCreated.asSharedFlow()
|
||||||
isHtmlContent = false
|
private val _postMessagesFlow = MutableSharedFlow<String>(replay = 1)
|
||||||
webViewNavigator = null
|
fun emitPostMessage(message: String) {
|
||||||
currentPluginId = null
|
_postMessagesFlow.tryEmit(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
val urlChangedFlow = urlFlow.asStateFlow()
|
val postMessagesFlow = _postMessagesFlow.asSharedFlow()
|
||||||
val webviewCreatedFlow = webViewCreated.asSharedFlow()
|
|
||||||
private val _postMessagesFlow = MutableSharedFlow<String>(replay = 1)
|
|
||||||
fun emitPostMessage(message: String) {
|
|
||||||
_postMessagesFlow.tryEmit(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
val postMessagesFlow = _postMessagesFlow.asSharedFlow()
|
|
||||||
}
|
}
|
||||||
@ -1,24 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import io.github.kdroidfilter.webview.web.WebViewState
|
import io.github.kdroidfilter.webview.web.WebViewState
|
||||||
|
|
||||||
expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)
|
expect fun platformWebviewConfig(webView: WebViewState)
|
||||||
|
|
||||||
expect suspend fun platformClearWebviewData(pluginId: String?)
|
|
||||||
|
|||||||
@ -1,222 +1,228 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import compose.icons.FeatherIcons
|
import androidx.compose.ui.Alignment
|
||||||
import compose.icons.feathericons.ChevronLeft
|
import androidx.compose.ui.Modifier
|
||||||
import compose.icons.feathericons.ChevronRight
|
import androidx.compose.ui.graphics.Color
|
||||||
import compose.icons.feathericons.X
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
import androidx.compose.ui.unit.dp
|
||||||
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
|
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
|
||||||
import io.github.kdroidfilter.webview.jsbridge.JsMessage
|
import io.github.kdroidfilter.webview.jsbridge.JsMessage
|
||||||
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
|
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
|
||||||
import io.github.kdroidfilter.webview.web.WebView
|
import io.github.kdroidfilter.webview.web.WebView
|
||||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
|
||||||
import io.github.kdroidfilter.webview.web.WebViewState
|
import io.github.kdroidfilter.webview.web.WebViewState
|
||||||
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
|
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||||
|
import compose.icons.FeatherIcons
|
||||||
class PostMessageHandler(
|
import compose.icons.feathericons.ChevronLeft
|
||||||
private val onMessageReceived: (String) -> Unit = {}
|
import compose.icons.feathericons.ChevronRight
|
||||||
) : IJsMessageHandler {
|
import compose.icons.feathericons.X
|
||||||
override fun methodName(): String {
|
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||||
return "sendMessage"
|
import kotlinx.coroutines.launch
|
||||||
}
|
|
||||||
|
class PostMessageHandler(
|
||||||
override fun handle(
|
private val onMessageReceived: (String) -> Unit = {}
|
||||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
) : IJsMessageHandler {
|
||||||
) {
|
override fun methodName(): String {
|
||||||
onMessageReceived(message.params)
|
return "sendMessage"
|
||||||
callback(message.params)
|
}
|
||||||
}
|
|
||||||
}
|
override fun handle(
|
||||||
|
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||||
@Composable
|
) {
|
||||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
onMessageReceived(message.params)
|
||||||
if (webViewController.getContent() == null) {
|
callback(message.params)
|
||||||
// This should never happen, but just in case
|
}
|
||||||
Text("No URL to load")
|
}
|
||||||
return
|
|
||||||
}
|
@Composable
|
||||||
|
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||||
val state = remember {
|
if (webViewController.getContent() == null) {
|
||||||
WebViewState(
|
// This should never happen, but just in case
|
||||||
webViewController.getWebContent(
|
Text("No URL to load")
|
||||||
additionalHttpHeaders = mapOf(
|
return
|
||||||
"User-Agent" to UserAgents.random()
|
}
|
||||||
)
|
|
||||||
)
|
val state = remember {
|
||||||
)
|
WebViewState(
|
||||||
}.apply {
|
webViewController.getWebContent(
|
||||||
this.content = webViewController.getWebContent()
|
additionalHttpHeaders = mapOf(
|
||||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
"User-Agent" to UserAgents.random()
|
||||||
}
|
)
|
||||||
|
)
|
||||||
val navigator = rememberWebViewNavigator()
|
)
|
||||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
}.apply {
|
||||||
|
this.content = webViewController.getWebContent()
|
||||||
val bridgeBootstrapScript = remember {
|
platformWebviewConfig(this)
|
||||||
"""
|
}
|
||||||
(function() {
|
|
||||||
if (typeof window.sendMessage !== "function") {
|
val navigator = rememberWebViewNavigator()
|
||||||
window.sendMessage = function(message) {
|
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||||
if (typeof message !== "string") {
|
|
||||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
val bridgeBootstrapScript = remember {
|
||||||
}
|
"""
|
||||||
window.kmpJsBridge.callNative("sendMessage", message);
|
(function() {
|
||||||
};
|
if (typeof window.sendMessage !== "function") {
|
||||||
}
|
window.sendMessage = function(message) {
|
||||||
|
if (typeof message !== "string") {
|
||||||
if (!window.bridgeReady) {
|
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||||
const event = new CustomEvent("onBridgeReady");
|
}
|
||||||
window.dispatchEvent(event);
|
window.kmpJsBridge.callNative("sendMessage", message);
|
||||||
window.bridgeReady = true;
|
};
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
""".trimIndent()
|
if (!window.bridgeReady) {
|
||||||
}
|
const event = new CustomEvent("onBridgeReady");
|
||||||
|
window.dispatchEvent(event);
|
||||||
LaunchedEffect(state) {
|
window.bridgeReady = true;
|
||||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
}
|
||||||
if (url != null) {
|
})();
|
||||||
webViewController.emitUrlChange(url)
|
""".trimIndent()
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
}
|
||||||
webViewController.emitWebViewCreated()
|
|
||||||
}
|
LaunchedEffect(state) {
|
||||||
}
|
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||||
}
|
if (url != null) {
|
||||||
|
webViewController.emitUrlChange(url)
|
||||||
LaunchedEffect(state.cookieManager, navigator) {
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
webViewController.emitWebViewCreated()
|
||||||
webViewController.webViewNavigator = navigator
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(webViewBridge) {
|
|
||||||
webViewBridge.register(PostMessageHandler { message ->
|
LaunchedEffect(state.cookieManager, navigator) {
|
||||||
webViewController.emitPostMessage(message)
|
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||||
})
|
webViewController.webViewNavigator = navigator
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
LaunchedEffect(webViewBridge) {
|
||||||
onDispose {
|
webViewBridge.register(PostMessageHandler { message ->
|
||||||
webViewController.dispose()
|
webViewController.emitPostMessage(message)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
DisposableEffect(Unit) {
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
onDispose {
|
||||||
topBar = {
|
webViewController.dispose()
|
||||||
Row(
|
}
|
||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
}
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Scaffold(
|
||||||
) {
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
Row(
|
topBar = {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Row(
|
||||||
horizontalArrangement = Arrangement.Start,
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||||
modifier = Modifier.height(56.dp)
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
IconButton(
|
) {
|
||||||
onClick = {
|
Row(
|
||||||
navigator.navigateBack()
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
}, enabled = navigator.canGoBack
|
horizontalArrangement = Arrangement.Start,
|
||||||
) {
|
modifier = Modifier.height(56.dp)
|
||||||
Icon(
|
) {
|
||||||
FeatherIcons.ChevronLeft,
|
IconButton(
|
||||||
contentDescription = "Go back to browser history"
|
onClick = {
|
||||||
)
|
navigator.navigateBack()
|
||||||
}
|
}, enabled = navigator.canGoBack
|
||||||
IconButton(
|
) {
|
||||||
onClick = {
|
Icon(
|
||||||
navigator.navigateForward()
|
FeatherIcons.ChevronLeft,
|
||||||
}, enabled = navigator.canGoForward
|
contentDescription = "Go back to browser history"
|
||||||
) {
|
)
|
||||||
Icon(
|
}
|
||||||
FeatherIcons.ChevronRight,
|
IconButton(
|
||||||
contentDescription = "Go forward to browser history"
|
onClick = {
|
||||||
)
|
navigator.navigateForward()
|
||||||
}
|
}, enabled = navigator.canGoForward
|
||||||
}
|
) {
|
||||||
Surface(
|
Icon(
|
||||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
FeatherIcons.ChevronRight,
|
||||||
shape = RoundedCornerShape(18.dp),
|
contentDescription = "Go forward to browser history"
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
)
|
||||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
}
|
||||||
) {
|
}
|
||||||
BasicTextField(
|
Surface(
|
||||||
value = state.lastLoadedUrl ?: "",
|
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||||
onValueChange = {}, // Read-only
|
shape = RoundedCornerShape(18.dp),
|
||||||
readOnly = true,
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
singleLine = true,
|
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
BasicTextField(
|
||||||
textAlign = TextAlign.Start
|
value = state.lastLoadedUrl ?: "",
|
||||||
),
|
onValueChange = {}, // Read-only
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
readOnly = true,
|
||||||
.wrapContentHeight(Alignment.CenterVertically)
|
singleLine = true,
|
||||||
)
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
}
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
IconButton(
|
textAlign = TextAlign.Start
|
||||||
onClick = {
|
),
|
||||||
webViewController.closeWebview()
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||||
}) {
|
.wrapContentHeight(Alignment.CenterVertically)
|
||||||
Icon(FeatherIcons.X, contentDescription = "Close WebView")
|
)
|
||||||
}
|
}
|
||||||
}
|
IconButton(
|
||||||
}) { innerPadding ->
|
onClick = {
|
||||||
WebView(
|
webViewController.closeWebview()
|
||||||
state = state,
|
}) {
|
||||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
Icon(FeatherIcons.X, contentDescription = "Close WebView")
|
||||||
navigator = navigator,
|
}
|
||||||
webViewJsBridge = webViewBridge,
|
}
|
||||||
onCreated = { webView ->
|
}) { innerPadding ->
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
WebView(
|
||||||
|
state = state,
|
||||||
},
|
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||||
factory = null
|
navigator = navigator,
|
||||||
)
|
webViewJsBridge = webViewBridge,
|
||||||
}
|
onCreated = { webView ->
|
||||||
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
|
|
||||||
|
},
|
||||||
|
factory = null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,345 +1,345 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.zipline
|
package dev.krtirtho.spotube.core.zipline
|
||||||
|
|
||||||
import app.cash.zipline.Zipline
|
import app.cash.zipline.Zipline
|
||||||
import app.cash.zipline.ZiplineService
|
import app.cash.zipline.ZiplineService
|
||||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||||
import app.cash.zipline.loader.LoadResult
|
import app.cash.zipline.loader.LoadResult
|
||||||
import app.cash.zipline.loader.ManifestVerifier
|
import app.cash.zipline.loader.ManifestVerifier
|
||||||
import app.cash.zipline.loader.ZiplineLoader
|
import app.cash.zipline.loader.ZiplineLoader
|
||||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||||
import io.ktor.http.URLBuilder
|
import io.ktor.http.URLBuilder
|
||||||
import io.ktor.http.decodeURLQueryComponent
|
import io.ktor.http.decodeURLQueryComponent
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the lifecycle of a Zipline plugin.
|
* Manages the lifecycle of a Zipline plugin.
|
||||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||||
* Anything it provides, must be called within that dispatcher context.
|
* Anything it provides, must be called within that dispatcher context.
|
||||||
* The [use] function must be used.
|
* The [use] function must be used.
|
||||||
*
|
*
|
||||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||||
*
|
*
|
||||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||||
*/
|
*/
|
||||||
open class ZiplinePluginService(
|
open class ZiplinePluginService(
|
||||||
val applicationName: String,
|
val applicationName: String,
|
||||||
private val manifestUrl: String,
|
private val manifestUrl: String,
|
||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry,
|
||||||
) : PluginService, KoinComponent {
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||||
// instance is created, before any modules are loaded.
|
// instance is created, before any modules are loaded.
|
||||||
private val ziplineDispatcher = createZiplineDispatcher()
|
private val ziplineDispatcher = createZiplineDispatcher()
|
||||||
|
|
||||||
private fun trace(event: String) {
|
private fun trace(event: String) {
|
||||||
logger.d { "[$applicationName] $event" }
|
logger.d { "[$applicationName] $event" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val logger by injectLogger<ZiplinePluginService>()
|
private val logger by injectLogger<ZiplinePluginService>()
|
||||||
private val webViewController: WebViewController by inject()
|
private val webViewController: WebViewController by inject()
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||||
logger.e(throwable) { "Zipline Engine Error" }
|
logger.e(throwable) { "Zipline Engine Error" }
|
||||||
}
|
}
|
||||||
private val lifecycleMutex = Mutex()
|
private val lifecycleMutex = Mutex()
|
||||||
private var ziplineLoader: ZiplineLoader
|
private var ziplineLoader: ZiplineLoader
|
||||||
private var ziplineInstance: Zipline? = null
|
private var ziplineInstance: Zipline? = null
|
||||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val manifestPath = URLBuilder(manifestUrl)
|
val manifestPath = URLBuilder(manifestUrl)
|
||||||
val baseDir =
|
val baseDir =
|
||||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||||
ziplineLoader = ZiplineLoader(
|
ziplineLoader = ZiplineLoader(
|
||||||
dispatcher = ziplineDispatcher.dispatcher,
|
dispatcher = ziplineDispatcher.dispatcher,
|
||||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||||
httpClient = FileSystemHTTPClient(baseDir)
|
httpClient = FileSystemHTTPClient(baseDir)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val realHttpClientAPI = RealHttpClientAPI()
|
private val realHttpClientAPI = RealHttpClientAPI()
|
||||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
private val realWebViewAPI = RealWebViewAPI(scope, webViewController)
|
||||||
|
|
||||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||||
private val systemInformationAPI = RealSystemInformationAPI()
|
private val systemInformationAPI = RealSystemInformationAPI()
|
||||||
|
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||||
|
|
||||||
private fun bindHostServices(zipline: Zipline) {
|
private fun bindHostServices(zipline: Zipline) {
|
||||||
trace("initializer(): binding host APIs")
|
trace("initializer(): binding host APIs")
|
||||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||||
try {
|
try {
|
||||||
// Basic APIs
|
// Basic APIs
|
||||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||||
zipline.bind<SystemInformationAPI>(
|
zipline.bind<SystemInformationAPI>(
|
||||||
SystemInformationAPI_SERVICE_NAME,
|
SystemInformationAPI_SERVICE_NAME,
|
||||||
systemInformationAPI
|
systemInformationAPI
|
||||||
)
|
)
|
||||||
|
|
||||||
// Conditional APIs based on plugin capabilities
|
// Conditional APIs based on plugin capabilities
|
||||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||||
zipline.bind<HttpClientAPI>(
|
zipline.bind<HttpClientAPI>(
|
||||||
HttpClientAPI_SERVICE_NAME,
|
HttpClientAPI_SERVICE_NAME,
|
||||||
realHttpClientAPI
|
realHttpClientAPI
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||||
}
|
}
|
||||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||||
zipline.bind<PersistedStorageAPI>(
|
zipline.bind<PersistedStorageAPI>(
|
||||||
PersistedStorageAPI_SERVICE_NAME,
|
PersistedStorageAPI_SERVICE_NAME,
|
||||||
persistedStorageAPI
|
persistedStorageAPI
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumePluginServices(result: LoadResult.Success) {
|
private fun consumePluginServices(result: LoadResult.Success) {
|
||||||
trace("start(): loadOnce success")
|
trace("start(): loadOnce success")
|
||||||
val apiMap =
|
val apiMap =
|
||||||
buildMap<KClass<*>, ZiplineService> {
|
buildMap<KClass<*>, ZiplineService> {
|
||||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||||
|
|
||||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||||
put(
|
put(
|
||||||
MetadataUserAPI::class,
|
MetadataUserAPI::class,
|
||||||
result.zipline.take<MetadataUserAPI>(
|
result.zipline.take<MetadataUserAPI>(
|
||||||
MetadataUserAPI_SERVICE_NAME
|
MetadataUserAPI_SERVICE_NAME
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
MetadataTrackAPI::class,
|
MetadataTrackAPI::class,
|
||||||
result.zipline.take<MetadataTrackAPI>(
|
result.zipline.take<MetadataTrackAPI>(
|
||||||
MetadataTrackAPI_SERVICE_NAME
|
MetadataTrackAPI_SERVICE_NAME
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
MetadataAlbumAPI::class,
|
MetadataAlbumAPI::class,
|
||||||
result.zipline.take<MetadataAlbumAPI>(
|
result.zipline.take<MetadataAlbumAPI>(
|
||||||
MetadataAlbumAPI_SERVICE_NAME
|
MetadataAlbumAPI_SERVICE_NAME
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
MetadataArtistAPI::class,
|
MetadataArtistAPI::class,
|
||||||
result.zipline.take<MetadataArtistAPI>(
|
result.zipline.take<MetadataArtistAPI>(
|
||||||
MetadataArtistAPI_SERVICE_NAME
|
MetadataArtistAPI_SERVICE_NAME
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
MetadataPlaylistAPI::class,
|
MetadataPlaylistAPI::class,
|
||||||
result.zipline.take<MetadataPlaylistAPI>(
|
result.zipline.take<MetadataPlaylistAPI>(
|
||||||
MetadataPlaylistAPI_SERVICE_NAME
|
MetadataPlaylistAPI_SERVICE_NAME
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
MetadataBrowseAPI::class,
|
MetadataBrowseAPI::class,
|
||||||
result.zipline.take<MetadataBrowseAPI>(
|
result.zipline.take<MetadataBrowseAPI>(
|
||||||
MetadataBrowseAPI_SERVICE_NAME
|
MetadataBrowseAPI_SERVICE_NAME
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
put(
|
put(
|
||||||
MetadataSearchAPI::class,
|
MetadataSearchAPI::class,
|
||||||
result.zipline.take<MetadataSearchAPI>(
|
result.zipline.take<MetadataSearchAPI>(
|
||||||
MetadataSearchAPI_SERVICE_NAME
|
MetadataSearchAPI_SERVICE_NAME
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||||
put(
|
put(
|
||||||
AudioAPI::class,
|
AudioAPI::class,
|
||||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||||
put(
|
put(
|
||||||
LyricsAPI::class,
|
LyricsAPI::class,
|
||||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||||
put(
|
put(
|
||||||
ScrobbleAPI::class,
|
ScrobbleAPI::class,
|
||||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceRegistry.putAll(apiMap)
|
serviceRegistry.putAll(apiMap)
|
||||||
trace("start(): API ready")
|
trace("start(): API ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runLogInFlowObservers() = scope.launch {
|
private fun runLogInFlowObservers() = scope.launch {
|
||||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||||
loggedInStateFlow.value = isLoggedIn
|
loggedInStateFlow.value = isLoggedIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun start() {
|
override suspend fun start() {
|
||||||
lifecycleMutex.withLock {
|
lifecycleMutex.withLock {
|
||||||
trace("start(): entered")
|
trace("start(): entered")
|
||||||
if (serviceRegistry.isNotEmpty()) {
|
if (serviceRegistry.isNotEmpty()) {
|
||||||
trace("start(): already started, skipping")
|
trace("start(): already started, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||||
withContext(ziplineDispatcher.dispatcher) {
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
trace("start(): inside zipline dispatcher before loadOnce")
|
trace("start(): inside zipline dispatcher before loadOnce")
|
||||||
val result = ziplineLoader.loadOnce(
|
val result = ziplineLoader.loadOnce(
|
||||||
applicationName = applicationName,
|
applicationName = applicationName,
|
||||||
manifestUrl = manifestUrl,
|
manifestUrl = manifestUrl,
|
||||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
||||||
)
|
)
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadResult.Success -> {
|
is LoadResult.Success -> {
|
||||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||||
ziplineInstance = result.zipline
|
ziplineInstance = result.zipline
|
||||||
// Now we consume the initializer
|
// Now we consume the initializer
|
||||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||||
// Bind host services before initialization, so plugins can use them in their initializer
|
// Bind host services before initialization, so plugins can use them in their initializer
|
||||||
bindHostServices(result.zipline)
|
bindHostServices(result.zipline)
|
||||||
trace("start(): calling initializer.initialize()")
|
trace("start(): calling initializer.initialize()")
|
||||||
runCatching { initializer.initialize() }
|
runCatching { initializer.initialize() }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
consumePluginServices(result)
|
consumePluginServices(result)
|
||||||
runLogInFlowObservers()
|
runLogInFlowObservers()
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is LoadResult.Failure -> {
|
is LoadResult.Failure -> {
|
||||||
trace("start(): loadOnce failure: ${result.exception}")
|
trace("start(): loadOnce failure: ${result.exception}")
|
||||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||||
throw result.exception
|
throw result.exception
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun stop() {
|
override suspend fun stop() {
|
||||||
lifecycleMutex.withLock {
|
lifecycleMutex.withLock {
|
||||||
trace("stop(): entered")
|
trace("stop(): entered")
|
||||||
withContext(ziplineDispatcher.dispatcher) {
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
ziplineInstance?.close()
|
ziplineInstance?.close()
|
||||||
ziplineInstance = null
|
ziplineInstance = null
|
||||||
for (service in serviceRegistry.values) {
|
for (service in serviceRegistry.values) {
|
||||||
try {
|
try {
|
||||||
trace("stop(): closing service ${service::class.simpleName}")
|
trace("stop(): closing service ${service::class.simpleName}")
|
||||||
service.close()
|
service.close()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
trace("stop(): error closing service ${service::class.simpleName}")
|
trace("stop(): error closing service ${service::class.simpleName}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serviceRegistry.clear()
|
serviceRegistry.clear()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
loggedInStateFlow.value = false
|
loggedInStateFlow.value = false
|
||||||
ziplineDispatcher.close()
|
ziplineDispatcher.close()
|
||||||
trace("stop(): completed")
|
trace("stop(): completed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||||
// Create the scope with the current registry
|
// Create the scope with the current registry
|
||||||
val scope = PluginServiceScope(serviceRegistry)
|
val scope = PluginServiceScope(serviceRegistry)
|
||||||
// Execute the block with 'scope' as 'this'
|
// Execute the block with 'scope' as 'this'
|
||||||
scope.block()
|
scope.block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,74 +1,76 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.zipline.host_apis
|
package dev.krtirtho.spotube.core.zipline.host_apis
|
||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
class RealWebViewAPI(
|
import kotlinx.coroutines.launch
|
||||||
private val scope: CoroutineScope,
|
import kotlinx.coroutines.withContext
|
||||||
private val webViewController: WebViewController,
|
|
||||||
private val pluginId: String,
|
class RealWebViewAPI(
|
||||||
) : WebViewAPI {
|
private val scope: CoroutineScope,
|
||||||
override fun navigateTo(url: String) {
|
private val webViewController: WebViewController,
|
||||||
scope.launch(Dispatchers.Main) {
|
) : WebViewAPI {
|
||||||
webViewController.navigateTo(url, pluginId)
|
override fun navigateTo(url: String) {
|
||||||
}
|
scope.launch(Dispatchers.Main) {
|
||||||
}
|
webViewController.navigateTo(url)
|
||||||
|
}
|
||||||
override fun navigateToHTML(html: String) {
|
}
|
||||||
scope.launch {
|
|
||||||
webViewController.navigateToHTML(html, pluginId)
|
override fun navigateToHTML(html: String) {
|
||||||
}
|
scope.launch {
|
||||||
}
|
webViewController.navigateToHTML(html)
|
||||||
|
}
|
||||||
override suspend fun getCookies(url: String): List<Cookie> {
|
}
|
||||||
return withContext(Dispatchers.Main) {
|
|
||||||
webViewController.getCookies(url)
|
override suspend fun getCookies(url: String): List<Cookie> {
|
||||||
}
|
return withContext(Dispatchers.Main) {
|
||||||
}
|
webViewController.getCookies(url)
|
||||||
|
}
|
||||||
override suspend fun evaluateJavaScript(script: String): String? {
|
}
|
||||||
return withContext(Dispatchers.Main) {
|
|
||||||
webViewController.evaluateJavascript(script)
|
override suspend fun evaluateJavaScript(script: String): String? {
|
||||||
}
|
return withContext(Dispatchers.Main) {
|
||||||
}
|
webViewController.evaluateJavascript(script)
|
||||||
|
}
|
||||||
override fun urlChangeFlow(): Flow<String> {
|
}
|
||||||
return webViewController.urlChangedFlow
|
|
||||||
}
|
override fun urlChangeFlow(): Flow<String> {
|
||||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
return webViewController.urlChangedFlow
|
||||||
return webViewController.webviewCreatedFlow
|
}
|
||||||
}
|
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||||
override fun postMessagesFlow(): Flow<String> {
|
return webViewController.webviewCreatedFlow
|
||||||
return webViewController.postMessagesFlow
|
}
|
||||||
}
|
override fun postMessagesFlow(): Flow<String> {
|
||||||
|
return webViewController.postMessagesFlow
|
||||||
override fun exitWebView() {
|
}
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
webViewController.closeWebview()
|
override fun exitWebView() {
|
||||||
}
|
scope.launch(Dispatchers.Main) {
|
||||||
}
|
webViewController.closeWebview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -27,7 +27,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
@ -37,7 +36,6 @@ import dev.krtirtho.spotube.core.share.ShareService
|
|||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
@ -47,18 +45,18 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumScreen(
|
fun AlbumScreen(albumId: String) {
|
||||||
albumId: String,
|
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||||
viewModel: AlbumViewModel,
|
val audioPlayer: AudioPlayer = koinInject()
|
||||||
audioPlayerQueue: AudioPlayerQueue,
|
val shareService: ShareService = koinInject()
|
||||||
audioPlayer: AudioPlayerInterface,
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
shareService: ShareService,
|
val libraryRepository: LibraryRepository = koinInject()
|
||||||
downloadsViewModel: DownloadsViewModel,
|
|
||||||
libraryRepository: LibraryRepository,
|
|
||||||
navigationCommands: NavigationCommands
|
|
||||||
) {
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val viewModel = koinViewModel<AlbumViewModel>(
|
||||||
|
key = albumId,
|
||||||
|
parameters = { parametersOf(albumId) }
|
||||||
|
)
|
||||||
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
@ -66,8 +64,6 @@ fun AlbumScreen(
|
|||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
||||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
|
||||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
|
||||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||||
@ -81,12 +77,12 @@ fun AlbumScreen(
|
|||||||
val queueTrackIds = queue.mapNotNull { entry ->
|
val queueTrackIds = queue.mapNotNull { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
}.toSet()
|
}.toSet()
|
||||||
return TrackOptionsState(
|
return TrackOptionsState(
|
||||||
isInQueue = queueTrackIds.contains(track.id),
|
isInQueue = queueTrackIds.contains(track.id),
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = savedTrackIds.contains(track.id),
|
isFavorite = savedTrackIds.contains(track.id),
|
||||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
isBlacklisted = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||||
|
|||||||
@ -27,7 +27,6 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
|||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@ -37,8 +36,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@ -88,7 +85,6 @@ class AlbumViewModel(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val playbackHelper: CollectionPlaybackHelper,
|
private val playbackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<AlbumViewModel>()
|
private val logger by injectLogger<AlbumViewModel>()
|
||||||
|
|
||||||
@ -97,12 +93,6 @@ class AlbumViewModel(
|
|||||||
val savedAlbumIds
|
val savedAlbumIds
|
||||||
get() = libraryRepository.savedAlbumIdsFlow
|
get() = libraryRepository.savedAlbumIdsFlow
|
||||||
|
|
||||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
|
||||||
|
|
||||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.pluginManager.selectedMetadataPlugin
|
repository.pluginManager.selectedMetadataPlugin
|
||||||
@ -113,12 +103,6 @@ class AlbumViewModel(
|
|||||||
loadInitialData()
|
loadInitialData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blacklistRepository.blacklistedTracks
|
|
||||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
blacklistRepository.blacklistedArtists
|
|
||||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadInitialData() = runCatching {
|
private suspend fun loadInitialData() = runCatching {
|
||||||
@ -236,7 +220,7 @@ class AlbumViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> {}
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {}
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
is TrackOptionsAction.AddToPlaylist -> {}
|
||||||
}
|
}
|
||||||
@ -245,46 +229,18 @@ class AlbumViewModel(
|
|||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
|
||||||
val trackIds = _blacklistedTrackIds.value
|
|
||||||
val artistIds = _blacklistedArtistIds.value
|
|
||||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
blacklistRepository.toggleTrack(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val savedTrackIds
|
val savedTrackIds
|
||||||
get() = savedTracksRepository.savedTracksIdsFlow
|
get() = savedTracksRepository.savedTracksIdsFlow
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
@ -85,30 +85,36 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
|||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistScreen(
|
fun ArtistScreen(artistId: String) {
|
||||||
viewModel: ArtistViewModel,
|
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||||
audioPlayerQueue: AudioPlayerQueue,
|
val audioPlayer: AudioPlayer = koinInject()
|
||||||
audioPlayer: AudioPlayerInterface,
|
val shareService: ShareService = koinInject()
|
||||||
shareService: ShareService,
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
downloadsViewModel: DownloadsViewModel,
|
val libraryRepository: LibraryRepository = koinInject()
|
||||||
libraryRepository: LibraryRepository,
|
|
||||||
navigationCommands: NavigationCommands
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val viewModel = koinViewModel<ArtistViewModel>(
|
||||||
|
key = artistId,
|
||||||
|
parameters = { parametersOf(artistId) }
|
||||||
|
)
|
||||||
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||||
@ -116,8 +122,6 @@ fun ArtistScreen(
|
|||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
||||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
|
||||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
|
||||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||||
@ -135,7 +139,7 @@ fun ArtistScreen(
|
|||||||
isInQueue = queueTrackIds.contains(track.id),
|
isInQueue = queueTrackIds.contains(track.id),
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = savedTrackIds.contains(track.id),
|
isFavorite = savedTrackIds.contains(track.id),
|
||||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
isBlacklisted = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,8 +191,6 @@ fun ArtistScreen(
|
|||||||
artist = currentState.artist,
|
artist = currentState.artist,
|
||||||
isSaved = savedArtistIds.contains(currentState.artist.id),
|
isSaved = savedArtistIds.contains(currentState.artist.id),
|
||||||
onFollowClick = viewModel::toggleSavedArtist,
|
onFollowClick = viewModel::toggleSavedArtist,
|
||||||
isBlacklisted = currentState.artist.id in blacklistedArtistIds,
|
|
||||||
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,8 +398,6 @@ private fun ArtistHeaderCard(
|
|||||||
artist: MetadataArtist.Detailed,
|
artist: MetadataArtist.Detailed,
|
||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
onFollowClick: () -> Unit,
|
onFollowClick: () -> Unit,
|
||||||
isBlacklisted: Boolean,
|
|
||||||
onBlacklistClick: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
val isCompact = maxWidth < 600.dp
|
val isCompact = maxWidth < 600.dp
|
||||||
@ -432,8 +432,6 @@ private fun ArtistHeaderCard(
|
|||||||
ArtistHeaderActions(
|
ArtistHeaderActions(
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
onFollowClick = onFollowClick,
|
onFollowClick = onFollowClick,
|
||||||
isBlacklisted = isBlacklisted,
|
|
||||||
onBlacklistClick = onBlacklistClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -461,8 +459,6 @@ private fun ArtistHeaderCard(
|
|||||||
ArtistHeaderActions(
|
ArtistHeaderActions(
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
onFollowClick = onFollowClick,
|
onFollowClick = onFollowClick,
|
||||||
isBlacklisted = isBlacklisted,
|
|
||||||
onBlacklistClick = onBlacklistClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -551,8 +547,6 @@ private fun ArtistMeta(
|
|||||||
private fun ArtistHeaderActions(
|
private fun ArtistHeaderActions(
|
||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
onFollowClick: () -> Unit,
|
onFollowClick: () -> Unit,
|
||||||
isBlacklisted: Boolean,
|
|
||||||
onBlacklistClick: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@ -567,23 +561,6 @@ private fun ArtistHeaderActions(
|
|||||||
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlacklisted) {
|
|
||||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxUserRemove,
|
|
||||||
contentDescription = "Remove from blacklist",
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxUserRemove,
|
|
||||||
contentDescription = "Add to blacklist",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,14 +28,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@ -64,7 +61,6 @@ class ArtistViewModel(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<ArtistViewModel>()
|
private val logger by injectLogger<ArtistViewModel>()
|
||||||
|
|
||||||
@ -77,12 +73,6 @@ class ArtistViewModel(
|
|||||||
val savedTrackIds
|
val savedTrackIds
|
||||||
get() = savedTracksRepository.savedTracksIdsFlow
|
get() = savedTracksRepository.savedTracksIdsFlow
|
||||||
|
|
||||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
|
||||||
|
|
||||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
libraryRepository.savedArtistIdsFlow.collect {
|
libraryRepository.savedArtistIdsFlow.collect {
|
||||||
@ -92,12 +82,6 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blacklistRepository.blacklistedTracks
|
|
||||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
blacklistRepository.blacklistedArtists
|
|
||||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
loadOverview()
|
loadOverview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,28 +101,6 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isArtistBlacklisted(artistId: String): Boolean {
|
|
||||||
return _blacklistedArtistIds.value.contains(artistId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleArtistBlacklist(artist: MetadataArtist) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
blacklistRepository.toggleArtist(artist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
|
||||||
val trackIds = _blacklistedTrackIds.value
|
|
||||||
val artistIds = _blacklistedArtistIds.value
|
|
||||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
blacklistRepository.toggleTrack(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadMoreAlbums() {
|
fun loadMoreAlbums() {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
if (currentState !is ArtistScreenState.Loaded) return
|
if (currentState !is ArtistScreenState.Loaded) return
|
||||||
@ -249,32 +211,17 @@ class ArtistViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -320,7 +267,7 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> {}
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {}
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
is TrackOptionsAction.AddToPlaylist -> {}
|
||||||
}
|
}
|
||||||
@ -356,23 +303,12 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveTopTrackEntries(): List<QueueEntry> {
|
private fun resolveTopTrackEntries(): List<QueueEntry> {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
if (currentState !is ArtistScreenState.Loaded) return emptyList()
|
if (currentState !is ArtistScreenState.Loaded) return emptyList()
|
||||||
|
return currentState.topTracks.map { track ->
|
||||||
val blacklistedTracks = blacklistRepository.getTracksSnapshot()
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
val blacklistedArtists = blacklistRepository.getArtistsSnapshot()
|
}
|
||||||
val blacklistedTrackIds = blacklistedTracks.map { it.id }.toSet()
|
|
||||||
val blacklistedArtistIds = blacklistedArtists.map { it.id }.toSet()
|
|
||||||
|
|
||||||
return currentState.topTracks
|
|
||||||
.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
.map { track ->
|
|
||||||
QueueEntry.StreamingTrack(track = track, url = "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,321 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
package dev.krtirtho.spotube.modules.blacklist
|
|
||||||
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingmor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.blacklist
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class BlacklistViewModel(
|
|
||||||
private val repository: BlacklistRepository,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
val blacklistedTracks: StateFlow<List<MetadataTrack>> = repository.blacklistedTracks
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
||||||
|
|
||||||
val blacklistedArtists: StateFlow<List<MetadataArtist>> = repository.blacklistedArtists
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
||||||
|
|
||||||
fun toggleTrack(track: MetadataTrack) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.toggleTrack(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleArtist(artist: MetadataArtist) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
repository.toggleArtist(artist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -34,7 +34,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
@ -58,17 +57,18 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistScreen(
|
fun PlaylistScreen(playlistId: String) {
|
||||||
playlistId: String,
|
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||||
viewModel: PlaylistViewModel,
|
val audioPlayer: AudioPlayer = koinInject()
|
||||||
audioPlayerQueue: AudioPlayerQueue,
|
val shareService: ShareService = koinInject()
|
||||||
audioPlayer: AudioPlayerInterface,
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
shareService: ShareService,
|
val libraryRepository: LibraryRepository = koinInject()
|
||||||
downloadsViewModel: DownloadsViewModel,
|
|
||||||
libraryRepository: LibraryRepository,
|
|
||||||
navigationCommands: NavigationCommands
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val viewModel = koinViewModel<PlaylistViewModel>(
|
||||||
|
key = playlistId,
|
||||||
|
parameters = { parametersOf(playlistId) }
|
||||||
|
)
|
||||||
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
@ -109,20 +109,18 @@ fun PlaylistScreen(
|
|||||||
playerState == PlayerState.PLAYING
|
playerState == PlayerState.PLAYING
|
||||||
|
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
|
||||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
val queueTrackIds = queue.mapNotNull { entry ->
|
val queueTrackIds = queue.mapNotNull { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
}.toSet()
|
}.toSet()
|
||||||
return TrackOptionsState(
|
return TrackOptionsState(
|
||||||
isInQueue = queueTrackIds.contains(track.id),
|
isInQueue = queueTrackIds.contains(track.id),
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = savedTrackIds.contains(track.id),
|
isFavorite = savedTrackIds.contains(track.id),
|
||||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
isBlacklisted = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val errorMessage = (state as? PlaylistScreenState.Error)?.message
|
val errorMessage = (state as? PlaylistScreenState.Error)?.message
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import dev.krtirtho.spotube.core.di.injectLogger
|
|||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@ -38,8 +37,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@ -89,7 +86,6 @@ class PlaylistViewModel(
|
|||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
private val playbackHelper: CollectionPlaybackHelper,
|
private val playbackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<PlaylistViewModel>()
|
private val logger by injectLogger<PlaylistViewModel>()
|
||||||
|
|
||||||
@ -101,12 +97,6 @@ class PlaylistViewModel(
|
|||||||
private val _currentUserId = MutableStateFlow<String?>(null)
|
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||||
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
||||||
|
|
||||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
|
||||||
|
|
||||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.pluginManager.selectedMetadataPlugin
|
repository.pluginManager.selectedMetadataPlugin
|
||||||
@ -118,12 +108,6 @@ class PlaylistViewModel(
|
|||||||
loadCurrentUser()
|
loadCurrentUser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blacklistRepository.blacklistedTracks
|
|
||||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
blacklistRepository.blacklistedArtists
|
|
||||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadCurrentUser() {
|
private suspend fun loadCurrentUser() {
|
||||||
@ -281,51 +265,23 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> {}
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {}
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
is TrackOptionsAction.AddToPlaylist -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
|
||||||
val trackIds = _blacklistedTrackIds.value
|
|
||||||
val artistIds = _blacklistedArtistIds.value
|
|
||||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
blacklistRepository.toggleTrack(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,11 +27,7 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
|
|||||||
PluginCapability.NETWORK_REQUESTS,
|
PluginCapability.NETWORK_REQUESTS,
|
||||||
PluginCapability.PERSISTENT_STORAGE
|
PluginCapability.PERSISTENT_STORAGE
|
||||||
),
|
),
|
||||||
abilities = listOf(PluginAbility.AUDIO),
|
abilities = listOf(PluginAbility.AUDIO)
|
||||||
contact = "",
|
|
||||||
repository = "",
|
|
||||||
bugs = "",
|
|
||||||
license = "",
|
|
||||||
)
|
)
|
||||||
val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
|
val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
|
||||||
name = "LRCLib Lyrics",
|
name = "LRCLib Lyrics",
|
||||||
@ -42,11 +38,7 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
|
|||||||
capabilities = listOf(
|
capabilities = listOf(
|
||||||
PluginCapability.NETWORK_REQUESTS,
|
PluginCapability.NETWORK_REQUESTS,
|
||||||
),
|
),
|
||||||
abilities = listOf(PluginAbility.LYRICS),
|
abilities = listOf(PluginAbility.LYRICS)
|
||||||
contact = "",
|
|
||||||
repository = "",
|
|
||||||
bugs = "",
|
|
||||||
license = "",
|
|
||||||
)
|
)
|
||||||
val BUILT_IN_PLUGINS = listOf(
|
val BUILT_IN_PLUGINS = listOf(
|
||||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.plugin
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.request.headers
|
|
||||||
import io.ktor.client.request.parameter
|
|
||||||
import io.ktor.http.append
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GitHubRepoSearchResponse(
|
|
||||||
@SerialName("total_count") val totalCount: Int,
|
|
||||||
@SerialName("incomplete_results") val incompleteResults: Boolean,
|
|
||||||
val items: List<GitHubRepo>,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GitHubRepo(
|
|
||||||
val id: Long,
|
|
||||||
@SerialName("full_name") val fullName: String,
|
|
||||||
val description: String? = null,
|
|
||||||
@SerialName("stargazers_count") val stargazersCount: Int = 0,
|
|
||||||
@SerialName("html_url") val htmlUrl: String,
|
|
||||||
val owner: GitHubOwner,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GitHubOwner(
|
|
||||||
val login: String,
|
|
||||||
@SerialName("avatar_url") val avatarUrl: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GitHubRelease(
|
|
||||||
@SerialName("tag_name") val tagName: String,
|
|
||||||
val name: String? = null,
|
|
||||||
val body: String? = null,
|
|
||||||
val assets: List<GitHubAsset>,
|
|
||||||
@SerialName("html_url") val htmlUrl: String,
|
|
||||||
val prerelease: Boolean = false,
|
|
||||||
val draft: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GitHubAsset(
|
|
||||||
val name: String,
|
|
||||||
@SerialName("browser_download_url") val browserDownloadUrl: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
class GitHubPluginRepository {
|
|
||||||
private val httpClient = HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json { ignoreUnknownKeys = true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse {
|
|
||||||
return httpClient.get("https://api.github.com/search/repositories") {
|
|
||||||
headers {
|
|
||||||
append("Accept", "application/vnd.github+json")
|
|
||||||
append("X-GitHub-Api-Version", "2022-11-28")
|
|
||||||
}
|
|
||||||
parameter("q", "topic:spotube-zipline-plugin")
|
|
||||||
parameter("sort", "stars")
|
|
||||||
parameter("order", "desc")
|
|
||||||
parameter("page", page)
|
|
||||||
parameter("per_page", perPage)
|
|
||||||
}.body()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLatestReleaseSmplugUrl(owner: String, repo: String): String? {
|
|
||||||
return try {
|
|
||||||
val release: GitHubRelease =
|
|
||||||
httpClient.get("https://api.github.com/repos/$owner/$repo/releases/latest") {
|
|
||||||
headers {
|
|
||||||
append("Accept", "application/vnd.github+json")
|
|
||||||
append("X-GitHub-Api-Version", "2022-11-28")
|
|
||||||
}
|
|
||||||
}.body()
|
|
||||||
release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getReleases(owner: String, repo: String, perPage: Int = 30): List<GitHubRelease> {
|
|
||||||
return try {
|
|
||||||
httpClient.get("https://api.github.com/repos/$owner/$repo/releases") {
|
|
||||||
headers {
|
|
||||||
append("Accept", "application/vnd.github+json")
|
|
||||||
append("X-GitHub-Api-Version", "2022-11-28")
|
|
||||||
}
|
|
||||||
parameter("per_page", perPage)
|
|
||||||
}.body()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
httpClient.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.plugin
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
|
|
||||||
data class PluginDiscoverState(
|
|
||||||
val repos: List<GitHubRepo> = emptyList(),
|
|
||||||
val currentPage: Int = 1,
|
|
||||||
val hasMore: Boolean = true,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val isLoadingMore: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
val installingRepoId: Long? = null,
|
|
||||||
val isInitialLoaded: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
class PluginDiscoverViewModel(
|
|
||||||
private val pluginManager: PluginManager,
|
|
||||||
) : ViewModel(), KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<PluginDiscoverViewModel>()
|
|
||||||
private val gitHubRepo = GitHubPluginRepository()
|
|
||||||
|
|
||||||
private val _allRepos = mutableListOf<GitHubRepo>()
|
|
||||||
private val _paginationInfo = PaginationInfo()
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PluginDiscoverState())
|
|
||||||
val state: StateFlow<PluginDiscoverState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private class PaginationInfo(
|
|
||||||
var currentPage: Int = 1,
|
|
||||||
var hasMore: Boolean = true,
|
|
||||||
var totalCount: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
pluginManager.state.collect { pluginState ->
|
|
||||||
if (pluginState is PluginManagerStates.Data) {
|
|
||||||
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
|
|
||||||
_state.update {
|
|
||||||
it.copy(repos = _allRepos.filter { repo -> repo.htmlUrl !in installedUrls })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadFirstPage()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filterInstalled(repos: List<GitHubRepo>): List<GitHubRepo> {
|
|
||||||
val pluginState = pluginManager.state.value
|
|
||||||
if (pluginState !is PluginManagerStates.Data) return repos
|
|
||||||
val installedUrls = pluginState.plugins.mapNotNull { it.repository.takeIf { r -> r.isNotBlank() } }.toSet()
|
|
||||||
return repos.filter { it.htmlUrl !in installedUrls }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadFirstPage() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
|
||||||
runCatching {
|
|
||||||
gitHubRepo.searchSpotubePlugins(page = 1)
|
|
||||||
}.onSuccess { response ->
|
|
||||||
_allRepos.clear()
|
|
||||||
_allRepos.addAll(response.items)
|
|
||||||
_paginationInfo.currentPage = 1
|
|
||||||
_paginationInfo.totalCount = response.totalCount
|
|
||||||
_paginationInfo.hasMore = _allRepos.size < response.totalCount
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
repos = filterInstalled(response.items),
|
|
||||||
currentPage = 1,
|
|
||||||
hasMore = _paginationInfo.hasMore,
|
|
||||||
isLoading = false,
|
|
||||||
isInitialLoaded = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to load plugins" }
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = e.message,
|
|
||||||
isInitialLoaded = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadNextPage() {
|
|
||||||
val current = _state.value
|
|
||||||
if (current.isLoadingMore || !current.hasMore) return
|
|
||||||
viewModelScope.launch {
|
|
||||||
val nextPage = _paginationInfo.currentPage + 1
|
|
||||||
_state.update { it.copy(isLoadingMore = true, error = null) }
|
|
||||||
runCatching {
|
|
||||||
gitHubRepo.searchSpotubePlugins(page = nextPage)
|
|
||||||
}.onSuccess { response ->
|
|
||||||
_allRepos.addAll(response.items)
|
|
||||||
_paginationInfo.currentPage = nextPage
|
|
||||||
_paginationInfo.hasMore = _allRepos.size < response.totalCount
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
repos = filterInstalled(_allRepos),
|
|
||||||
currentPage = nextPage,
|
|
||||||
hasMore = _paginationInfo.hasMore,
|
|
||||||
isLoadingMore = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to load more plugins" }
|
|
||||||
_state.update { it.copy(isLoadingMore = false, error = e.message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun installPlugin(repo: GitHubRepo) {
|
|
||||||
if (_state.value.installingRepoId != null) return
|
|
||||||
_state.update { it.copy(installingRepoId = repo.id, error = null) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching {
|
|
||||||
val parts = repo.fullName.split("/")
|
|
||||||
val url = gitHubRepo.getLatestReleaseSmplugUrl(parts[0], parts[1])
|
|
||||||
?: throw IllegalStateException("No .smplug asset found in latest release")
|
|
||||||
pluginManager.addPluginFromURL(url)
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to install plugin" }
|
|
||||||
_state.update { it.copy(error = e.message) }
|
|
||||||
}
|
|
||||||
_state.update { it.copy(installingRepoId = null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun installPluginFromUrl(url: String, repoId: Long) {
|
|
||||||
if (_state.value.installingRepoId != null) return
|
|
||||||
_state.update { it.copy(installingRepoId = repoId, error = null) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching {
|
|
||||||
pluginManager.addPluginFromURL(url)
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to install plugin" }
|
|
||||||
_state.update { it.copy(error = e.message) }
|
|
||||||
}
|
|
||||||
_state.update { it.copy(installingRepoId = null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getReleases(owner: String, repo: String): List<GitHubRelease> {
|
|
||||||
return gitHubRepo.getReleases(owner, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
gitHubRepo.close()
|
|
||||||
super.onCleared()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -80,7 +80,6 @@ class PluginManager(
|
|||||||
}
|
}
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
|
||||||
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
|
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
|
||||||
val pluginsDirPath: Path get() = pluginsDir
|
|
||||||
private val httpClient = HttpClient()
|
private val httpClient = HttpClient()
|
||||||
|
|
||||||
|
|
||||||
@ -325,15 +324,6 @@ class PluginManager(
|
|||||||
throw IllegalArgumentException("Invalid plugin.json format: ${e.message}")
|
throw IllegalArgumentException("Invalid plugin.json format: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve logo.png before deleting temp dir so the permission dialog can show it
|
|
||||||
val logoPngPath = tempDir / "logo.png".toPath()
|
|
||||||
if (FileSystem.SYSTEM.exists(logoPngPath)) {
|
|
||||||
val logoDir = pluginsDirPath / pluginEntry.id.toPath()
|
|
||||||
if (!FileSystem.SYSTEM.exists(logoDir)) FileSystem.SYSTEM.createDirectories(logoDir)
|
|
||||||
val destLogo = logoDir / "logo.png".toPath()
|
|
||||||
FileSystem.SYSTEM.copy(logoPngPath, destLogo)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes)
|
pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw Exception("Failed to read plugin: ${e.message}", e)
|
throw Exception("Failed to read plugin: ${e.message}", e)
|
||||||
|
|||||||
@ -18,28 +18,20 @@
|
|||||||
package dev.krtirtho.spotube.modules.plugin
|
package dev.krtirtho.spotube.modules.plugin
|
||||||
|
|
||||||
import com.goncalossilva.murmurhash.MurmurHash3
|
import com.goncalossilva.murmurhash.MurmurHash3
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
enum class PluginCapability {
|
enum class PluginCapability {
|
||||||
@SerialName("persistent_storage")
|
|
||||||
PERSISTENT_STORAGE,
|
PERSISTENT_STORAGE,
|
||||||
@SerialName("network_requests")
|
|
||||||
NETWORK_REQUESTS,
|
NETWORK_REQUESTS,
|
||||||
@SerialName("webview")
|
|
||||||
WEBVIEW
|
WEBVIEW
|
||||||
}
|
}
|
||||||
|
|
||||||
//Set naming strategy to snake_case for better interoperability with JavaScript plugins
|
//Set naming strategy to snake_case for better interoperability with JavaScript plugins
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class PluginAbility {
|
enum class PluginAbility {
|
||||||
@SerialName("metadata")
|
|
||||||
METADATA,
|
METADATA,
|
||||||
@SerialName("audio")
|
|
||||||
AUDIO,
|
AUDIO,
|
||||||
@SerialName("lyrics")
|
|
||||||
LYRICS,
|
LYRICS,
|
||||||
@SerialName("scrobble")
|
|
||||||
SCROBBLE,
|
SCROBBLE,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,11 +43,7 @@ data class PluginEntry(
|
|||||||
val description: String,
|
val description: String,
|
||||||
val author: String,
|
val author: String,
|
||||||
val capabilities: List<PluginCapability>,
|
val capabilities: List<PluginCapability>,
|
||||||
val abilities: List<PluginAbility>,
|
val abilities: List<PluginAbility>
|
||||||
val contact: String,
|
|
||||||
val repository: String,
|
|
||||||
val bugs: String,
|
|
||||||
val license: String,
|
|
||||||
) {
|
) {
|
||||||
@Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD")
|
@Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD")
|
||||||
val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray())
|
val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -39,11 +38,6 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import coil3.compose.LocalPlatformContext
|
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import coil3.request.crossfade
|
|
||||||
import okio.Path
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS
|
import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS
|
||||||
@ -52,8 +46,6 @@ import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTag
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxTag
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
@ -78,9 +70,6 @@ internal fun PluginCard(
|
|||||||
isLoggedIn: Boolean,
|
isLoggedIn: Boolean,
|
||||||
onLogin: (() -> Unit)? = null,
|
onLogin: (() -> Unit)? = null,
|
||||||
onLogout: (() -> Unit)? = null,
|
onLogout: (() -> Unit)? = null,
|
||||||
onInfo: (() -> Unit)? = null,
|
|
||||||
onSupport: (() -> Unit)? = null,
|
|
||||||
logoPath: Path? = null,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -96,25 +85,13 @@ internal fun PluginCard(
|
|||||||
.clip(RoundedCornerShape(10.dp)),
|
.clip(RoundedCornerShape(10.dp)),
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||||
) {
|
) {
|
||||||
if (logoPath != null) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
val platformContext = LocalPlatformContext.current
|
Icon(
|
||||||
AsyncImage(
|
Iconsax.IconsaxBox,
|
||||||
model = ImageRequest.Builder(platformContext)
|
contentDescription = null,
|
||||||
.data(logoPath.toString())
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
.crossfade(true)
|
modifier = Modifier.size(22.dp)
|
||||||
.build(),
|
|
||||||
contentDescription = plugin.name,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.IconsaxBox,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(22.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,50 +215,28 @@ internal fun PluginCard(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.align(Alignment.Bottom),
|
modifier = Modifier.align(Alignment.Bottom),
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
if (plugin in BUILT_IN_PLUGINS) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
// Built-in plugins cannot be removed
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Text(
|
||||||
) {
|
stringResource(Res.string.plugin_state_builtin),
|
||||||
if (onInfo != null) {
|
style = MaterialTheme.typography.labelSmall,
|
||||||
GhostIconButton(onClick = onInfo) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Icon(
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
Iconsax.IconsaxInformation,
|
.border(
|
||||||
contentDescription = null,
|
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
shape = RoundedCornerShape(6.dp)
|
||||||
)
|
)
|
||||||
}
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
}
|
)
|
||||||
if (onSupport != null) {
|
} else {
|
||||||
GhostIconButton(onClick = onSupport) {
|
GhostIconButton(onClick = onRemove) {
|
||||||
Icon(
|
Icon(
|
||||||
Iconsax.IconsaxHeart,
|
Iconsax.IconsaxTrash,
|
||||||
contentDescription = null,
|
contentDescription = stringResource(Res.string.plugin_action_remove),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (plugin in BUILT_IN_PLUGINS) {
|
|
||||||
Text(
|
|
||||||
stringResource(Res.string.plugin_state_builtin),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
.border(
|
|
||||||
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
|
||||||
shape = RoundedCornerShape(6.dp)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
GhostIconButton(onClick = onRemove) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.IconsaxTrash,
|
|
||||||
contentDescription = stringResource(Res.string.plugin_action_remove),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,256 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.plugin.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.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.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.MenuAnchorType
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
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.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import coil3.compose.LocalPlatformContext
|
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import coil3.request.crossfade
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.GitHubRepo
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.GitHubRelease
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun PluginInstallDialog(
|
|
||||||
repo: GitHubRepo,
|
|
||||||
releases: List<GitHubRelease>,
|
|
||||||
isLoadingReleases: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onInstall: (GitHubRelease) -> Unit,
|
|
||||||
) {
|
|
||||||
var selectedRelease by remember { mutableStateOf<GitHubRelease?>(null) }
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(releases) {
|
|
||||||
if (selectedRelease == null && releases.isNotEmpty()) {
|
|
||||||
selectedRelease = releases.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemedDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp)),
|
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
val platformContext = LocalPlatformContext.current
|
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(platformContext)
|
|
||||||
.data(repo.owner.avatarUrl)
|
|
||||||
.crossfade(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = repo.owner.login,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
repo.fullName,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (!repo.description.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
repo.description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
PrimaryButton(
|
|
||||||
onClick = { selectedRelease?.let { onInstall(it) } },
|
|
||||||
enabled = selectedRelease != null && !isLoadingReleases
|
|
||||||
) {
|
|
||||||
Text("Install")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
if (isLoadingReleases) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().height(120.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
|
||||||
}
|
|
||||||
} else if (releases.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"No releases found",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
"Select Release",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = expanded,
|
|
||||||
onExpandedChange = { expanded = it }
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
selectedRelease?.tagName ?: "Select a release",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = { expanded = false }
|
|
||||||
) {
|
|
||||||
releases.forEach { release ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
release.tagName,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = if (release == selectedRelease) FontWeight.SemiBold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
if (release.prerelease) {
|
|
||||||
Text(
|
|
||||||
"Pre-release",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
selectedRelease = release
|
|
||||||
expanded = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedRelease?.let { release ->
|
|
||||||
if (!release.body.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
"Release Notes",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = 200.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(12.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
release.body,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -39,11 +38,6 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import coil3.compose.LocalPlatformContext
|
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import coil3.request.crossfade
|
|
||||||
import okio.Path
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
import dev.krtirtho.spotube.core.ui.base.Card
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
@ -82,7 +76,6 @@ fun PluginPermissionDialog(
|
|||||||
message: String,
|
message: String,
|
||||||
confirmLabel: String?,
|
confirmLabel: String?,
|
||||||
existingPlugin: PluginEntry? = null,
|
existingPlugin: PluginEntry? = null,
|
||||||
logoPath: Path? = null,
|
|
||||||
onConfirm: (() -> Unit)? = null,
|
onConfirm: (() -> Unit)? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -100,24 +93,12 @@ fun PluginPermissionDialog(
|
|||||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (logoPath != null) {
|
Icon(
|
||||||
val platformContext = LocalPlatformContext.current
|
imageVector = Iconsax.IconsaxBoxAdd,
|
||||||
AsyncImage(
|
contentDescription = null,
|
||||||
model = ImageRequest.Builder(platformContext)
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
.data(logoPath.toString())
|
modifier = Modifier.size(24.dp)
|
||||||
.crossfade(true)
|
)
|
||||||
.build(),
|
|
||||||
contentDescription = pluginInfo.name,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxBoxAdd,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@ -27,7 +27,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||||
@ -41,7 +40,6 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
|||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@ -50,23 +48,23 @@ import spotube.composeapp.generated.resources.Res
|
|||||||
import spotube.composeapp.generated.resources.liked_tracks
|
import spotube.composeapp.generated.resources.liked_tracks
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SavedTracksScreen(
|
fun SavedTracksScreen() {
|
||||||
viewModel: SavedTracksViewModel,
|
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||||
audioPlayerQueue: AudioPlayerQueue,
|
val audioPlayer: AudioPlayer = koinInject()
|
||||||
audioPlayer: AudioPlayerInterface,
|
val shareService: ShareService = koinInject()
|
||||||
shareService: ShareService,
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
downloadsViewModel: DownloadsViewModel,
|
val libraryRepository: LibraryRepository = koinInject()
|
||||||
libraryRepository: LibraryRepository,
|
|
||||||
navigationCommands: NavigationCommands
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val viewModel = koinViewModel<SavedTracksViewModel>(
|
||||||
|
key = SAVED_TRACKS_COLLECTION_ID,
|
||||||
|
parameters = { parametersOf() }
|
||||||
|
)
|
||||||
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
|
|
||||||
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
|
|
||||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||||
@ -80,12 +78,12 @@ fun SavedTracksScreen(
|
|||||||
val queueTrackIds = queue.mapNotNull { entry ->
|
val queueTrackIds = queue.mapNotNull { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
}.toSet()
|
}.toSet()
|
||||||
return TrackOptionsState(
|
return TrackOptionsState(
|
||||||
isInQueue = queueTrackIds.contains(track.id),
|
isInQueue = queueTrackIds.contains(track.id),
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = true,
|
isFavorite = true,
|
||||||
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
|
isBlacklisted = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||||
|
|||||||
@ -35,7 +35,6 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
|||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@ -44,8 +43,6 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
@ -87,7 +84,6 @@ class SavedTracksViewModel(
|
|||||||
private val repository: SavedTracksRepository,
|
private val repository: SavedTracksRepository,
|
||||||
private val playbackHelper: CollectionPlaybackHelper,
|
private val playbackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<SavedTracksViewModel>()
|
private val logger by injectLogger<SavedTracksViewModel>()
|
||||||
|
|
||||||
@ -96,12 +92,6 @@ class SavedTracksViewModel(
|
|||||||
val savedTrackIdsFlow: StateFlow<Set<String>>
|
val savedTrackIdsFlow: StateFlow<Set<String>>
|
||||||
get() = repository.savedTracksIdsFlow
|
get() = repository.savedTracksIdsFlow
|
||||||
|
|
||||||
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
|
|
||||||
|
|
||||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(
|
combine(
|
||||||
@ -114,12 +104,6 @@ class SavedTracksViewModel(
|
|||||||
loadInitialData()
|
loadInitialData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blacklistRepository.blacklistedTracks
|
|
||||||
.onEach { tracks -> _blacklistedTrackIds.value = tracks.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
blacklistRepository.blacklistedArtists
|
|
||||||
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadInitialData() {
|
private suspend fun loadInitialData() {
|
||||||
@ -211,51 +195,23 @@ class SavedTracksViewModel(
|
|||||||
|
|
||||||
is TrackOptionsAction.ToggleFavorite -> {}
|
is TrackOptionsAction.ToggleFavorite -> {}
|
||||||
is TrackOptionsAction.Download -> {}
|
is TrackOptionsAction.Download -> {}
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||||
is TrackOptionsAction.Share -> {}
|
is TrackOptionsAction.Share -> {}
|
||||||
is TrackOptionsAction.AddToPlaylist -> {}
|
is TrackOptionsAction.AddToPlaylist -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
|
||||||
val trackIds = _blacklistedTrackIds.value
|
|
||||||
val artistIds = _blacklistedArtistIds.value
|
|
||||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
blacklistRepository.toggleTrack(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,8 +84,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||||
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||||
@ -100,7 +98,6 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
|||||||
import dev.krtirtho.spotube.core.ui.component.UserCard
|
import dev.krtirtho.spotube.core.ui.component.UserCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
@ -123,8 +120,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
val shareService: ShareService = koinInject()
|
val shareService: ShareService = koinInject()
|
||||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
val libraryRepository: LibraryRepository = koinInject()
|
val libraryRepository: LibraryRepository = koinInject()
|
||||||
val blacklistRepository: BlacklistRepository = koinInject()
|
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val selectedType = state.selectedSearchType
|
val selectedType = state.selectedSearchType
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -133,21 +128,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList())
|
|
||||||
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList())
|
|
||||||
|
|
||||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
|
||||||
val trackIds = blacklistedTracks.map { it.id }.toSet()
|
|
||||||
val artistIds = blacklistedArtists.map { it.id }.toSet()
|
|
||||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleTrackBlacklist(track: MetadataTrack) {
|
|
||||||
scope.launch {
|
|
||||||
blacklistRepository.toggleTrack(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
currentUserId = libraryRepository.currentUser()?.id
|
currentUserId = libraryRepository.currentUser()?.id
|
||||||
}
|
}
|
||||||
@ -200,7 +180,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
|
|
||||||
is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id)
|
is TrackOptionsAction.ToggleFavorite -> viewModel.toggleTrackIsFavorite(track.id)
|
||||||
is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track)
|
is TrackOptionsAction.Download -> downloadsViewModel.downloadTrack(track)
|
||||||
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
|
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||||
is TrackOptionsAction.Share -> {
|
is TrackOptionsAction.Share -> {
|
||||||
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
val uri = track.externalUri?.takeIf { it.isNotBlank() }
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@ -226,7 +206,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
isInQueue = queueTrackIds.contains(track.id),
|
isInQueue = queueTrackIds.contains(track.id),
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = savedTrackIds.contains(track.id),
|
isFavorite = savedTrackIds.contains(track.id),
|
||||||
isBlacklisted = isTrackBlacklisted(track),
|
isBlacklisted = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,36 +222,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
isInQueue = queueTrackIds.contains(track.id),
|
isInQueue = queueTrackIds.contains(track.id),
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = savedTrackIds.contains(track.id),
|
isFavorite = savedTrackIds.contains(track.id),
|
||||||
isBlacklisted = isTrackBlacklisted(track),
|
isBlacklisted = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
|
||||||
|
|
||||||
val filteredTracks = tracks.filter { track ->
|
|
||||||
track.id !in blacklistedTrackIds &&
|
|
||||||
track.artists.none { it.id in blacklistedArtistIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -351,15 +315,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
tracksToAddToPlaylist = tracks
|
tracksToAddToPlaylist = tracks
|
||||||
showAddToPlaylistPicker = true
|
showAddToPlaylistPicker = true
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
|
||||||
},
|
|
||||||
onAlbumClick = { album ->
|
|
||||||
navigationCommands.navigateTo(Routes.Album(album.id))
|
|
||||||
},
|
|
||||||
onArtistsOverflowClick = { track ->
|
|
||||||
// No-op for now, could show a dialog with all artists
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@ -380,15 +335,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
tracksToAddToPlaylist = tracks
|
tracksToAddToPlaylist = tracks
|
||||||
showAddToPlaylistPicker = true
|
showAddToPlaylistPicker = true
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
|
||||||
},
|
|
||||||
onAlbumClick = { album ->
|
|
||||||
navigationCommands.navigateTo(Routes.Album(album.id))
|
|
||||||
},
|
|
||||||
onArtistsOverflowClick = { track ->
|
|
||||||
// No-op for now, could show a dialog with all artists
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@ -661,9 +607,6 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
@ -718,9 +661,6 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onArtistClick = onArtistClick,
|
|
||||||
onAlbumClick = onAlbumClick,
|
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
|
||||||
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
|
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
|
||||||
headerContent = {
|
headerContent = {
|
||||||
if (showTracks) {
|
if (showTracks) {
|
||||||
@ -805,9 +745,6 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
@ -834,9 +771,6 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onArtistClick = onArtistClick,
|
|
||||||
onAlbumClick = onAlbumClick,
|
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
|
||||||
simplified = true,
|
simplified = true,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -105,7 +105,6 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
|||||||
playbackSection(
|
playbackSection(
|
||||||
settings = settingsState!!,
|
settings = settingsState!!,
|
||||||
settingsViewModel = settingsViewModel,
|
settingsViewModel = settingsViewModel,
|
||||||
navigatorCommands = navigatorCommands,
|
|
||||||
)
|
)
|
||||||
if (settingsState != null)
|
if (settingsState != null)
|
||||||
cacheSection(
|
cacheSection(
|
||||||
|
|||||||
@ -18,27 +18,20 @@
|
|||||||
package dev.krtirtho.spotube.modules.settings.sections
|
package dev.krtirtho.spotube.modules.settings.sections
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import compose.icons.FeatherIcons
|
import compose.icons.FeatherIcons
|
||||||
import compose.icons.feathericons.ChevronRight
|
|
||||||
import compose.icons.feathericons.Cast
|
import compose.icons.feathericons.Cast
|
||||||
import compose.icons.feathericons.Radio
|
import compose.icons.feathericons.Radio
|
||||||
import compose.icons.feathericons.Repeat
|
import compose.icons.feathericons.Repeat
|
||||||
import compose.icons.feathericons.Server
|
import compose.icons.feathericons.Server
|
||||||
import compose.icons.feathericons.Sliders
|
import compose.icons.feathericons.Sliders
|
||||||
import spotube.composeapp.generated.resources.*
|
import spotube.composeapp.generated.resources.*
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
|
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
|
||||||
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
|
||||||
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
||||||
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
||||||
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow
|
||||||
@ -48,7 +41,6 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
internal fun LazyListScope.playbackSection(
|
internal fun LazyListScope.playbackSection(
|
||||||
settings: UserSettings,
|
settings: UserSettings,
|
||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
navigatorCommands: NavigationCommands,
|
|
||||||
) {
|
) {
|
||||||
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
|
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
|
||||||
val streamingQualities = availableAudioQualities(
|
val streamingQualities = availableAudioQualities(
|
||||||
@ -167,28 +159,6 @@ 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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
package dev.krtirtho.spotube.resources.iconsax
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.graphics.vector.path
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
val Iconsax.CarbonGithubLogo: ImageVector
|
|
||||||
get() {
|
|
||||||
if (_CarbonGithubLogo != null) {
|
|
||||||
return _CarbonGithubLogo!!
|
|
||||||
}
|
|
||||||
_CarbonGithubLogo = ImageVector.Builder(
|
|
||||||
name = "CarbonGithubLogo",
|
|
||||||
defaultWidth = 24.dp,
|
|
||||||
defaultHeight = 24.dp,
|
|
||||||
viewportWidth = 32f,
|
|
||||||
viewportHeight = 32f
|
|
||||||
).apply {
|
|
||||||
path(
|
|
||||||
fill = SolidColor(Color.Black),
|
|
||||||
stroke = SolidColor(Color.Black),
|
|
||||||
strokeLineWidth = 1f
|
|
||||||
) {
|
|
||||||
moveTo(16f, 2f)
|
|
||||||
arcToRelative(14f, 14f, 0f, isMoreThanHalf = false, isPositiveArc = false, -4.43f, 27.28f)
|
|
||||||
curveToRelative(0.7f, 0.13f, 1f, -0.3f, 1f, -0.67f)
|
|
||||||
reflectiveCurveToRelative(0f, -1.21f, 0f, -2.38f)
|
|
||||||
curveToRelative(-3.89f, 0.84f, -4.71f, -1.88f, -4.71f, -1.88f)
|
|
||||||
arcTo(3.71f, 3.71f, 0f, isMoreThanHalf = false, isPositiveArc = false, 6.24f, 22.3f)
|
|
||||||
curveToRelative(-1.27f, -0.86f, 0.1f, -0.85f, 0.1f, -0.85f)
|
|
||||||
arcTo(2.94f, 2.94f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.48f, 22.9f)
|
|
||||||
arcToRelative(3f, 3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 4.08f, 1.16f)
|
|
||||||
arcToRelative(2.93f, 2.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.88f, -1.87f)
|
|
||||||
curveToRelative(-3.1f, -0.36f, -6.37f, -1.56f, -6.37f, -6.92f)
|
|
||||||
arcToRelative(5.4f, 5.4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.44f, -3.76f)
|
|
||||||
arcToRelative(5f, 5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.14f, -3.7f)
|
|
||||||
reflectiveCurveToRelative(1.17f, -0.38f, 3.85f, 1.43f)
|
|
||||||
arcToRelative(13.3f, 13.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7f, 0f)
|
|
||||||
curveToRelative(2.67f, -1.81f, 3.84f, -1.43f, 3.84f, -1.43f)
|
|
||||||
arcToRelative(5f, 5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.14f, 3.7f)
|
|
||||||
arcToRelative(5.4f, 5.4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.44f, 3.76f)
|
|
||||||
curveToRelative(0f, 5.38f, -3.27f, 6.56f, -6.39f, 6.91f)
|
|
||||||
arcToRelative(3.33f, 3.33f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.95f, 2.59f)
|
|
||||||
curveToRelative(0f, 1.87f, 0f, 3.38f, 0f, 3.84f)
|
|
||||||
reflectiveCurveToRelative(0.25f, 0.81f, 1f, 0.67f)
|
|
||||||
arcTo(14f, 14f, 0f, isMoreThanHalf = false, isPositiveArc = false, 16f, 2f)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
return _CarbonGithubLogo!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("ObjectPropertyName")
|
|
||||||
private var _CarbonGithubLogo: ImageVector? = null
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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.IconsaxInformation: ImageVector
|
|
||||||
get() {
|
|
||||||
if (_IconsaxInformation != null) {
|
|
||||||
return _IconsaxInformation!!
|
|
||||||
}
|
|
||||||
_IconsaxInformation = ImageVector.Builder(
|
|
||||||
name = "IconsaxInformation",
|
|
||||||
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(10.75f, 2.45f)
|
|
||||||
curveTo(11.45f, 1.86f, 12.58f, 1.86f, 13.26f, 2.45f)
|
|
||||||
lineTo(14.84f, 3.8f)
|
|
||||||
curveTo(15.14f, 4.05f, 15.71f, 4.26f, 16.11f, 4.26f)
|
|
||||||
horizontalLineTo(17.81f)
|
|
||||||
curveTo(18.87f, 4.26f, 19.74f, 5.13f, 19.74f, 6.19f)
|
|
||||||
verticalLineTo(7.89f)
|
|
||||||
curveTo(19.74f, 8.29f, 19.95f, 8.85f, 20.2f, 9.15f)
|
|
||||||
lineTo(21.55f, 10.73f)
|
|
||||||
curveTo(22.14f, 11.43f, 22.14f, 12.56f, 21.55f, 13.24f)
|
|
||||||
lineTo(20.2f, 14.82f)
|
|
||||||
curveTo(19.95f, 15.12f, 19.74f, 15.68f, 19.74f, 16.08f)
|
|
||||||
verticalLineTo(17.78f)
|
|
||||||
curveTo(19.74f, 18.84f, 18.87f, 19.71f, 17.81f, 19.71f)
|
|
||||||
horizontalLineTo(16.11f)
|
|
||||||
curveTo(15.71f, 19.71f, 15.15f, 19.92f, 14.85f, 20.17f)
|
|
||||||
lineTo(13.27f, 21.52f)
|
|
||||||
curveTo(12.57f, 22.11f, 11.44f, 22.11f, 10.76f, 21.52f)
|
|
||||||
lineTo(9.18f, 20.17f)
|
|
||||||
curveTo(8.88f, 19.92f, 8.31f, 19.71f, 7.92f, 19.71f)
|
|
||||||
horizontalLineTo(6.17f)
|
|
||||||
curveTo(5.11f, 19.71f, 4.24f, 18.84f, 4.24f, 17.78f)
|
|
||||||
verticalLineTo(16.07f)
|
|
||||||
curveTo(4.24f, 15.68f, 4.04f, 15.11f, 3.79f, 14.82f)
|
|
||||||
lineTo(2.44f, 13.23f)
|
|
||||||
curveTo(1.86f, 12.54f, 1.86f, 11.42f, 2.44f, 10.73f)
|
|
||||||
lineTo(3.79f, 9.14f)
|
|
||||||
curveTo(4.04f, 8.84f, 4.24f, 8.28f, 4.24f, 7.89f)
|
|
||||||
verticalLineTo(6.2f)
|
|
||||||
curveTo(4.24f, 5.14f, 5.11f, 4.27f, 6.17f, 4.27f)
|
|
||||||
horizontalLineTo(7.9f)
|
|
||||||
curveTo(8.3f, 4.27f, 8.86f, 4.06f, 9.16f, 3.81f)
|
|
||||||
lineTo(10.75f, 2.45f)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
path(fill = SolidColor(Color.White)) {
|
|
||||||
moveTo(12f, 16.869f)
|
|
||||||
curveTo(11.45f, 16.869f, 11f, 16.419f, 11f, 15.869f)
|
|
||||||
curveTo(11f, 15.319f, 11.44f, 14.869f, 12f, 14.869f)
|
|
||||||
curveTo(12.55f, 14.869f, 13f, 15.319f, 13f, 15.869f)
|
|
||||||
curveTo(13f, 16.419f, 12.56f, 16.869f, 12f, 16.869f)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
path(fill = SolidColor(Color.White)) {
|
|
||||||
moveTo(12f, 13.721f)
|
|
||||||
curveTo(11.59f, 13.721f, 11.25f, 13.381f, 11.25f, 12.971f)
|
|
||||||
verticalLineTo(8.131f)
|
|
||||||
curveTo(11.25f, 7.721f, 11.59f, 7.381f, 12f, 7.381f)
|
|
||||||
curveTo(12.41f, 7.381f, 12.75f, 7.721f, 12.75f, 8.131f)
|
|
||||||
verticalLineTo(12.961f)
|
|
||||||
curveTo(12.75f, 13.381f, 12.42f, 13.721f, 12f, 13.721f)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
return _IconsaxInformation!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("ObjectPropertyName")
|
|
||||||
private var _IconsaxInformation: ImageVector? = null
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
use discord_rich_presence::activity::{Activity, ActivityType, Assets, Timestamps};
|
|
||||||
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
#[derive(Debug, uniffi::Error)]
|
|
||||||
pub enum DiscordRpcError {
|
|
||||||
ConnectionError { reason: String },
|
|
||||||
UpdateError { reason: String },
|
|
||||||
NotConnected,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for DiscordRpcError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
DiscordRpcError::ConnectionError { reason } => write!(f, "Connection error: {}", reason),
|
|
||||||
DiscordRpcError::UpdateError { reason } => write!(f, "Update error: {}", reason),
|
|
||||||
DiscordRpcError::NotConnected => write!(f, "Not connected"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(uniffi::Object)]
|
|
||||||
pub struct DiscordRpcClient {
|
|
||||||
client_id: String,
|
|
||||||
client: Mutex<Option<DiscordIpcClient>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export]
|
|
||||||
impl DiscordRpcClient {
|
|
||||||
#[uniffi::constructor]
|
|
||||||
pub fn new(client_id: String) -> Self {
|
|
||||||
Self {
|
|
||||||
client_id,
|
|
||||||
client: Mutex::new(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connect(&self) -> Result<(), DiscordRpcError> {
|
|
||||||
let mut client_guard = self.client.lock().unwrap();
|
|
||||||
if client_guard.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut client = DiscordIpcClient::new(&self.client_id);
|
|
||||||
|
|
||||||
client
|
|
||||||
.connect()
|
|
||||||
.map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?;
|
|
||||||
|
|
||||||
*client_guard = Some(client);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn disconnect(&self) -> Result<(), DiscordRpcError> {
|
|
||||||
let mut client_guard = self.client.lock().unwrap();
|
|
||||||
if let Some(mut client) = client_guard.take() {
|
|
||||||
client
|
|
||||||
.close()
|
|
||||||
.map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_presence(
|
|
||||||
&self,
|
|
||||||
title: String,
|
|
||||||
artist: String,
|
|
||||||
album: String,
|
|
||||||
cover_url: String,
|
|
||||||
position_ms: i64,
|
|
||||||
duration_ms: i64,
|
|
||||||
) -> Result<(), DiscordRpcError> {
|
|
||||||
let mut client_guard = self.client.lock().unwrap();
|
|
||||||
let client = client_guard
|
|
||||||
.as_mut()
|
|
||||||
.ok_or(DiscordRpcError::NotConnected)?;
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs() as i64;
|
|
||||||
|
|
||||||
let start = now - (position_ms / 1000);
|
|
||||||
let end = start + (duration_ms / 1000);
|
|
||||||
|
|
||||||
let details = if title.is_empty() {
|
|
||||||
"Unknown Track".to_string()
|
|
||||||
} else {
|
|
||||||
title
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = if artist.is_empty() {
|
|
||||||
"Unknown Artist".to_string()
|
|
||||||
} else {
|
|
||||||
format!("by {}", artist)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut activity = Activity::new()
|
|
||||||
.details(&details)
|
|
||||||
.state(&state)
|
|
||||||
.timestamps(Timestamps::new().start(start).end(end))
|
|
||||||
.activity_type(ActivityType::Listening);
|
|
||||||
|
|
||||||
if !album.is_empty() {
|
|
||||||
let mut assets = Assets::new().large_text(&album);
|
|
||||||
if !cover_url.is_empty() {
|
|
||||||
assets = assets.large_image(&cover_url);
|
|
||||||
}
|
|
||||||
activity = activity.assets(assets);
|
|
||||||
} else if !cover_url.is_empty() {
|
|
||||||
activity = activity.assets(Assets::new().large_image(&cover_url));
|
|
||||||
}
|
|
||||||
|
|
||||||
client
|
|
||||||
.set_activity(activity)
|
|
||||||
.map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_presence(&self) -> Result<(), DiscordRpcError> {
|
|
||||||
let mut client_guard = self.client.lock().unwrap();
|
|
||||||
let client = client_guard
|
|
||||||
.as_mut()
|
|
||||||
.ok_or(DiscordRpcError::NotConnected)?;
|
|
||||||
|
|
||||||
client
|
|
||||||
.clear_activity()
|
|
||||||
.map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_connected(&self) -> bool {
|
|
||||||
self.client.lock().unwrap().is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,298 @@
|
|||||||
mod metadata;
|
use discord_rich_presence::activity::{Activity, ActivityType, Assets, Timestamps};
|
||||||
mod discord_rpc;
|
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||||
|
use lofty::config::WriteOptions;
|
||||||
|
use lofty::file::{AudioFile, FileType, TaggedFileExt};
|
||||||
|
use lofty::picture::Picture;
|
||||||
|
use lofty::probe::Probe;
|
||||||
|
use lofty::tag::{Accessor, Tag, TagExt};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
pub use metadata::*;
|
#[derive(uniffi::Record)]
|
||||||
pub use discord_rpc::*;
|
pub struct AudioMetadata {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artists: Vec<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub duration_ms: i64,
|
||||||
|
pub track_number: Option<u32>,
|
||||||
|
pub disc_number: Option<u32>,
|
||||||
|
pub cover_bytes: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, uniffi::Error)]
|
||||||
|
pub enum AudioTagError {
|
||||||
|
FileNotFound,
|
||||||
|
ReadError { reason: String },
|
||||||
|
WriteError { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AudioTagError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AudioTagError::FileNotFound => write!(f, "File not found"),
|
||||||
|
AudioTagError::ReadError { reason } => write!(f, "Read error: {}", reason),
|
||||||
|
AudioTagError::WriteError { reason } => write!(f, "Write error: {}", reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
fn read_audio_metadata(file_path: String) -> Result<AudioMetadata, AudioTagError> {
|
||||||
|
let path = Path::new(&file_path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(AudioTagError::FileNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagged_file = Probe::open(path)
|
||||||
|
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
|
||||||
|
.read()
|
||||||
|
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
|
||||||
|
|
||||||
|
let duration = tagged_file.properties().duration();
|
||||||
|
let duration_ms = duration.as_millis() as i64;
|
||||||
|
|
||||||
|
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
|
||||||
|
|
||||||
|
let mut title = None;
|
||||||
|
let mut artists = Vec::new();
|
||||||
|
let mut album = None;
|
||||||
|
let mut track_number = None;
|
||||||
|
let mut disc_number = None;
|
||||||
|
let mut cover_bytes = None;
|
||||||
|
|
||||||
|
if let Some(tag) = tag {
|
||||||
|
title = tag.title().map(|s| s.to_string());
|
||||||
|
|
||||||
|
if let Some(artist) = tag.artist() {
|
||||||
|
artists = artist
|
||||||
|
.split(';')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
album = tag.album().map(|s| s.to_string());
|
||||||
|
track_number = tag.track();
|
||||||
|
disc_number = tag.disk();
|
||||||
|
|
||||||
|
if let Some(pic) = tag.pictures().first() {
|
||||||
|
cover_bytes = Some(pic.data().to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AudioMetadata {
|
||||||
|
title,
|
||||||
|
artists,
|
||||||
|
album,
|
||||||
|
duration_ms,
|
||||||
|
track_number,
|
||||||
|
disc_number,
|
||||||
|
cover_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
fn write_audio_metadata(
|
||||||
|
file_path: String,
|
||||||
|
title: String,
|
||||||
|
artists: String,
|
||||||
|
album: Option<String>,
|
||||||
|
track_number: Option<i32>,
|
||||||
|
disc_number: Option<i32>,
|
||||||
|
cover_bytes: Option<Vec<u8>>,
|
||||||
|
) -> Result<(), AudioTagError> {
|
||||||
|
let path = Path::new(&file_path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(AudioTagError::FileNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tagged_file = Probe::open(path)
|
||||||
|
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
|
||||||
|
.read()
|
||||||
|
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
|
||||||
|
|
||||||
|
let file_type = tagged_file.file_type();
|
||||||
|
|
||||||
|
if tagged_file.primary_tag().is_none() && tagged_file.first_tag().is_none() {
|
||||||
|
let new_tag = match file_type {
|
||||||
|
FileType::Mpeg => Tag::new(lofty::tag::TagType::Id3v2),
|
||||||
|
FileType::Mp4 => Tag::new(lofty::tag::TagType::Mp4Ilst),
|
||||||
|
FileType::Flac => Tag::new(lofty::tag::TagType::VorbisComments),
|
||||||
|
FileType::Opus | FileType::Vorbis => Tag::new(lofty::tag::TagType::VorbisComments),
|
||||||
|
_ => Tag::new(lofty::tag::TagType::Id3v2),
|
||||||
|
};
|
||||||
|
tagged_file.insert_tag(new_tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag = if tagged_file.primary_tag().is_some() {
|
||||||
|
tagged_file.primary_tag_mut().unwrap()
|
||||||
|
} else if tagged_file.first_tag().is_some() {
|
||||||
|
tagged_file.first_tag_mut().unwrap()
|
||||||
|
} else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
tag.set_title(title);
|
||||||
|
tag.set_artist(artists);
|
||||||
|
|
||||||
|
if let Some(album_name) = album {
|
||||||
|
tag.set_album(album_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(num) = track_number {
|
||||||
|
tag.set_track(num as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(num) = disc_number {
|
||||||
|
tag.set_disk(num as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bytes) = cover_bytes {
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
let picture = Picture::unchecked(bytes).build();
|
||||||
|
tag.push_picture(picture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.save_to_path(path, WriteOptions::default())
|
||||||
|
.map_err(|e| AudioTagError::WriteError { reason: e.to_string() })?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, uniffi::Error)]
|
||||||
|
pub enum DiscordRpcError {
|
||||||
|
ConnectionError { reason: String },
|
||||||
|
UpdateError { reason: String },
|
||||||
|
NotConnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DiscordRpcError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
DiscordRpcError::ConnectionError { reason } => write!(f, "Connection error: {}", reason),
|
||||||
|
DiscordRpcError::UpdateError { reason } => write!(f, "Update error: {}", reason),
|
||||||
|
DiscordRpcError::NotConnected => write!(f, "Not connected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct DiscordRpcClient {
|
||||||
|
client_id: String,
|
||||||
|
client: Mutex<Option<DiscordIpcClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl DiscordRpcClient {
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn new(client_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client_id,
|
||||||
|
client: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect(&self) -> Result<(), DiscordRpcError> {
|
||||||
|
let mut client_guard = self.client.lock().unwrap();
|
||||||
|
if client_guard.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut client = DiscordIpcClient::new(&self.client_id);
|
||||||
|
|
||||||
|
client
|
||||||
|
.connect()
|
||||||
|
.map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?;
|
||||||
|
|
||||||
|
*client_guard = Some(client);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disconnect(&self) -> Result<(), DiscordRpcError> {
|
||||||
|
let mut client_guard = self.client.lock().unwrap();
|
||||||
|
if let Some(mut client) = client_guard.take() {
|
||||||
|
client
|
||||||
|
.close()
|
||||||
|
.map_err(|e| DiscordRpcError::ConnectionError { reason: e.to_string() })?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_presence(
|
||||||
|
&self,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
cover_url: String,
|
||||||
|
position_ms: i64,
|
||||||
|
duration_ms: i64,
|
||||||
|
) -> Result<(), DiscordRpcError> {
|
||||||
|
let mut client_guard = self.client.lock().unwrap();
|
||||||
|
let client = client_guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or(DiscordRpcError::NotConnected)?;
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let start = now - (position_ms / 1000);
|
||||||
|
let end = start + (duration_ms / 1000);
|
||||||
|
|
||||||
|
let details = if title.is_empty() {
|
||||||
|
"Unknown Track".to_string()
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = if artist.is_empty() {
|
||||||
|
"Unknown Artist".to_string()
|
||||||
|
} else {
|
||||||
|
format!("by {}", artist)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut activity = Activity::new()
|
||||||
|
.details(&details)
|
||||||
|
.state(&state)
|
||||||
|
.timestamps(Timestamps::new().start(start).end(end))
|
||||||
|
.activity_type(ActivityType::Listening);
|
||||||
|
|
||||||
|
if !album.is_empty() {
|
||||||
|
let mut assets = Assets::new().large_text(&album);
|
||||||
|
if !cover_url.is_empty() {
|
||||||
|
assets = assets.large_image(&cover_url);
|
||||||
|
}
|
||||||
|
activity = activity.assets(assets);
|
||||||
|
} else if !cover_url.is_empty() {
|
||||||
|
activity = activity.assets(Assets::new().large_image(&cover_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.set_activity(activity)
|
||||||
|
.map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_presence(&self) -> Result<(), DiscordRpcError> {
|
||||||
|
let mut client_guard = self.client.lock().unwrap();
|
||||||
|
let client = client_guard
|
||||||
|
.as_mut()
|
||||||
|
.ok_or(DiscordRpcError::NotConnected)?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.clear_activity()
|
||||||
|
.map_err(|e| DiscordRpcError::UpdateError { reason: e.to_string() })?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_connected(&self) -> bool {
|
||||||
|
self.client.lock().unwrap().is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|||||||
@ -1,158 +0,0 @@
|
|||||||
use lofty::config::WriteOptions;
|
|
||||||
use lofty::file::{AudioFile, FileType, TaggedFileExt};
|
|
||||||
use lofty::picture::Picture;
|
|
||||||
use lofty::probe::Probe;
|
|
||||||
use lofty::tag::{Accessor, Tag, TagExt};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(uniffi::Record)]
|
|
||||||
pub struct AudioMetadata {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub artists: Vec<String>,
|
|
||||||
pub album: Option<String>,
|
|
||||||
pub duration_ms: i64,
|
|
||||||
pub track_number: Option<u32>,
|
|
||||||
pub disc_number: Option<u32>,
|
|
||||||
pub cover_bytes: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, uniffi::Error)]
|
|
||||||
pub enum AudioTagError {
|
|
||||||
FileNotFound,
|
|
||||||
ReadError { reason: String },
|
|
||||||
WriteError { reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for AudioTagError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
AudioTagError::FileNotFound => write!(f, "File not found"),
|
|
||||||
AudioTagError::ReadError { reason } => write!(f, "Read error: {}", reason),
|
|
||||||
AudioTagError::WriteError { reason } => write!(f, "Write error: {}", reason),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export]
|
|
||||||
pub fn read_audio_metadata(file_path: String) -> Result<AudioMetadata, AudioTagError> {
|
|
||||||
let path = Path::new(&file_path);
|
|
||||||
if !path.exists() {
|
|
||||||
return Err(AudioTagError::FileNotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
let tagged_file = Probe::open(path)
|
|
||||||
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
|
|
||||||
.read()
|
|
||||||
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
|
|
||||||
|
|
||||||
let duration = tagged_file.properties().duration();
|
|
||||||
let duration_ms = duration.as_millis() as i64;
|
|
||||||
|
|
||||||
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
|
|
||||||
|
|
||||||
let mut title = None;
|
|
||||||
let mut artists = Vec::new();
|
|
||||||
let mut album = None;
|
|
||||||
let mut track_number = None;
|
|
||||||
let mut disc_number = None;
|
|
||||||
let mut cover_bytes = None;
|
|
||||||
|
|
||||||
if let Some(tag) = tag {
|
|
||||||
title = tag.title().map(|s| s.to_string());
|
|
||||||
|
|
||||||
if let Some(artist) = tag.artist() {
|
|
||||||
artists = artist
|
|
||||||
.split(';')
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
album = tag.album().map(|s| s.to_string());
|
|
||||||
track_number = tag.track();
|
|
||||||
disc_number = tag.disk();
|
|
||||||
|
|
||||||
if let Some(pic) = tag.pictures().first() {
|
|
||||||
cover_bytes = Some(pic.data().to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(AudioMetadata {
|
|
||||||
title,
|
|
||||||
artists,
|
|
||||||
album,
|
|
||||||
duration_ms,
|
|
||||||
track_number,
|
|
||||||
disc_number,
|
|
||||||
cover_bytes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[uniffi::export]
|
|
||||||
pub fn write_audio_metadata(
|
|
||||||
file_path: String,
|
|
||||||
title: String,
|
|
||||||
artists: String,
|
|
||||||
album: Option<String>,
|
|
||||||
track_number: Option<i32>,
|
|
||||||
disc_number: Option<i32>,
|
|
||||||
cover_bytes: Option<Vec<u8>>,
|
|
||||||
) -> Result<(), AudioTagError> {
|
|
||||||
let path = Path::new(&file_path);
|
|
||||||
if !path.exists() {
|
|
||||||
return Err(AudioTagError::FileNotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tagged_file = Probe::open(path)
|
|
||||||
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
|
|
||||||
.read()
|
|
||||||
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
|
|
||||||
|
|
||||||
let file_type = tagged_file.file_type();
|
|
||||||
|
|
||||||
if tagged_file.primary_tag().is_none() && tagged_file.first_tag().is_none() {
|
|
||||||
let new_tag = match file_type {
|
|
||||||
FileType::Mpeg => Tag::new(lofty::tag::TagType::Id3v2),
|
|
||||||
FileType::Mp4 => Tag::new(lofty::tag::TagType::Mp4Ilst),
|
|
||||||
FileType::Flac => Tag::new(lofty::tag::TagType::VorbisComments),
|
|
||||||
FileType::Opus | FileType::Vorbis => Tag::new(lofty::tag::TagType::VorbisComments),
|
|
||||||
_ => Tag::new(lofty::tag::TagType::Id3v2),
|
|
||||||
};
|
|
||||||
tagged_file.insert_tag(new_tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
let tag = if tagged_file.primary_tag().is_some() {
|
|
||||||
tagged_file.primary_tag_mut().unwrap()
|
|
||||||
} else if tagged_file.first_tag().is_some() {
|
|
||||||
tagged_file.first_tag_mut().unwrap()
|
|
||||||
} else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
|
|
||||||
tag.set_title(title);
|
|
||||||
tag.set_artist(artists);
|
|
||||||
|
|
||||||
if let Some(album_name) = album {
|
|
||||||
tag.set_album(album_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(num) = track_number {
|
|
||||||
tag.set_track(num as u32);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(num) = disc_number {
|
|
||||||
tag.set_disk(num as u32);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(bytes) = cover_bytes {
|
|
||||||
if !bytes.is_empty() {
|
|
||||||
let picture = Picture::unchecked(bytes).build();
|
|
||||||
tag.push_picture(picture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tag.save_to_path(path, WriteOptions::default())
|
|
||||||
.map_err(|e| AudioTagError::WriteError { reason: e.to_string() })?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -28,7 +28,6 @@ class DeviceAudioPlayerQueueTest {
|
|||||||
private lateinit var fakeSettings: FakeSettingsProvider
|
private lateinit var fakeSettings: FakeSettingsProvider
|
||||||
private lateinit var fakeRepo: FakeAudioPlayerQueueRepository
|
private lateinit var fakeRepo: FakeAudioPlayerQueueRepository
|
||||||
private lateinit var fakePlugin: FakePluginProvider
|
private lateinit var fakePlugin: FakePluginProvider
|
||||||
private lateinit var fakeBlacklist: FakeBlacklistRepository
|
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
@ -41,7 +40,6 @@ class DeviceAudioPlayerQueueTest {
|
|||||||
fakeSettings = FakeSettingsProvider()
|
fakeSettings = FakeSettingsProvider()
|
||||||
fakeRepo = FakeAudioPlayerQueueRepository()
|
fakeRepo = FakeAudioPlayerQueueRepository()
|
||||||
fakePlugin = FakePluginProvider()
|
fakePlugin = FakePluginProvider()
|
||||||
fakeBlacklist = FakeBlacklistRepository()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterTest
|
@AfterTest
|
||||||
@ -50,7 +48,7 @@ class DeviceAudioPlayerQueueTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createQueue(): DeviceAudioPlayerQueue {
|
private fun createQueue(): DeviceAudioPlayerQueue {
|
||||||
return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin, fakeBlacklist)
|
return DeviceAudioPlayerQueue(fakePlayer, fakeSettings, fakeRepo, fakePlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamingTrack(
|
private fun streamingTrack(
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,8 +17,6 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube
|
package dev.krtirtho.spotube
|
||||||
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.UIKit.UIApplication
|
|
||||||
import platform.UIKit.UIDevice
|
import platform.UIKit.UIDevice
|
||||||
|
|
||||||
class IOSPlatform: Platform {
|
class IOSPlatform: Platform {
|
||||||
@ -26,9 +24,4 @@ class IOSPlatform: Platform {
|
|||||||
override val type: PlatformType = PlatformType.IOS
|
override val type: PlatformType = PlatformType.IOS
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun getPlatform(): Platform = IOSPlatform()
|
actual fun getPlatform(): Platform = IOSPlatform()
|
||||||
|
|
||||||
actual fun openUrlInBrowser(url: String) {
|
|
||||||
val nsUrl = NSURL.URLWithString(url) ?: return
|
|
||||||
UIApplication.sharedApplication.openURL(nsUrl)
|
|
||||||
}
|
|
||||||
@ -1,41 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import io.github.kdroidfilter.webview.web.WebViewState
|
import io.github.kdroidfilter.webview.web.WebViewState
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import platform.Foundation.NSDate
|
actual fun platformWebviewConfig(webView: WebViewState) {
|
||||||
import platform.Foundation.NSHTTPCookieStorage
|
|
||||||
import platform.Foundation.distantPast
|
|
||||||
import platform.WebKit.WKWebsiteDataStore
|
|
||||||
import platform.WebKit.WKWebsiteDataTypeCookies
|
|
||||||
import platform.WebKit.WKWebsiteDataTypeLocalStorage
|
|
||||||
|
|
||||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
|
||||||
if (pluginId == null) return
|
|
||||||
|
|
||||||
val dataStore = WKWebsiteDataStore.defaultDataStore()
|
|
||||||
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
|
|
||||||
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
|
|
||||||
|
|
||||||
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
|
|
||||||
}
|
}
|
||||||
@ -17,9 +17,6 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube
|
package dev.krtirtho.spotube
|
||||||
|
|
||||||
import java.awt.Desktop
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
class JVMPlatform : Platform {
|
class JVMPlatform : Platform {
|
||||||
override val name: String = "Java ${System.getProperty("java.version")}"
|
override val name: String = "Java ${System.getProperty("java.version")}"
|
||||||
override val type: PlatformType = System.getProperty("os.name").let { osName ->
|
override val type: PlatformType = System.getProperty("os.name").let { osName ->
|
||||||
@ -36,8 +33,4 @@ class JVMPlatform : Platform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun getPlatform(): Platform = JVMPlatform()
|
actual fun getPlatform(): Platform = JVMPlatform()
|
||||||
|
|
||||||
actual fun openUrlInBrowser(url: String) {
|
|
||||||
Desktop.getDesktop().browse(URI(url))
|
|
||||||
}
|
|
||||||
@ -29,8 +29,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import java.awt.BorderLayout
|
import java.awt.BorderLayout
|
||||||
@ -40,6 +38,8 @@ import java.awt.Image
|
|||||||
import java.awt.SystemTray
|
import java.awt.SystemTray
|
||||||
import java.awt.Toolkit
|
import java.awt.Toolkit
|
||||||
import java.awt.TrayIcon
|
import java.awt.TrayIcon
|
||||||
|
import java.awt.event.FocusAdapter
|
||||||
|
import java.awt.event.FocusEvent
|
||||||
import java.awt.event.MouseAdapter
|
import java.awt.event.MouseAdapter
|
||||||
import java.awt.event.MouseEvent
|
import java.awt.event.MouseEvent
|
||||||
import java.awt.event.WindowAdapter
|
import java.awt.event.WindowAdapter
|
||||||
@ -50,8 +50,10 @@ import javax.swing.BoxLayout
|
|||||||
import javax.swing.JCheckBoxMenuItem
|
import javax.swing.JCheckBoxMenuItem
|
||||||
import javax.swing.JDialog
|
import javax.swing.JDialog
|
||||||
import javax.swing.JLabel
|
import javax.swing.JLabel
|
||||||
|
import javax.swing.JMenu
|
||||||
import javax.swing.JMenuItem
|
import javax.swing.JMenuItem
|
||||||
import javax.swing.JPanel
|
import javax.swing.JPanel
|
||||||
|
import javax.swing.JPopupMenu
|
||||||
import javax.swing.SwingUtilities
|
import javax.swing.SwingUtilities
|
||||||
import javax.swing.UIManager
|
import javax.swing.UIManager
|
||||||
|
|
||||||
@ -69,87 +71,26 @@ class SystemTrayService(
|
|||||||
private var menuDialog: TrayMenuDialog? = null
|
private var menuDialog: TrayMenuDialog? = null
|
||||||
|
|
||||||
private var windowVisible: Boolean = true
|
private var windowVisible: Boolean = true
|
||||||
|
private var onToggleWindowVisibility: (() -> Unit)? = null
|
||||||
private var onToggleWindowVisibility: () -> Unit = {}
|
private var onExit: (() -> Unit)? = null
|
||||||
private var onExit: () -> Unit = {}
|
|
||||||
|
|
||||||
private val volumeStep = 0.05f
|
|
||||||
private var trayEnabled = false
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var currentState: TrayState? = null
|
private var started = false
|
||||||
|
|
||||||
init {
|
private val volumeStep = 0.05f
|
||||||
scope.launch {
|
|
||||||
settingsProvider.settingsState
|
|
||||||
.map { it?.minimizeToTray ?: false }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { enabled ->
|
|
||||||
trayEnabled = enabled
|
|
||||||
if (enabled) {
|
|
||||||
ensureTrayCreated()
|
|
||||||
} else {
|
|
||||||
removeTray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
fun start(
|
||||||
combine(
|
|
||||||
audioPlayer.playerStateFlow,
|
|
||||||
audioPlayer.currentMediaItemFlow,
|
|
||||||
audioPlayer.loopStateFlow,
|
|
||||||
audioPlayer.shuffleModeFlow,
|
|
||||||
audioPlayer.volumeFlow,
|
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
|
||||||
savedTracksRepository.savedTracksIdsFlow,
|
|
||||||
) { values ->
|
|
||||||
TrayState(
|
|
||||||
playerState = values[0] as PlayerState,
|
|
||||||
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
|
||||||
loopState = values[2] as LoopState,
|
|
||||||
shuffleEnabled = values[3] as Boolean,
|
|
||||||
volume = values[4] as Float,
|
|
||||||
currentEntry = values[5] as QueueEntry?,
|
|
||||||
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
|
|
||||||
)
|
|
||||||
}.collect { state ->
|
|
||||||
currentState = state
|
|
||||||
SwingUtilities.invokeLater {
|
|
||||||
updateTooltip(state)
|
|
||||||
menuDialog?.let { dialog ->
|
|
||||||
val panel = dialog.contentPane.getComponent(0) as? JPanel
|
|
||||||
if (panel != null) rebuildMenuPanel(panel, dialog)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCallbacks(
|
|
||||||
onToggleWindowVisibility: () -> Unit,
|
onToggleWindowVisibility: () -> Unit,
|
||||||
onExit: () -> Unit,
|
onExit: () -> Unit,
|
||||||
) {
|
) {
|
||||||
this.onToggleWindowVisibility = onToggleWindowVisibility
|
this.onToggleWindowVisibility = onToggleWindowVisibility
|
||||||
this.onExit = onExit
|
this.onExit = onExit
|
||||||
}
|
|
||||||
|
|
||||||
fun hideWindow() {
|
if (started) {
|
||||||
if (windowVisible) {
|
return
|
||||||
windowVisible = false
|
|
||||||
onToggleWindowVisibility()
|
|
||||||
}
|
}
|
||||||
}
|
started = true
|
||||||
|
|
||||||
fun showWindow() {
|
|
||||||
if (!windowVisible) {
|
|
||||||
windowVisible = true
|
|
||||||
onToggleWindowVisibility()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureTrayCreated() {
|
|
||||||
if (trayIcon != null) return
|
|
||||||
if (!SystemTray.isSupported()) {
|
if (!SystemTray.isSupported()) {
|
||||||
logger.w { "System tray is not supported on this platform" }
|
logger.w { "System tray is not supported on this platform" }
|
||||||
return
|
return
|
||||||
@ -163,39 +104,25 @@ class SystemTrayService(
|
|||||||
icon.addMouseListener(object : MouseAdapter() {
|
icon.addMouseListener(object : MouseAdapter() {
|
||||||
override fun mouseClicked(e: MouseEvent) {
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
|
if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) {
|
||||||
if (windowVisible) hideWindow() else showWindow()
|
onToggleWindowVisibility.invoke()
|
||||||
} else if (e.button == MouseEvent.BUTTON3) {
|
} else if (e.button == MouseEvent.BUTTON3) {
|
||||||
showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
|
showMenuDialog(icon, e.xOnScreen, e.yOnScreen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
SystemTray.getSystemTray().add(icon)
|
val systemTray = SystemTray.getSystemTray()
|
||||||
trayIcon = icon
|
systemTray.add(icon)
|
||||||
logger.i { "System tray created" }
|
this.trayIcon = icon
|
||||||
|
|
||||||
|
logger.i { "System tray initialized" }
|
||||||
|
observeState()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.e(e) { "Failed to create system tray" }
|
logger.e(e) { "Failed to initialize system tray" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeTray() {
|
|
||||||
menuDialog?.dispose()
|
|
||||||
menuDialog = null
|
|
||||||
trayIcon?.let {
|
|
||||||
try {
|
|
||||||
SystemTray.getSystemTray().remove(it)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.e(e) { "Failed to remove tray icon" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trayIcon = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
removeTray()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
|
private fun showMenuDialog(icon: TrayIcon, x: Int, y: Int) {
|
||||||
val dialog = menuDialog
|
val dialog = menuDialog
|
||||||
if (dialog != null && dialog.isVisible) {
|
if (dialog != null && dialog.isVisible) {
|
||||||
@ -208,6 +135,7 @@ class SystemTrayService(
|
|||||||
isUndecorated = true
|
isUndecorated = true
|
||||||
isAlwaysOnTop = true
|
isAlwaysOnTop = true
|
||||||
focusableWindowState = true
|
focusableWindowState = true
|
||||||
|
setAutoRequestFocus(true)
|
||||||
background = Color(0, 0, 0, 0)
|
background = Color(0, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,7 +190,7 @@ class SystemTrayService(
|
|||||||
panel.add(createSeparator())
|
panel.add(createSeparator())
|
||||||
|
|
||||||
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
|
panel.add(createMenuItem(if (windowVisible) "Hide Window" else "Show Window") {
|
||||||
if (windowVisible) hideWindow() else showWindow()
|
onToggleWindowVisibility?.invoke()
|
||||||
})
|
})
|
||||||
panel.add(createSeparator())
|
panel.add(createSeparator())
|
||||||
|
|
||||||
@ -291,12 +219,18 @@ class SystemTrayService(
|
|||||||
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
|
val loopAll = javax.swing.JRadioButtonMenuItem("Loop: ALL", state.loopState == LoopState.ALL)
|
||||||
|
|
||||||
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||||
|
|
||||||
listOf(loopNone, loopOne, loopAll).forEach { item ->
|
listOf(loopNone, loopOne, loopAll).forEach { item ->
|
||||||
item.isOpaque = true
|
item.isOpaque = true
|
||||||
val normalBg = item.background
|
val normalBackground = item.background
|
||||||
item.addMouseListener(object : MouseAdapter() {
|
item.addMouseListener(object : MouseAdapter() {
|
||||||
override fun mouseEntered(e: MouseEvent) { item.background = hoverBackground }
|
override fun mouseEntered(e: MouseEvent) {
|
||||||
override fun mouseExited(e: MouseEvent) { item.background = normalBg }
|
item.background = hoverBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mouseExited(e: MouseEvent) {
|
||||||
|
item.background = normalBackground
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,16 +279,53 @@ class SystemTrayService(
|
|||||||
})
|
})
|
||||||
panel.add(createSeparator())
|
panel.add(createSeparator())
|
||||||
|
|
||||||
panel.add(createMenuItem("Exit") { onExit() })
|
panel.add(createMenuItem("Exit") { onExit?.invoke() })
|
||||||
|
|
||||||
panel.revalidate()
|
panel.revalidate()
|
||||||
panel.repaint()
|
panel.repaint()
|
||||||
dialog.pack()
|
dialog.pack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var currentState: TrayState? = null
|
||||||
|
|
||||||
|
private fun observeState() {
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
audioPlayer.playerStateFlow,
|
||||||
|
audioPlayer.currentMediaItemFlow,
|
||||||
|
audioPlayer.loopStateFlow,
|
||||||
|
audioPlayer.shuffleModeFlow,
|
||||||
|
audioPlayer.volumeFlow,
|
||||||
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
|
savedTracksRepository.savedTracksIdsFlow,
|
||||||
|
) { values ->
|
||||||
|
TrayState(
|
||||||
|
playerState = values[0] as PlayerState,
|
||||||
|
mediaItem = values[1] as dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||||
|
loopState = values[2] as LoopState,
|
||||||
|
shuffleEnabled = values[3] as Boolean,
|
||||||
|
volume = values[4] as Float,
|
||||||
|
currentEntry = values[5] as QueueEntry?,
|
||||||
|
savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set<String>),
|
||||||
|
)
|
||||||
|
}.collect { state ->
|
||||||
|
currentState = state
|
||||||
|
SwingUtilities.invokeLater {
|
||||||
|
updateTooltip(state)
|
||||||
|
menuDialog?.let { dialog ->
|
||||||
|
val panel = dialog.contentPane.getComponent(0) as? JPanel
|
||||||
|
if (panel != null) rebuildMenuPanel(panel, dialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateTooltip(state: TrayState) {
|
private fun updateTooltip(state: TrayState) {
|
||||||
|
val icon = trayIcon ?: return
|
||||||
val media = state.mediaItem
|
val media = state.mediaItem
|
||||||
trayIcon?.toolTip = if (media != null) {
|
icon.toolTip = if (media != null) {
|
||||||
"Spotube - ${media.title} by ${media.artist}"
|
"Spotube - ${media.title} by ${media.artist}"
|
||||||
} else {
|
} else {
|
||||||
"Spotube"
|
"Spotube"
|
||||||
@ -367,11 +338,20 @@ class SystemTrayService(
|
|||||||
isOpaque = true
|
isOpaque = true
|
||||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||||
addActionListener { action() }
|
addActionListener { action() }
|
||||||
val normalBg = background
|
|
||||||
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
val normalBackground = background
|
||||||
|
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||||
|
|
||||||
addMouseListener(object : MouseAdapter() {
|
addMouseListener(object : MouseAdapter() {
|
||||||
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
override fun mouseEntered(e: MouseEvent) {
|
||||||
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
if (isEnabled) {
|
||||||
|
background = hoverBackground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mouseExited(e: MouseEvent) {
|
||||||
|
background = normalBackground
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -381,11 +361,20 @@ class SystemTrayService(
|
|||||||
isOpaque = true
|
isOpaque = true
|
||||||
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
|
||||||
addActionListener { action() }
|
addActionListener { action() }
|
||||||
val normalBg = background
|
|
||||||
val hoverBg = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
val normalBackground = background
|
||||||
|
val hoverBackground = UIManager.getColor("MenuItem.selectionBackground") ?: Color(75, 110, 175)
|
||||||
|
|
||||||
addMouseListener(object : MouseAdapter() {
|
addMouseListener(object : MouseAdapter() {
|
||||||
override fun mouseEntered(e: MouseEvent) { if (isEnabled) background = hoverBg }
|
override fun mouseEntered(e: MouseEvent) {
|
||||||
override fun mouseExited(e: MouseEvent) { background = normalBg }
|
if (isEnabled) {
|
||||||
|
background = hoverBackground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mouseExited(e: MouseEvent) {
|
||||||
|
background = normalBackground
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -407,7 +396,26 @@ class SystemTrayService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createSeparator(): javax.swing.JSeparator {
|
private fun createSeparator(): javax.swing.JSeparator {
|
||||||
return javax.swing.JSeparator().apply { maximumSize = Dimension(Int.MAX_VALUE, 2) }
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTrayIconImage(): Image {
|
private fun createTrayIconImage(): Image {
|
||||||
|
|||||||
@ -84,34 +84,52 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
|
|||||||
val windowState = LocalWindowState.current
|
val windowState = LocalWindowState.current
|
||||||
val applicationScope = LocalApplicationScope.current
|
val applicationScope = LocalApplicationScope.current
|
||||||
val settingsProvider = koinInject<SettingsProvider>()
|
val settingsProvider = koinInject<SettingsProvider>()
|
||||||
val trayService = koinInject<SystemTrayService>()
|
val systemTrayService = koinInject<SystemTrayService>()
|
||||||
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
||||||
|
val minimizeToTray = settings?.minimizeToTray ?: false
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { windowState.isMinimized = true }) {
|
IconButton(
|
||||||
Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp))
|
onClick = {
|
||||||
|
windowState.isMinimized = true
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(
|
||||||
windowState.placement = if (windowState.placement == WindowPlacement.Maximized)
|
onClick = {
|
||||||
WindowPlacement.Floating else WindowPlacement.Maximized
|
windowState.placement = if (windowState.placement == WindowPlacement.Maximized) {
|
||||||
}) {
|
WindowPlacement.Floating
|
||||||
|
} else {
|
||||||
|
WindowPlacement.Maximized
|
||||||
|
}
|
||||||
|
}) {
|
||||||
if (windowState.placement == WindowPlacement.Floating) {
|
if (windowState.placement == WindowPlacement.Floating) {
|
||||||
Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp))
|
Icon(
|
||||||
|
Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp))
|
Icon(
|
||||||
|
Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(
|
||||||
if (settings?.minimizeToTray == true) {
|
onClick = {
|
||||||
trayService.hideWindow()
|
if (minimizeToTray) {
|
||||||
} else {
|
windowState.isMinimized = true
|
||||||
applicationScope.exitApplication()
|
systemTrayService.setWindowVisible(false)
|
||||||
}
|
} else {
|
||||||
}) {
|
applicationScope.exitApplication()
|
||||||
Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp))
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,42 +1,31 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
import io.github.kdroidfilter.webview.web.WebViewState
|
import io.github.kdroidfilter.webview.web.WebViewState
|
||||||
import okio.FileSystem
|
import io.github.vinceglb.filekit.utils.div
|
||||||
import okio.Path.Companion.toPath
|
import io.github.vinceglb.filekit.utils.toPath
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
|
|
||||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
actual fun platformWebviewConfig(webView: WebViewState) {
|
||||||
val paths = GlobalContext.get().get<Paths>()
|
val paths = GlobalContext.get().get<Paths>()
|
||||||
|
|
||||||
val baseDir = "${paths.getApplicationCacheDirPath()}/webview_data".toPath()
|
webView.webSettings.desktopWebSettings.dataDirectory =
|
||||||
val dataDir = if (pluginId != null) baseDir / pluginId else baseDir
|
(paths.getApplicationCacheDirPath().toPath() / "webview_data").toString()
|
||||||
webView.webSettings.desktopWebSettings.dataDirectory = dataDir.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
|
||||||
if (pluginId == null) return
|
|
||||||
val paths = GlobalContext.get().get<Paths>()
|
|
||||||
val dataDirStr = "${paths.getApplicationCacheDirPath()}/webview_data/$pluginId"
|
|
||||||
val dataDir = dataDirStr.toPath()
|
|
||||||
if (FileSystem.SYSTEM.exists(dataDir)) {
|
|
||||||
FileSystem.SYSTEM.deleteRecursively(dataDir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -20,9 +20,6 @@ package dev.krtirtho.spotube
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
@ -71,33 +68,22 @@ fun main() {
|
|||||||
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
val settings by settingsProvider.settingsState.collectAsState(initial = null)
|
||||||
val minimizeToTray = settings?.minimizeToTray ?: false
|
val minimizeToTray = settings?.minimizeToTray ?: false
|
||||||
|
|
||||||
val trayService = KoinServicesProvider.systemTrayService
|
val systemTrayService = KoinServicesProvider.systemTrayService
|
||||||
|
|
||||||
var isWindowVisible by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
trayService.setCallbacks(
|
|
||||||
onToggleWindowVisibility = { isWindowVisible = !isWindowVisible },
|
|
||||||
onExit = {
|
|
||||||
trayService.close()
|
|
||||||
appScope.cancel()
|
|
||||||
exitApplication()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
state = windowState,
|
state = windowState,
|
||||||
onCloseRequest = {
|
onCloseRequest = {
|
||||||
if (minimizeToTray) {
|
if (minimizeToTray) {
|
||||||
isWindowVisible = false
|
windowState.isMinimized = true
|
||||||
|
systemTrayService.setWindowVisible(false)
|
||||||
} else {
|
} else {
|
||||||
trayService.close()
|
|
||||||
appScope.cancel()
|
appScope.cancel()
|
||||||
exitApplication()
|
exitApplication()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = "Spotube",
|
title = "Spotube",
|
||||||
decoration = WindowDecoration.Undecorated(),
|
decoration = WindowDecoration.Undecorated(),
|
||||||
visible = isWindowVisible,
|
visible = true,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalApplicationScope provides this@application,
|
LocalApplicationScope provides this@application,
|
||||||
@ -107,5 +93,17 @@ fun main() {
|
|||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
systemTrayService.start(
|
||||||
|
onToggleWindowVisibility = {
|
||||||
|
windowState.isMinimized = !windowState.isMinimized
|
||||||
|
systemTrayService.setWindowVisible(!windowState.isMinimized)
|
||||||
|
},
|
||||||
|
onExit = {
|
||||||
|
systemTrayService.close()
|
||||||
|
appScope.cancel()
|
||||||
|
exitApplication()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,6 @@ spotube-gradle = "0.1.0"
|
|||||||
cryptography = "0.6.0"
|
cryptography = "0.6.0"
|
||||||
reorderable = "3.1.0"
|
reorderable = "3.1.0"
|
||||||
vlcjBundler = "0.1.0"
|
vlcjBundler = "0.1.0"
|
||||||
vanniktechMavenPublish = "0.37.0"
|
|
||||||
uniffi = "0.3.7"
|
uniffi = "0.3.7"
|
||||||
kotlinx-atomicfu = "0.33.0"
|
kotlinx-atomicfu = "0.33.0"
|
||||||
mokkery = "3.3.0"
|
mokkery = "3.3.0"
|
||||||
@ -182,5 +181,4 @@ vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "v
|
|||||||
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
|
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
|
||||||
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
|
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
|
||||||
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
|
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
|
||||||
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
|
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
|
||||||
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
|
|
||||||
201
js_plugin_example/LICENSE
Normal file
201
js_plugin_example/LICENSE
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example
|
package dev.krtirtho.js_plugin_example
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.audio
|
package dev.krtirtho.js_plugin_example.plugin_apis.audio
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.core
|
package dev.krtirtho.js_plugin_example.plugin_apis.core
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.lyrics
|
package dev.krtirtho.js_plugin_example.plugin_apis.lyrics
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.js_plugin_example.plugin_apis.scrobble
|
package dev.krtirtho.js_plugin_example.plugin_apis.scrobble
|
||||||
|
|||||||
@ -1,23 +1,31 @@
|
|||||||
# Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
# Copyright 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# you may not use this file except in compliance with the License.
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# You may obtain a copy of the License at
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
# 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
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
parallel: true
|
parallel: true
|
||||||
commands:
|
commands:
|
||||||
# 1. Protect the AGPL Core (Exclude the Apache libraries)
|
# 1. Protect the AGPL Core (Exclude the Apache libraries)
|
||||||
agpl-headers:
|
agpl-headers:
|
||||||
glob: "*.{kt,kts,xml,yaml,yml}"
|
glob: "*.{kt,kts,xml}"
|
||||||
|
exclude: "(js_plugin_example|plugin_interfaces)/"
|
||||||
run: addlicense -f .github/agpl_header.txt {staged_files}
|
run: addlicense -f .github/agpl_header.txt {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
|
||||||
|
# 2. Protect the Apache Libraries (Only include those folders)
|
||||||
|
apache-headers:
|
||||||
|
glob: "(js_plugin_example|plugin_interfaces)/**/*.{kt,kts,xml}"
|
||||||
|
run: addlicense -f .github/apache_header.txt {staged_files}
|
||||||
stage_fixed: true
|
stage_fixed: true
|
||||||
201
plugin_interfaces/LICENSE
Normal file
201
plugin_interfaces/LICENSE
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@ -16,7 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.vanniktechMavenPublish)
|
alias(libs.plugins.mavenPublish)
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
@ -32,7 +32,7 @@ kotlin {
|
|||||||
// which platforms this KMP module supports.
|
// which platforms this KMP module supports.
|
||||||
// See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets
|
// See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets
|
||||||
androidLibrary {
|
androidLibrary {
|
||||||
namespace = "dev.krtirtho.spotube.plugin_interfaces"
|
namespace = "dev.krtirtho.plugin_interfaces"
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
|
|
||||||
@ -91,39 +91,4 @@ kotlin {
|
|||||||
|
|
||||||
jsMain.dependencies {}
|
jsMain.dependencies {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
mavenPublishing {
|
|
||||||
publishToMavenCentral()
|
|
||||||
signAllPublications()
|
|
||||||
coordinates(
|
|
||||||
group.toString(),
|
|
||||||
"plugin_interfaces",
|
|
||||||
version.toString()
|
|
||||||
)
|
|
||||||
pom {
|
|
||||||
name = "plugin_interfaces"
|
|
||||||
description = "Plugin interfaces for Spotube"
|
|
||||||
inceptionYear = "2026"
|
|
||||||
url = "https://github.com/KRTirtho/spotube"
|
|
||||||
licenses {
|
|
||||||
license {
|
|
||||||
name = "AGPL-3.0-or-later"
|
|
||||||
url = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
|
|
||||||
distribution = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
developers {
|
|
||||||
developer {
|
|
||||||
id = "KRTirtho"
|
|
||||||
name = "Kingkor Roy Tirtho"
|
|
||||||
url = "https://github.com/KRTirtho/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scm {
|
|
||||||
url = "https://github.com/KRTirtho/spotube"
|
|
||||||
connection = "scm:git:git://github.com/KRTirtho/spotube.git"
|
|
||||||
developerConnection = "scm:git:ssh://git@github.com/KRTirtho/spotube.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.core
|
package dev.krtirtho.plugin_interfaces.core
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.core.browser_apis
|
package dev.krtirtho.plugin_interfaces.core.browser_apis
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.logger
|
package dev.krtirtho.plugin_interfaces.extras.logger
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.core
|
package dev.krtirtho.plugin_interfaces.core
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.core.browser_apis
|
package dev.krtirtho.plugin_interfaces.core.browser_apis
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.logger
|
package dev.krtirtho.plugin_interfaces.extras.logger
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.spotor
|
package dev.krtirtho.plugin_interfaces.extras.spotor
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.spotor
|
package dev.krtirtho.plugin_interfaces.extras.spotor
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.spotor
|
package dev.krtirtho.plugin_interfaces.extras.spotor
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.spotor
|
package dev.krtirtho.plugin_interfaces.extras.spotor
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.spotor
|
package dev.krtirtho.plugin_interfaces.extras.spotor
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.spotor
|
package dev.krtirtho.plugin_interfaces.extras.spotor
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* you may not use this file except in compliance with the License.
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* You may obtain a copy of the License at
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
* 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
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.extras.spotor
|
package dev.krtirtho.plugin_interfaces.extras.spotor
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user