diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 77189e7d..493ffc8f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Ask a question - url: https://github.com/KRTirtho/spotube/discussions/new?category=q-a + url: https://github.com/team-spotube/spotube/discussions/new?category=q-a about: The place for questions, support and feature requests diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index 46deed55..e32be2f7 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -10,9 +10,9 @@ jobs: runs-on: macOS-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: 'zulu' java-version: 21 diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml new file mode 100644 index 00000000..0ce12c27 --- /dev/null +++ b/.github/workflows/spotube-publish-binary.yml @@ -0,0 +1,274 @@ +name: Publish + +# Publishes an existing GitHub release (made by spotube-release-binary.yml) to the package managers. +# The `render` job checks out the release tag, downloads its assets and renders packaging/ with +# Gradle (:composeApp:renderPackaging: version, checksums, git-cliff release notes). The publish +# jobs only push what it rendered. +# +# Secrets: +# FLATHUB_TOKEN PAT with push access to team-spotube/org.spotube.spotube +# AUR_USERNAME, AUR_EMAIL, AUR_SSH_PRIVATE_KEY AUR account that maintains spotube-bin +# WINGET_TOKEN classic PAT (public_repo) that can push to team-spotube/winget-pkgs and open PRs +# CHOCO_API_KEY chocolatey.org API key + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to publish (vX.Y.Z) + required: true + type: string + jobs: + description: Jobs to run (flathub,aur,winget,chocolatey) + required: true + type: string + default: flathub,aur,winget,chocolatey + aur_pkgrel: + description: AUR pkgrel (bump to re-release the same version) + required: true + type: string + default: '1' + dry_run: + description: Dry run (render the packages, push nothing) + required: true + type: boolean + default: true + +permissions: + contents: read + +env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + +jobs: + render: + name: Render packages + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Validate tag and find release + id: version + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::tag must look like vX.Y.Z, got '$TAG'" + exit 1 + fi + is_draft=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) + # The packages download from the public release URLs, which a draft doesn't have yet + if [ "$is_draft" = "true" ] && [ "$DRY_RUN" != "true" ]; then + echo "::error::release $TAG is still a draft; publish it first" + exit 1 + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + # Full history: git-cliff builds the release notes from it + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + # Configuring :composeApp runs cargo (Gobley) + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install git-cliff + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Download release assets + run: | + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir release-assets \ + --pattern 'spotube-linux-*.tar.xz' --pattern 'Spotube-windows-x86_64-setup.exe' + + - name: Render packages + env: + PKGREL: ${{ inputs.aur_pkgrel }} + run: | + chmod +x gradlew + ./gradlew :composeApp:renderPackagingFlatpak :composeApp:renderPackagingAur \ + :composeApp:renderPackagingWinget :composeApp:renderPackagingChocolatey \ + -Pnightly=false -Ppkgrel="$PKGREL" -PpackagingArtifactsDir=release-assets + for f in composeApp/build/packaging/{flatpak/*.yml,aur/PKGBUILD,winget/*.yaml,chocolatey/*.nuspec}; do + echo "::group::$f" + cat "$f" + echo "::endgroup::" + done + + - name: Upload rendered packages + uses: actions/upload-artifact@v7 + with: + name: packaging + path: | + composeApp/build/packaging/flatpak + composeApp/build/packaging/aur + composeApp/build/packaging/winget + composeApp/build/packaging/chocolatey + + # Chocolatey embeds the installer in the package + - name: Upload Windows installer + if: contains(inputs.jobs, 'chocolatey') + uses: actions/upload-artifact@v7 + with: + name: windows-installer + path: release-assets/Spotube-windows-x86_64-setup.exe + + flathub: + name: Flathub + needs: render + if: contains(inputs.jobs, 'flathub') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FLATHUB_REPO: team-spotube/org.spotube.spotube + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - uses: actions/checkout@v7 + with: + repository: ${{ env.FLATHUB_REPO }} + token: ${{ secrets.FLATHUB_TOKEN }} + path: flathub + + - name: Commit + working-directory: flathub + run: | + cp ../packaging/flatpak/org.spotube.spotube.yml . + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "update-$VERSION" + git add org.spotube.spotube.yml + git diff --cached + git commit -m "Update to $VERSION" + + - name: Push and open PR + if: ${{ !inputs.dry_run }} + working-directory: flathub + env: + GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }} + run: | + git push -u origin "update-$VERSION" + gh pr create --repo "$FLATHUB_REPO" --head "update-$VERSION" \ + --title "Update to $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" + + aur: + name: AUR + needs: render + if: contains(inputs.jobs, 'aur') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - name: Publish to AUR + if: ${{ !inputs.dry_run }} + uses: KSXGitHub/github-actions-deploy-aur@v4.2.0 + with: + pkgname: spotube-bin + pkgbuild: packaging/aur/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: Update to ${{ needs.render.outputs.version }}-${{ inputs.aur_pkgrel }} + + winget: + name: WinGet + needs: render + if: contains(inputs.jobs, 'winget') + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.render.outputs.version }} + FORK: team-spotube/winget-pkgs + MANIFEST_DIR: manifests/k/KRTirtho/Spotube + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - name: Sync fork with upstream + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: gh repo sync "$FORK" --source microsoft/winget-pkgs --branch master + + # winget-pkgs is huge: only fetch this package's directory + - uses: actions/checkout@v7 + if: ${{ !inputs.dry_run }} + with: + repository: ${{ env.FORK }} + ref: master + token: ${{ secrets.WINGET_TOKEN }} + path: winget-pkgs + sparse-checkout: ${{ env.MANIFEST_DIR }} + + - name: Push and open PR + if: ${{ !inputs.dry_run }} + working-directory: winget-pkgs + env: + GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + branch="KRTirtho.Spotube-$VERSION" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + mkdir -p "$MANIFEST_DIR/$VERSION" + cp ../packaging/winget/*.yaml "$MANIFEST_DIR/$VERSION/" + git add "$MANIFEST_DIR/$VERSION" + git commit -m "New version: KRTirtho.Spotube version $VERSION" + git push -u origin "$branch" + gh pr create --repo microsoft/winget-pkgs --base master --head "${FORK%%/*}:$branch" \ + --title "New version: KRTirtho.Spotube version $VERSION" \ + --body "Release notes: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG" + + chocolatey: + name: Chocolatey + needs: render + if: contains(inputs.jobs, 'chocolatey') + runs-on: windows-latest + env: + VERSION: ${{ needs.render.outputs.version }} + steps: + - uses: actions/download-artifact@v8 + with: + name: packaging + path: packaging + + - uses: actions/download-artifact@v8 + with: + name: windows-installer + path: packaging/chocolatey/tools + + - name: Pack + run: choco pack packaging/chocolatey/spotube.nuspec --outputdirectory dist + + - name: Upload package + uses: actions/upload-artifact@v7 + with: + name: chocolatey-nupkg + path: dist/*.nupkg + + - name: Publish to Chocolatey + if: ${{ !inputs.dry_run }} + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + run: choco push "dist/spotube.$env:VERSION.nupkg" --source https://push.chocolatey.org/ --api-key "$env:CHOCO_API_KEY" diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index b1c07619..418db54e 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -10,7 +10,21 @@ on: default: 'stable' options: - stable + - beta - nightly + platform: + description: 'Platform to build (nightly only; stable and beta always build everything)' + required: true + type: choice + default: 'all' + options: + - all + - android + - linux-x86_64 + - linux-aarch64 + - windows + - macos + - ios dry_run: description: 'Dry run (build & upload artifacts only, skip creating release)' required: false @@ -22,57 +36,133 @@ on: type: boolean default: false +# Read-only by default; only create-release gets write access +permissions: + contents: read + +# Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }} + cancel-in-progress: false env: - COMPOSE_WEBVIEW_REPO: kdroidFilter/ComposeNativeWebview - COMPOSE_WEBVIEW_COMMIT: 5aeb268a2c37ff4dbfb76924573344a7c4f763ce - GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin - VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin + COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview + # fix/linux-wayland-input + COMPOSE_WEBVIEW_COMMIT: b11ac748de1219f9432338c067a2c5dafef3072a jobs: - prepare-deps: - name: Prepare Dependencies - runs-on: macos-latest + validate-inputs: + name: Validate Inputs + runs-on: ubuntu-latest + steps: + - name: Single platform builds are nightly only + if: ${{ inputs.platform != 'all' && inputs.release_type != 'nightly' }} + run: | + echo "::error::Building a single platform is only supported for nightly releases" + exit 1 + + # ComposeNativeWebview's JNI backends are gitignored and only built for the host OS, so a + # publish from macOS alone ships a jar without the Linux / Windows WebView libraries (the + # WebView then renders nothing). Build them on their own OS and hand them to prepare-deps. + build-webview-natives: + name: WebView Natives (${{ matrix.name }}) + needs: validate-inputs + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - name: linux-x64 + os: ubuntu-latest + - name: linux-aarch64 + os: ubuntu-24.04-arm + - name: windows-x64 + os: windows-latest steps: - name: Checkout ComposeNativeWebview - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: repository: ${{ env.COMPOSE_WEBVIEW_REPO }} ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }} token: ${{ secrets.GITHUB_TOKEN }} path: compose-webview - - name: Checkout gradle-plugin - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v6 with: - repository: ${{ env.GRADLE_PLUGIN_REPO }} - token: ${{ secrets.GITHUB_TOKEN }} - path: gradle-plugin + distribution: temurin + java-version: 21 - - name: Checkout vlcj-bundler - uses: actions/checkout@v4 + - name: Build Linux WebView native library + if: runner.os == 'Linux' + working-directory: compose-webview + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libgtk-3-dev libcairo2-dev \ + libsoup-3.0-dev libwebkit2gtk-4.1-dev + bash webview-compose/src/jvmMain/native/linux/build.sh + + - name: Build Windows WebView native library + if: runner.os == 'Windows' + working-directory: compose-webview + shell: cmd + run: webview-compose\src\jvmMain\native\windows\build.bat + + - name: Verify WebView native library + working-directory: compose-webview + shell: bash + run: | + case "${{ matrix.name }}" in + linux-*) files="${{ matrix.name }}/libcompose_webview_linux.so" ;; + windows-x64) files="win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll" ;; + esac + for f in $files; do + test -f "webview-compose/src/jvmMain/resources/nucleus/native/$f" || { echo "::error::Missing $f"; exit 1; } + done + + - name: Upload WebView native library + uses: actions/upload-artifact@v7 with: - repository: ${{ env.VLCJ_BUNDLER_REPO }} + name: webview-natives-${{ matrix.name }} + path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native/ + retention-days: 1 + + prepare-deps: + name: Prepare Dependencies + needs: [validate-inputs, build-webview-natives] + runs-on: macos-latest + steps: + - name: Checkout ComposeNativeWebview + uses: actions/checkout@v7 + with: + repository: ${{ env.COMPOSE_WEBVIEW_REPO }} + ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }} token: ${{ secrets.GITHUB_TOKEN }} - path: vlcj-bundler + path: compose-webview + + # Downloaded after checkout, so they are newer than the native sources and + # the build keeps them instead of trying to rebuild on the wrong OS. + - name: Download WebView native libraries + uses: actions/download-artifact@v8 + with: + pattern: webview-natives-* + merge-multiple: true + path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Set up Rust uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Publish compose-webview to mavenLocal working-directory: compose-webview @@ -80,39 +170,39 @@ jobs: chmod +x gradlew ./gradlew publishToMavenLocal - - name: Publish gradle-plugin to mavenLocal - working-directory: gradle-plugin + - name: Verify published JVM jar carries every WebView backend run: | - chmod +x gradlew - ./gradlew publishToMavenLocal - - - name: Publish vlcj-bundler to mavenLocal - working-directory: vlcj-bundler - run: | - chmod +x gradlew - ./gradlew publishToMavenLocal + jar=$(ls ~/.m2/repository/dev/nucleusframework/composewebview-jvm/*/composewebview-jvm-*.jar | grep -v -e sources -e javadoc | head -1) + contents=$(unzip -l "$jar") + for f in linux-x64/libcompose_webview_linux.so linux-aarch64/libcompose_webview_linux.so \ + win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll \ + darwin-aarch64/libcompose_webview_macos.dylib darwin-x64/libcompose_webview_macos.dylib; do + grep -q "nucleus/native/$f" <<< "$contents" || { echo "::error::$jar is missing $f"; exit 1; } + done - name: Upload maven local - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: maven-local path: ~/.m2/repository/ build-android: name: Android - needs: prepare-deps + needs: [validate-inputs, prepare-deps] + if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }} + timeout-minutes: 120 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Download maven local - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: maven-local path: ~/.m2/repository/ - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -121,35 +211,46 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Make gradlew executable run: chmod +x gradlew - name: Decode keystore + env: + KEYSTORE: ${{ secrets.KEYSTORE }} run: | - echo '${{ secrets.KEYSTORE }}' | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" + echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" - name: Configure Android signing working-directory: composeApp + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} run: | cat >> local.properties < + ./gradlew :composeApp:packageGraalvmDmg + --no-daemon + -Dorg.gradle.jvmargs="-Xmx3g -XX:MaxMetaspaceSize=1g" + -Pkotlin.compiler.execution.strategy=in-process + -Pnightly=${{ inputs.release_type == 'nightly' }} + -PbuildNumber=${{ github.run_number }} - name: Rename macOS artifact run: | - DMG_FILE=$(find composeApp/build -name "*.dmg" -type f | head -1) + DMG_FILE=$(find composeApp/build/compose/binaries/main/graalvm-dmg -maxdepth 1 -name "*.dmg" -type f | head -1) cp "$DMG_FILE" Spotube-macos-universal.dmg - name: Upload DMG - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: macos-dmg path: Spotube-macos-universal.dmg @@ -315,30 +629,124 @@ jobs: with: limit-access-to-actor: true + build-ios: + name: iOS + needs: [validate-inputs, prepare-deps] + if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }} + timeout-minutes: 120 + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make gradlew executable + run: chmod +x gradlew + + # Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading + # (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate. + # The Gradle build phase inside Xcode reads these as project properties. + - name: Build unsigned app + env: + ORG_GRADLE_PROJECT_nightly: ${{ inputs.release_type == 'nightly' }} + ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }} + run: > + xcodebuild + -project iosApp/iosApp.xcodeproj + -scheme iosApp + -configuration Release + -sdk iphoneos + -destination 'generic/platform=iOS' + -derivedDataPath build/ios + CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_REQUIRED=NO + CODE_SIGN_IDENTITY="" + DEVELOPMENT_TEAM="" + build + + - name: Package IPA + run: | + APP=$(find build/ios/Build/Products/Release-iphoneos -maxdepth 1 -name "*.app" -type d | head -1) + mkdir Payload + cp -R "$APP" Payload/ + zip -qry Spotube-ios.ipa Payload + + - name: Upload IPA + uses: actions/upload-artifact@v7 + with: + name: ios-ipa + path: Spotube-ios.ipa + + - name: Setup tmate session on failure + if: ${{ inputs.debug_ssh && failure() }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + create-release: name: Create GitHub Release - if: ${{ !inputs.dry_run }} + # Skipped platform jobs count as skipped, not failed, so a single platform build still releases + if: ${{ !inputs.dry_run && !cancelled() && !contains(needs.*.result, 'failure') }} needs: + - validate-inputs + - prepare-deps - build-android - build-linux + - build-linux-arm64 - build-windows - build-macos + - build-ios runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 - name: Set release variables id: vars run: | - if [ "${{ inputs.release_type }}" = "stable" ]; then - VERSION=$(grep 'versionName' composeApp/build.gradle.kts | head -1 | sed 's/.*"\(.*\)".*/\1/') + if [ "${{ inputs.release_type }}" != "nightly" ]; then + chmod +x gradlew + # printVersion includes the -beta.N suffix when betaNumber is set in build.gradle.kts + VERSION=$(./gradlew -q :composeApp:printVersion) TAG="v${VERSION}" echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "is_draft=true" >> "$GITHUB_OUTPUT" echo "release_name=Spotube ${TAG}" >> "$GITHUB_OUTPUT" else TAG="nightly" - VERSION="$(date +%Y-%m-%d)" + VERSION="$(date +%Y-%m-%d)-${{ github.run_number }}" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "is_draft=false" >> "$GITHUB_OUTPUT" echo "release_name=Nightly ${VERSION}" >> "$GITHUB_OUTPUT" @@ -346,13 +754,14 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 - name: Generate release notes + if: ${{ inputs.platform == 'all' }} run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md - for dir in android-apk android-aab linux-deb linux-rpm linux-appimage windows-msi windows-exe macos-dmg; do + for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg ios-ipa; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -363,19 +772,29 @@ jobs: done - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.vars.outputs.tag }} - name: ${{ steps.vars.outputs.release_name }} - body_path: release-notes.md + # A new tag (stable) would otherwise point at the default branch instead of the built commit + target_commitish: ${{ github.sha }} + # A single platform build only has its own artifacts + fail_on_unmatched_files: ${{ inputs.platform == 'all' }} + # A single platform build only replaces that platform's assets (same file names are overwritten), + # so leave the existing release's name and notes alone + name: ${{ inputs.platform == 'all' && steps.vars.outputs.release_name || '' }} + body_path: ${{ inputs.platform == 'all' && 'release-notes.md' || '' }} draft: ${{ steps.vars.outputs.is_draft }} prerelease: ${{ steps.vars.outputs.is_prerelease }} files: | android-apk/*.apk - android-aab/*.aab linux-deb/*.deb linux-rpm/*.rpm linux-appimage/*.AppImage - windows-msi/*.msi + linux-tarball/*.tar.xz + linux-deb-arm64/*.deb + linux-rpm-arm64/*.rpm + linux-appimage-arm64/*.AppImage + linux-tarball-arm64/*.tar.xz windows-exe/*.exe macos-dmg/*.dmg + ios-ipa/*.ipa diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..4c36a5e5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "compose-hot-reload": { + "command": "./gradlew", + "args": [ + "--no-daemon", + "--quiet", + "--console=plain", + "hotMcpServer" + ] + }, + "studio": { + "url": "http://127.0.0.1:64342/stream", + "type": "http" + } + } +} \ No newline at end of file diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 6dbeab07..f733b628 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -3,6 +3,7 @@ "mcp": { "compose-hot-reload": { "enabled": true, + "disabled": false, "type": "local", "command": [ "./gradlew", @@ -11,6 +12,10 @@ "--console=plain", "hotMcpServer" ] + }, + "studio": { + "url": "http://127.0.0.1:64342/stream", + "type": "remote" } } } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..073e46d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,37 @@ +{ + "gradle.autoDetect": "off", + "java.gradle.buildServer.enabled": "off", + "cmake.configureOnOpen": false, + "cSpell.words": [ + "acousticness", + "ambiguate", + "Amoled", + "Buildless", + "configurators", + "danceability", + "fuzzywuzzy", + "gapless", + "instrumentalness", + "isrc", + "Mpris", + "RGBO", + "riverpod", + "Scrobblenaut", + "shadcn", + "skeletonizer", + "songlink", + "speechiness", + "Spotube", + "titlebar", + "winget" + ], + "editor.formatOnSave": true, + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", + "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", + "*.dart": "${capture}.g.dart,${capture}.freezed.dart" + }, + "dart.flutterSdkPath": ".fvm/versions/3.35.2", + "makefile.configureOnOpen": false +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index ee383842..dc6bc52f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,32 +5,56 @@ - `:composeApp` uses custom KMP source sets (`mobileMain`, `androidJvmMain`) with explicit `dependsOn`; Gradle prints "Default Kotlin Hierarchy Template Not Applied Correctly" warning. Treat as known/expected. ## Real app entrypoints -- Desktop/JVM: `composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt` (`mainClass = dev.krtirtho.spotube.MainKt`). -- Android: `composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt`. -- iOS bridge: `composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. +- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`). +- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`. +- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. ## High-value commands - Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. - Run desktop: `:composeApp:run` -- Build Android debug: `:composeApp:assembleDebug` +- Build Android debug: `:composeApp:assembleStableDebug` (or `assembleNightlyDebug`). Android has `stable`/`nightly` product flavors (nightly = beta channel: `.nightly` applicationId suffix, own icons/name in `composeApp/src/nightly/res`). Desktop icons come from `composeApp/icons/{stable,nightly}`, chosen with `-Pnightly=true`. - Module checks: `:composeApp:check`, `:plugin_interfaces:check`, `:js_plugin_example:check` - Focused tests: `:composeApp:jvmTest`, `:composeApp:iosSimulatorArm64Test`, `:plugin_interfaces:jvmTest`, `:plugin_interfaces:jsTest`, `:js_plugin_example:jsTest` - No lint/typecheck/formatter tasks are configured; `:composeApp:check` is the only aggregated check. ## Dependencies - `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. -- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). +- Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). - **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). +## Networking +- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). +- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails. + +## Group Jam +- Room server: separate repository, https://github.com/team-spotube/jam-server (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in its `src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes, and land both changes together). +- App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off). +- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See the jam-server README. +- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (`JamDevice.kt` traits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it through `JamMediaItem.sanitized` before loading it. See "Security" in the jam-server README. +- A network drop must never crash the app: `JamRoomClient.pump` turns socket failures into a reconnect. `JamSessionSimulationTest.roomSurvivesNetworkLoss` cuts the network with `TcpProxy`. +- Server URL setting: `UserSettings.jamServerUrl` (default `DEFAULT_JAM_SERVER_URL` in `SettingsModels.kt`). +- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (run `npm run dev` in a jam-server checkout). + +## GraalVM native image (desktop) +- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. +- Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. +- Hand-maintained + agent-recorded metadata lives in `composeApp/graalvm/reachability-metadata.json`. After adding features that use reflection/JNI/JNA (vlcj, Zipline, plugins), run `:composeApp:runWithNativeAgent`, exercise the feature, then close the window normally (the merge only happens on a clean exit). +- `uk.co.caprica.vlcj.binding.lib.LibVlc` must stay registered (`allDeclaredMethods` + `jniAccessible`): JNA direct mapping finds its `native` methods by reflection. +- The `*UberJarForCurrentOS` task excludes other OSes'/CPUs' native libs and stale CEF binaries, because native-image embeds every registered resource into the binary. On Linux, `copyGraalvmJvmSo` is disabled and `copyGraalvmAwtSoLibs` prefers native-image's own libs (10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's 28 MB `libjvm.so`). +- `isProductionBuild` (top of `composeApp/build.gradle.kts`) is true for `package*`/`create*`/`notarize*`/`nativeImageCompile` tasks, or with `-Pproduction=true|false`. It gates the Rust release profile (Gobley `jvmVariant`) so `:composeApp:run` keeps fast debug Rust builds. Put new slow, ship-only optimizations behind it. +- Full user-facing build docs are in `README.md` ("Building from source"). + ## Codegen and plugin packaging - JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`. -- Zipline plugin entrypoint: `mainFunction = "dev.krtirtho.js_plugin_example.main"` in `js_plugin_example/build.gradle.kts`. Plugin metadata from `js_plugin_example/plugin.json`. +- Zipline plugin entrypoint: `mainFunction = "org.spotube.js_plugin_example.main"` in `js_plugin_example/build.gradle.kts`. Plugin metadata from `js_plugin_example/plugin.json`. ## Plugin architecture - `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system. - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. ## UI component patterns +- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen. +- Outside composables (ViewModels, services, snackbars), import `getString` / `getPluralString` / `getStringArray` from `org.spotube.spotube.core.i18n`, never from `org.jetbrains.compose.resources`. The library versions ask Skiko for the system theme on every call, which on Linux is a blocking D-Bus call that segfaulted the desktop app from background threads (#3183). `NonComposableStringLookupTest` enforces this. - **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors. - **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. @@ -49,11 +73,30 @@ ## Release workflow (`.github/workflows/release.yml`) - Triggered by `workflow_dispatch` with a `release_type` choice input (`stable` | `nightly`). -- `prepare-deps` job checks out & publishes to `mavenLocal`: - - `kdroidFilter/ComposeNativeWebview` (compose-webview) - - `team-spotube/gradle-plugin` (spotubeGradle + vlcjBundler plugins) -- Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. +- `prepare-deps` job checks out & publishes `kdroidFilter/ComposeNativeWebview` (compose-webview) to `mavenLocal`. The spotube gradle plugin and vlcj-bundler now come from Maven Central. +- Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. Desktop jobs ship GraalVM native installers (`packageGraalvm{Deb,Rpm,Exe,Dmg}`, output in `composeApp/build/compose/binaries/main/graalvm-/`), not jpackage/JVM ones. - **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release. - **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease. - **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets. -- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. \ No newline at end of file +- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached. + +## Package managers (`packaging/`, `.github/workflows/spotube-publish-binary.yml`) +- `packaging/` (`linux`, `aur`, `flatpak`, `winget`, `chocolatey`) holds JMustache templates (`*.mustache`) plus files copied as is. `:composeApp:renderPackaging` renders them into `composeApp/build/packaging/` (`RenderPackagingTask` in buildSrc documents the template data). Never hand-write versions, checksums or release notes into these files. +- Checksums come from release assets in `-PpackagingArtifactsDir=`; a template using a missing value fails the render. Release notes come from `git-cliff --context` with `cliff.toml` (`GitCliffContextTask`): needs `git-cliff` on the PATH and full git history. `gradle.properties` defaults to `nightly=true`, which leaves commits unreleased: pass `-Pnightly=false` for a stable render. +- `packaging/package-linux-tarball.sh` packs `spotube-linux--.tar.xz` (the AUR and Flathub source) from the GraalVM distributable and the rendered `linux` dir; the release workflow builds it before the AppImage step. +- The publish workflow (`workflow_dispatch`, input `tag`) renders once, then pushes to Flathub (`team-spotube/org.spotube.spotube`), the AUR (`spotube-bin`), WinGet (PR from `team-spotube/winget-pkgs`) and Chocolatey. + +## OpenCode Agent MCP Execution Directives + +### 1. Code Discovery & Context Search +- Use `android-studio-mcp` search/symbol tools to locate declarations before making edits across `:composeApp`, `:plugin_interfaces`, or `:js_plugin_example`[cite: 1, 2]. +- Keep `gradle/libs.versions.toml` as the single source of truth for dependencies and version updates. + +### 2. UI Architecture Constraints +- **Strict ViewModel Separation:** UI composables in `commonMain` must only collect `StateFlow` and emit event callbacks. Do NOT write business logic, filtering, or `mutableStateListOf` directly inside `@Composable` functions. +- **Adaptive Components:** Use `AdaptiveDropdownBottomSheet` or `AdaptiveDialogBottomSheet` inside `commonMain` for adaptive layouts[cite: 2]. Do NOT create platform-specific `expect/actual` variants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2]. + +### 3. Verification & Diagnostic Loops +- **Post-Edit Verification:** After editing Kotlin code, validate compilation by running `./gradlew :composeApp:check` or `./gradlew :composeApp:assembleStableDebug` via MCP[cite: 2]. +- **Dispatcher Integrity:** Never remove or bypass `SwingMainDispatcherFactory` in `composeApp/src/jvmMain/.../core/coroutines/`[cite: 2]. It is required to prevent `compose-webview` from hijacking `Dispatchers.Main` away from the Swing EDT[cite: 2]. +- **Live Preview Iteration:** Use `compose-hot-reload` to evaluate UI layout changes headlessly without forcing full Gradle rebuilds. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cf335a..3c57ce82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,157 @@ # Changelog -## [5.1.2](https://github.com/KRTirtho/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) +## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26) + +### Features + +- Migration to Compose Multiplatform +- Add ChipTab component and refactor SearchScreen to use it +- Add AutocompleteTextField component and integrate it into SearchScreen +- Enhance AutocompleteTextField with focus management and dropdown behavior +- Enhance AppSidebar with improved button interactions and visual feedback +- Implement endless playback recommendations in DeviceAudioPlayerQueue +- Add GhostIconButton component and enhance button interactions in TrackList +- Replace IconButton with GhostIconButton in AppExpandedPlayer and AppLargePlayer for improved visual consistency +- Replace IconButton with GhostIconButton in PlayerQueueContent and PlayableCard for visual consistency +- Enhance UI components with LocalBaseUITheme for consistent styling and improved interactions +- Enhance button styles and interactions in Buttons and Theme components for improved UI consistency +- Simplify highlight implementation in UI components for improved readability and maintainability +- Add genre selection and tabs to HomeScreen for improved content navigation +- Implement artist overview screen with loading, error handling, and pagination for albums, related artists, and featured playlists +- Add genre and featured browsing capabilities to MediaBrowseHelper +- Implement playlist creation and editing functionality with UI components +- Add 'Add to Playlist' functionality with UI components and dialogs +- Add Toggle component and update UI to use it in place of Switch +- Implement settings section cards for updates, desktop, language, caching, and plugins +- Update icons in settings sections to use Iconsax library for improved UI consistency +- Replace FeatherIcons with Iconsax in PluginCard and PluginPermissionsDialog for improved UI consistency +- Refactor PluginPermissionsDialog to use custom UI components for improved consistency and readability +- Enhance UI with shared element transitions and improve state management in album and playlist screens +- Integrate Uniffi for audio metadata reading and writing with Lofty library +- Replace FeatherIcons with Iconsax in window controls and update sidebar with better design +- Implement haze effect and UI enhancements in AppLargePlayer and AppShell +- Enhance haze effect integration in AppShell and AppSidebar for improved UI experience +- Add adaptive dialog bottom sheet component and enhance plugin installation UI +- Enhance AdaptiveDialogBottomSheet with dismiss icon and improve plugin screen layout +- Implement CollectionView component and refactor Album, Playlist, SavedTracks screens for improved UI consistency +- Add current time management in AudioPlayer for improved playback control +- Simplify playback control logic in AudioPlayer by removing redundant state checks +- Introduce AudioPlayerInterface and refactor AudioPlayer implementations for better abstraction +- Update skipToNext and skipToPrevious methods in AudioPlayer for improved media item management +- Refactor LyricsViewModel to use AudioPlayerInterface for improved abstraction +- Add Discord Rich Presence integration for enhanced media playback visibility +- Implement System Tray integration for window management and media controls +- Add hover effect to menu items in System Tray for improved user experience +- Add SSH debug session setup on failure for improved troubleshooting +- Implement blacklist functionality for tracks and artists with UI updates +- **systemtray**: Self-manage tray lifecycle via Koin DI +- **plugin_interfaces**: Update Maven publishing configuration and add GitHub Actions workflow +- **plugin_discovery**: Implement GitHub plugin discovery and add logo support in UI +- **plugin_discovery**: Implement GitHub plugin discovery and add logo support and information dialog in UI +- **plugin_install**: Add plugin installation dialog and release fetching functionality +- Update dependencies and configure compose hot reload settings +- Improve NewPipe video rank matching +- Integrate Nucleus framework for enhanced window management and webview support +- Implement system tray functionality with Compose Native Tray integration +- **mediacontrol**: Implement SystemMediaControlService for OS media integration +- **devices**: Add device discovery and remote control features +- **deeplinks**: Implement deep link handling for jam sessions +- **remote-control**: Implement remote playback functionality and UI components +- **remote-control**: Enhance queue management and player state synchronization +- **remote-control**: Enhance remote playback functionality with new commands and UI integration +- **webrtc**: Update ICE server configuration and enhance logging for peer connection events +- **jam-session**: Add connection state management and update UI feedback for session status +- **jam-session**: Enhance jam session functionality with improved playback synchronization and error handling +- **remote-control**: Refactor service registration and cleanup logic for improved reliability +- **jam-session**: Integrate MQTT broker configuration and enhance jam session settings +- **jam-session**: Implement track addition to jam session and enhance UI feedback for jam state +- **jam-session**: Enhance guest functionality with track addition and participant moderation options +- **release**: Add support for Linux ARM64 builds and artifact uploads +- **player-options**: Add PlayerOptionsViewModel and enhance player UI with options menu +- **player-options**: Enhance options menu with localized strings and icons +- **jam-settings**: Implement JamSettings screen and view model for broker configuration +- Refactor track source management to support plugin-specific caching and retrieval +- Implement shuffle play functionality for albums, playlists, and saved tracks +- **navigation**: Refactor sidebar and bottombar navigation structure with new tab and group models +- **search**: Enhance LazyRow with drag scrolling functionality +- **autocomplete**: Simplify focus management and remove unused key event handling +- **update**: Implement update checker with dialog for available updates +- **update**: Refactor update checking mechanism and implement new dialog for app and plugin updates +- **scrobble**: Implement ScrobbleManager to handle track scrobbling and integrate with audio player +- **welcome**: Implement onboarding flow with multiple pages and donation options +- **previews**: Add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields +- **settings**: Add 'About' section and improve header visibility in settings sections +- **surface**: Implement Frutiger-Aero themed surfaces with glass and acrylic styles +- **ui**: Replace Scaffold with BaseScaffold for consistent surface theming across screens +- **ui**: New custom surface integration on all components +- **onboarding**: Enhance welcome screen with structured guide steps and improved layout +- **player**: Add content descriptions for play, pause, and add to queue actions +- **support**: Implement support reminder dialog and donation options +- **player**: Refactor artist display and improve search field labels +- **search**: Enhance search functionality with recent search suggestions and improved UI components +- **sidebar**: Adjust spacing in sidebar and setting cards for improved layout +- **jam**: Implement group jam functionality with UI components and state management +- **library**: Enhance library UI with new tab titles, filtering options, and improved state management +- **library**: Add Library screen list mode beside grid mode, Local Track, Download screen, Local Library folders dialog visual improvement +- **ui**: Integrate LocalTextStyle for dynamic tinting in player controls and UI components +- **navigation**: Add custom navigation transition specifications for push and pop actions +- **i18n**: Replace hardcoded strings with localized resources across multiple components +- **plugin**: Implement MetadataPluginGuard to manage plugin dependencies and user prompts +- **ui**: Replace IndeterminateProgress with new loading components and enhance dialog structures +- **ui**: Update MillenniumModalBottomSheet with compact padding and spacing adjustments +- **plugin**: Add login prompt for metadata plugin access and implement authentication view model +- **user-agents**: Implement dynamic user agent fetching and caching mechanism +- **lyrics**: Implement SyncedLyricsList component and enhance LyricsViewModel for better state management +- **server**: Implement port fallback mechanism for playback proxy server +- **window**: Implement window state persistence and restoration +- Enhance Android build configuration with stable/nightly flavors and update launcher icons +- **graalvm**: Enable GraalVM native image support and update packaging configurations +- **local-tracks**: Implement LocalFolder screen and view model for folder navigation +- **navigation**: Implement fade-through and stack transition animations for navigation +- **remote**: Redesign remote control and devices screens, fix connection issues +- Download diagnostics, live connect status, remote settings on devices screen +- Enhance loading states for various UI components and update dependencies +- **packaging**: Flathub, AUR, WinGet and Chocolatey publishing +- **plugin**: Implement automatic plugin updates and enhance JSON parsing + +### Bug Fixes + +- **newpipe**: Fallback to muxed streams if no audio stream is available +- Dismiss search dropdown and keyboard on submission +- Custom image helper null exception +- **plugin**: Reload Zipline service on same-version replace +- Convert buildCurrentMediaItemList to a suspend function for coroutine support +- Enhance Discord RPC connection management with cooldown and error handling +- Cached track audio not being invalidated causing alternative track source to not being played +- Play next not working +- Update pendingNextIndex when playing a track from the playlist +- **webview**: Linux webview crashing the app +- Add mavenLocal() to plugin and dependency resolution management +- **plugin**: Handle service closure exception during authentication check +- **sidebar**: Add spacer to AppSidebar for improved layout +- **remote-control**: Update connection handling and improve logging for remote control events +- Add window insets to top bar and bottom navigation bar, queue sheet +- **stream-proxy**: Stream upstream audio instead of buffering the whole track +- **newpipe**: Fall back through multiple searches when ISRC search misses +- **audio-api**: Enhance fallback mechanism for text search results in YouTube +- **audio-player**: Android shuffle wasnt working +- **stream-proxy**: Improve logging for cache timeout and handle disconnection scenarios +- **macos**: Add GraalVM runtime dependency and initialize GraalVm in main function +- **android**: Create intermediate directories for Rust targets to prevent runtime errors +- **android**: Update sharing functionality to use chooser intent with new task flag +- **android**: Simplify service start logic for PlaybackService to avoid crashes on background playback +- **android**: Update playback state management to ensure guests follow host's play state +- **android**: App stuck on splash screen from main-thread string loading +- **downloads**: Respect download format/quality and never save manifests or video +- **ios**: Run Zipline on an 8 MiB stack thread +- **server**: Don't crash when the proxy port is still in use +- **ios**: Stream formats AVPlayer can play +- **gradle**: Restore VFS watch setting and clean up properties +- **build**: Restore version catalog lost in dev merge +- **ios**: Make the login WebView identify as Mobile Safari +- Update website URLs from spotube.org to spotube.cc + +## [5.1.2](https://github.com/team-spotube/spotube/compare/v5.1.1...v5.1.2) (2026-06-05) ### Bug Fixes @@ -8,14 +159,14 @@ - Dismiss search dropdown and keyboard on submission - Custom image helper null exception -## [5.1.1](https://github.com/KRTirtho/spotube/compare/v5.1.0...v5.1.1) (2026-02-24) +## [5.1.1](https://github.com/team-spotube/spotube/compare/v5.1.0...v5.1.1) (2026-02-24) ### Bug Fixes - Upgrade NewPipeExtractor to latest version fixing playback issue - Lyrics not working -## [5.1.0](https://github.com/KRTirtho/spotube/compare/v5.0.0...v5.1.0) (2025-11-14) +## [5.1.0](https://github.com/team-spotube/spotube/compare/v5.0.0...v5.1.0) (2025-11-14) ### Features @@ -23,6 +174,8 @@ - **playback**: Add dab music source - **playback**: Add uncompressed flac playback support - Add plugin audio source models and api service +- **plugins**: Filter plugins by abilities in plugins page and show abilities as + badge - **plugins**: Filter plugins by abilities in plugins page and show abilities as badge - Add setting default audio source support @@ -30,7 +183,7 @@ - Add NewPipe support for desktop platforms - Add default plugin loading capability - **queue**: Add multi-select and bulk actions to queue - ([#2839](https://github.com/KRTirtho/spotube/issues/2839)) + ([#2839](https://github.com/team-spotube/spotube/issues/2839)) - **android**: Add 16KB page size support ### Bug Fixes @@ -41,19 +194,21 @@ - Download not working in different devices and slow - **playback**: Use stream instead of chunked serving of audio bytes -## [5.0.0](https://github.com/KRTirtho/spotube/compare/v4.0.2...v5.0.0) (2025-09-08) +## [5.0.0](https://github.com/team-spotube/spotube/compare/v4.0.2...v5.0.0) (2025-09-08) ### Features - Add ISRC track search for YouTube - ([#2594](https://github.com/KRTirtho/spotube/issues/2594)) + ([#2594](https://github.com/team-spotube/spotube/issues/2594)) - Add new icons #2676 by @alexio-dev - ([#2678](https://github.com/KRTirtho/spotube/issues/2678)) + ([#2678](https://github.com/team-spotube/spotube/issues/2678)) - Add connect confirmation dialog - Add metadata api service and models - **metadata-plugin**: Add pagination support, feed and playlist CRUD endpoints - **metadata-plugin**: Add local storage api - Add webview, totp and setInterval apis for plugins +- Enhance local storage and webview APIs with improved error handling and + resource management - Enhance local storage and webview APIs with improved error handling and resource management - **metadata_plugin**: Add logout method @@ -92,6 +247,8 @@ - Local track not working and images of local not showing up - Local playback not working for tracks with special # (hashtag) characters - Inaccessible streaming url causing rapid skips +- **yt**: Fallback to different search result if all streaming url is + inaccessible - **yt**: Fallback to different search result if all streaming url is inaccessible - **playback**: Skip network requests if cached file already exists @@ -105,32 +262,36 @@ ### Translation - Add Traditional Chinese translation - ([#2762](https://github.com/KRTirtho/spotube/issues/2762)) + ([#2762](https://github.com/team-spotube/spotube/issues/2762)) - Fix Japanese translations - ([#2732](https://github.com/KRTirtho/spotube/issues/2732)) + ([#2732](https://github.com/team-spotube/spotube/issues/2732)) - Correction of the dutch language - ([#1306](https://github.com/KRTirtho/spotube/issues/1306)) + ([#1306](https://github.com/team-spotube/spotube/issues/1306)) -## [4.0.2](https://github.com/krtirtho/spotube/compare/v4.0.1...v4.0.2) (2025-03-16) +## [4.0.2](https://github.com/team-spotube/spotube/compare/v4.0.1...v4.0.2) (2025-03-16) ### Bug Fixes - invalid access token exception #2525 -## [4.0.1](https://github.com/krtirtho/spotube/compare/v4.0.0...v4.0.1) (2025-03-15) +## [4.0.1](https://github.com/team-spotube/spotube/compare/v4.0.0...v4.0.1) (2025-03-15) ### Bug Fixes - **android**: navigation overlaying in app navigation - add to playlist not working in smaller screen devices - language picker search broken +- **generate_playlist**: create playlist not adding tracks nor navigating to + playlist page +- **desktop**: double titlebar in local library folders and massive space in + overlay player - **generate_playlist**: create playlist not adding tracks nor navigating to playlist page - **desktop**: double titlebar in local library folders and massive space in overlay player - lastfm form broken in other locales #2447 -- spotify login broken due to new totp requirement #2494 -- spotify authentication 429 errors +- Greedy Green Corp login broken due to new totp requirement #2494 +- Greedy Green Corp authentication 429 errors ### Features @@ -138,7 +299,7 @@ - **translation**: add tagalog language support #2504 - **translation**: add tamil translation for spotube #2501 -## [4.0.0](https://github.com/krtirtho/spotube/compare/v3.9.0...v4.0.0) (2025-03-07) +## [4.0.0](https://github.com/team-spotube/spotube/compare/v3.9.0...v4.0.0) (2025-03-07) ## Changes @@ -154,6 +315,8 @@ - youtube source taking too long to buffer - youtube tracks keeps skipping despite being matched correctly - follow artist not working #2057 +- youtube_explode_dart failing for many videos due to youtube ios client visitor + data change - youtube_explode_dart failing for many videos due to youtube ios client visitor data change - piped api not working @@ -163,13 +326,13 @@ - **android**: home widget support (#2148) - rewrite entire app in shadcn-ui replacing material eww - grid/list customizable playbutton view -- flag to hide spotify generated images with patterns +- flag to hide Greedy Green Corp generated images with patterns - show placeholder images where there is no item or on empty page - pause playback when no internet connection - implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) - custom piped & invidious instance support -## [3.9.0](https://github.com/krtirtho/spotube/compare/v3.8.3...v3.9.0) (2024-12-08) +## [3.9.0](https://github.com/team-spotube/spotube/compare/v3.8.3...v3.9.0) (2024-12-08) ## Changes @@ -180,13 +343,17 @@ #2039 - **mobile**: ensure audio session is activated when playback is resumed after interruption #2092 +- selecting an Alternative Track Source removes the current song from the queue + #2039 +- **mobile**: ensure audio session is activated when playback is resumed after + interruption #2092 ### Features - add invidious audio source and fix auto skipping tracks (#2005) - track caching and cached track export support (#2117) -## [3.8.3](https://github.com/krtirtho/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) +## [3.8.3](https://github.com/team-spotube/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) ## Changes @@ -198,7 +365,7 @@ - **macos**: enable same window webview support -## [3.8.2](https://github.com/krtirtho/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) +## [3.8.2](https://github.com/team-spotube/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) ## Changes @@ -206,7 +373,7 @@ - endless song loading issue and no playback #1925 -## [3.8.1](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) +## [3.8.1](https://github.com/team-spotube/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) ## Changes @@ -215,11 +382,15 @@ - **translations**: correct some basque incorrect translations (#1815) - **lyrics**: LRCLIB lyrics should be usable without logging in #1803 - playlist displaying descriptions unescaped html #1784 +- **android**: pressing back while the player is open doesn't take to previous + page - **android**: pressing back while the player is open doesn't take to previous page - handle dublicated items in playback queue correctly #1852 - **desktop**: scrollbar overlapping with more options of tracks and playlists - **discord**: stop discord rpc from try update presence when not connected +- **stats**: minutes page shows plays and streams page shows minutes which + should be the opposite #1880 - **stats**: minutes page shows plays and streams page shows minutes which should be the opposite #1880 - **android**: clears queue upon swiping away notification @@ -234,7 +405,7 @@ - **desktop**: show error dialog if webview is not found on login #1871 - manually detect and define touch behavior #1763 -## [3.8.0](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) +## [3.8.0](https://github.com/team-spotube/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) ### Features @@ -268,1415 +439,1423 @@ - popup menu item opacity - linux: change app id in flatpak environment -## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) +## [3.7.1](https://github.com/team-spotube/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) ### Bug Fixes - alternative sources not showing up for SongLink matched results - ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) + ([37d002d](https://github.com/team-spotube/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) - **android:** Media Controls not working above Android 14 - [#1561](https://github.com/krtirtho/spotube/issues/1561) - ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) + [#1561](https://github.com/team-spotube/spotube/issues/1561) + ([3394c1b](https://github.com/team-spotube/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) - browse anonymously button takes to wrong route - ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) + ([73c5b30](https://github.com/team-spotube/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) - **desktop:** titlebar drag to move not working - ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) + ([5f280a1](https://github.com/team-spotube/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) - **desktop:** window is not centered - ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) + ([47f98b9](https://github.com/team-spotube/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) - **ios:** download not working - [#1575](https://github.com/krtirtho/spotube/issues/1575) - ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) + [#1575](https://github.com/team-spotube/spotube/issues/1575) + ([6591ec0](https://github.com/team-spotube/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) - **linux:** application window not visible after launch - ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) + ([8fc44ed](https://github.com/team-spotube/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) - local track not showing up in queue - ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) + ([d82261c](https://github.com/team-spotube/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) - use weak match for Jiosaavn fallback to improve matching - ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) + ([6cb2986](https://github.com/team-spotube/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) - **windows:** media controls not showing up - [#1542](https://github.com/krtirtho/spotube/issues/1542) - ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) + [#1542](https://github.com/team-spotube/spotube/issues/1542) + ([d7d864f](https://github.com/team-spotube/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) - **windows:** revert Flutter version to 3.19.6 to avoid distortion - [#1553](https://github.com/krtirtho/spotube/issues/1553) - ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) + [#1553](https://github.com/team-spotube/spotube/issues/1553) + ([982cf0b](https://github.com/team-spotube/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) -## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) +## [3.7.0](https://github.com/team-spotube/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) ### Features - local library folder cards - ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) -- Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) - ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) + ([fc5bfa0](https://github.com/team-spotube/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) +- Local music library ([#1479](https://github.com/team-spotube/spotube/issues/1479)) + ([22caa81](https://github.com/team-spotube/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) - personalized stats based on local music history - ([#1522](https://github.com/krtirtho/spotube/issues/1522)) - ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) + ([#1522](https://github.com/team-spotube/spotube/issues/1522)) + ([82307bc](https://github.com/team-spotube/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) - play initially available tracks of playlist/album immediately and fetch rest - in background [#670](https://github.com/krtirtho/spotube/issues/670) - ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) + in background [#670](https://github.com/team-spotube/spotube/issues/670) + ([02acbd9](https://github.com/team-spotube/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) - **player:** add volume slider floating label showing percentage - ([#1445](https://github.com/krtirtho/spotube/issues/1445)) - ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1445](https://github.com/team-spotube/spotube/issues/1445)) + ([8fad225](https://github.com/team-spotube/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** add Basque translation - ([#1493](https://github.com/krtirtho/spotube/issues/1493)) - ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) + ([#1493](https://github.com/team-spotube/spotube/issues/1493)) + ([dbc1c45](https://github.com/team-spotube/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) - **translations:** add Finnish translations - ([#1449](https://github.com/krtirtho/spotube/issues/1449)) - ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1449](https://github.com/team-spotube/spotube/issues/1449)) + ([edc997e](https://github.com/team-spotube/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** add georgian language - ([#1450](https://github.com/krtirtho/spotube/issues/1450)) - ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) + ([#1450](https://github.com/team-spotube/spotube/issues/1450)) + ([1e7f0e1](https://github.com/team-spotube/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) - **translations:** add Indonesian translation - ([#1426](https://github.com/krtirtho/spotube/issues/1426)) - ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1426](https://github.com/team-spotube/spotube/issues/1426)) + ([0280654](https://github.com/team-spotube/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - **translations:** Improve tr locales - ([#1419](https://github.com/krtirtho/spotube/issues/1419)) - ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1419](https://github.com/team-spotube/spotube/issues/1419)) + ([bf45681](https://github.com/team-spotube/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - upgrade to Flutter 3.22.0 - ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) + ([71341ec](https://github.com/team-spotube/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) ### Bug Fixes - fallback to LRCLIB when lyrics line less than 6 lines - [#1461](https://github.com/krtirtho/spotube/issues/1461) - ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) + [#1461](https://github.com/team-spotube/spotube/issues/1461) + ([9aea354](https://github.com/team-spotube/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) - **linux:** tray icon not showing - [#541](https://github.com/krtirtho/spotube/issues/541) - ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) + [#541](https://github.com/team-spotube/spotube/issues/541) + ([7ac7917](https://github.com/team-spotube/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) - local track not showing up in queue - ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) + ([d82261c](https://github.com/team-spotube/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) - **macos:** Logs directory not created by default - [#1353](https://github.com/krtirtho/spotube/issues/1353) - ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) + [#1353](https://github.com/team-spotube/spotube/issues/1353) + ([4ca8939](https://github.com/team-spotube/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) - **playback:** skipping tracks with unplayable sources instead of falling back - [#1492](https://github.com/krtirtho/spotube/issues/1492) - ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) + [#1492](https://github.com/team-spotube/spotube/issues/1492) + ([c607a33](https://github.com/team-spotube/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) - **search:** load more button not working - [#1417](https://github.com/krtirtho/spotube/issues/1417) - ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) + [#1417](https://github.com/team-spotube/spotube/issues/1417) + ([7e07c2e](https://github.com/team-spotube/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) - some text are garbled in different parts of the app - [#1463](https://github.com/krtirtho/spotube/issues/1463) - [#1505](https://github.com/krtirtho/spotube/issues/1505) - ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) -- spotify friends and user profile icon (mobile) showing when not authenticated - [#1410](https://github.com/krtirtho/spotube/issues/1410) - ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) + [#1463](https://github.com/team-spotube/spotube/issues/1463) + [#1505](https://github.com/team-spotube/spotube/issues/1505) + ([d2683c5](https://github.com/team-spotube/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) +- Greedy Green Corp friends and user profile icon (mobile) showing when not authenticated + [#1410](https://github.com/team-spotube/spotube/issues/1410) + ([9bccbc9](https://github.com/team-spotube/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) - **updater:** dead link - ([#1408](https://github.com/krtirtho/spotube/issues/1408)) - ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1171](https://github.com/krtirtho/spotube/issues/1171) - [#1082](https://github.com/krtirtho/spotube/issues/1082) - [#1082](https://github.com/krtirtho/spotube/issues/1082) + ([#1408](https://github.com/team-spotube/spotube/issues/1408)) + ([6907f9c](https://github.com/team-spotube/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1171](https://github.com/team-spotube/spotube/issues/1171) + [#1082](https://github.com/team-spotube/spotube/issues/1082) + [#1082](https://github.com/team-spotube/spotube/issues/1082) - windows SSL Certificate error breaking login - [#905](https://github.com/krtirtho/spotube/issues/905) - ([#1474](https://github.com/krtirtho/spotube/issues/1474)) - ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), - closes [#1468](https://github.com/krtirtho/spotube/issues/1468) + [#905](https://github.com/team-spotube/spotube/issues/905) + ([#1474](https://github.com/team-spotube/spotube/issues/1474)) + ([937a706](https://github.com/team-spotube/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), + closes [#1468](https://github.com/team-spotube/spotube/issues/1468) - **windows:** installer tries to install in current directory - ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) + ([c3c9fc5](https://github.com/team-spotube/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) -## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) +## [3.6.0](https://github.com/team-spotube/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) ### Features -- add Spotify homepage personalized recommendations - ([#1402](https://github.com/krtirtho/spotube/issues/1402)) - ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) +- add Greedy Green Corp homepage personalized recommendations + ([#1402](https://github.com/team-spotube/spotube/issues/1402)) + ([9e25c74](https://github.com/team-spotube/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) - add user profile page - ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) + ([39e97ee](https://github.com/team-spotube/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) - **android:** Filter Device To Force High Frame Rate - ([#880](https://github.com/krtirtho/spotube/issues/880)) - ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) + ([#880](https://github.com/team-spotube/spotube/issues/880)) + ([6e41b10](https://github.com/team-spotube/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) - improved caching based on riverpod - ([#1343](https://github.com/krtirtho/spotube/issues/1343)) - ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) + ([#1343](https://github.com/team-spotube/spotube/issues/1343)) + ([6673e5a](https://github.com/team-spotube/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) - LAN connect a.k.a control remote Spotube playback and local output device - selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) - ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) + selection ([#1355](https://github.com/team-spotube/spotube/issues/1355)) + ([68374ef](https://github.com/team-spotube/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) - **lyrics:** add LRCLIB lyrics provider as fallback - ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) + ([5afe823](https://github.com/team-spotube/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) - search history support - [#1236](https://github.com/krtirtho/spotube/issues/1236) - ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) + [#1236](https://github.com/team-spotube/spotube/issues/1236) + ([82b1cfa](https://github.com/team-spotube/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) - **translations:** Add Czech translation - ([#1401](https://github.com/krtirtho/spotube/issues/1401)) - ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) + ([#1401](https://github.com/team-spotube/spotube/issues/1401)) + ([5a6b800](https://github.com/team-spotube/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) - **translations:** add Thai Language - ([#1319](https://github.com/krtirtho/spotube/issues/1319)) - ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), - closes [#1310](https://github.com/krtirtho/spotube/issues/1310) - [#1311](https://github.com/krtirtho/spotube/issues/1311) + ([#1319](https://github.com/team-spotube/spotube/issues/1319)) + ([b70f250](https://github.com/team-spotube/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), + closes [#1310](https://github.com/team-spotube/spotube/issues/1310) + [#1311](https://github.com/team-spotube/spotube/issues/1311) ### Bug Fixes - instance of Artist bug - [#1362](https://github.com/krtirtho/spotube/issues/1362) - ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) + [#1362](https://github.com/team-spotube/spotube/issues/1362) + ([c8dd802](https://github.com/team-spotube/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) - **playback:** sponsor block skips and stutters in same position - ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) + ([0d080b7](https://github.com/team-spotube/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) -## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) +## [3.5.0](https://github.com/team-spotube/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) ### Features - add endless playback support - [#285](https://github.com/krtirtho/spotube/issues/285) - ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) + [#285](https://github.com/team-spotube/spotube/issues/285) + ([9dfd49c](https://github.com/team-spotube/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) - add getting started page - ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) + ([96a2a1f](https://github.com/team-spotube/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) - Add iOS background play support - ([#1166](https://github.com/krtirtho/spotube/issues/1166)) - ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) + ([#1166](https://github.com/team-spotube/spotube/issues/1166)) + ([095587e](https://github.com/team-spotube/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) - add songlink based track matching for youtube and open song link button - ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) + ([9095a8c](https://github.com/team-spotube/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) - **playlist:** show confirmation before deleting user playlist - [#1222](https://github.com/krtirtho/spotube/issues/1222) - ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) -- Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) - ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) + [#1222](https://github.com/team-spotube/spotube/issues/1222) + ([9f92440](https://github.com/team-spotube/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) +- Sort by Duration ([#1238](https://github.com/team-spotube/spotube/issues/1238)) + ([6f8271f](https://github.com/team-spotube/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) - start radio support - ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) + ([4defeef](https://github.com/team-spotube/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) - **translations:** add Korean translation - ([#1275](https://github.com/krtirtho/spotube/issues/1275)) - ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) + ([#1275](https://github.com/team-spotube/spotube/issues/1275)) + ([fdea930](https://github.com/team-spotube/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) - **translations:** Added Vietnamese - ([#1135](https://github.com/krtirtho/spotube/issues/1135)) - ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) + ([#1135](https://github.com/team-spotube/spotube/issues/1135)) + ([019ba86](https://github.com/team-spotube/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) - **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing - ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) + ([ba69496](https://github.com/team-spotube/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) ### Bug Fixes - album images are small in certain places - ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) + ([ca76a39](https://github.com/team-spotube/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) - album, artist page not loading - [#1282](https://github.com/krtirtho/spotube/issues/1282) - ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) + [#1282](https://github.com/team-spotube/spotube/issues/1282) + ([a9a1d4c](https://github.com/team-spotube/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) - **android:** audio issue when screen is off and broadcast audio session id - ([#1221](https://github.com/krtirtho/spotube/issues/1221) & - [#1247](https://github.com/krtirtho/spotube/issues/1247)) - ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), - closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) + ([#1221](https://github.com/team-spotube/spotube/issues/1221) & + [#1247](https://github.com/team-spotube/spotube/issues/1247)) + ([17105a6](https://github.com/team-spotube/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), + closes [KRTirtho/spotube#571](https://github.com/team-spotube/spotube/issues/571) - **android:** only ask battery optimization once - [#1252](https://github.com/krtirtho/spotube/issues/1252) - ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) + [#1252](https://github.com/team-spotube/spotube/issues/1252) + ([e516afb](https://github.com/team-spotube/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) - **android:** pressing back button in any other tab other than home exits the app - ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) + ([c3289a0](https://github.com/team-spotube/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) - **android:** system back button in player page exits the app - ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) + ([3294f65](https://github.com/team-spotube/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) - cleanTitle removing feat and ft from words instead of whole words - ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) + ([8612345](https://github.com/team-spotube/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) - friends list not scrollable with mouse drag - ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) + ([ab08c82](https://github.com/team-spotube/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) - no draggable scrollbar in playlist/album page - [#1158](https://github.com/krtirtho/spotube/issues/1158) - ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) + [#1158](https://github.com/team-spotube/spotube/issues/1158) + ([6f71e52](https://github.com/team-spotube/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) - non-banger songs breaking the queue if sources not found - ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) + ([90f7c53](https://github.com/team-spotube/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) - track loading when not found in Youtube - ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) + ([e964f61](https://github.com/team-spotube/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) - **translations:** Update app_nl.arb - ([#1168](https://github.com/krtirtho/spotube/issues/1168)) - ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) + ([#1168](https://github.com/team-spotube/spotube/issues/1168)) + ([8167963](https://github.com/team-spotube/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) -## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) +## [3.4.1](https://personal.github.com/team-spotube/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) ### Features - add create playlist button in add playlist dialog - ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) -- add spotify friends activity - ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) - ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) + ([2168a64](https://personal.github.com/team-spotube/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) +- add Greedy Green Corp friends activity + ([#1130](https://personal.github.com/team-spotube/spotube/issues/1130)) + ([7983932](https://personal.github.com/team-spotube/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) - **deep-link:** add track opening page - ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) + ([988a975](https://personal.github.com/team-spotube/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) - haptic feedback on long press and reordering actions - ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) + ([6242200](https://personal.github.com/team-spotube/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) - improve youtube/piped matching by suffixing "- Topic" - ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) + ([8184555](https://personal.github.com/team-spotube/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) - **translations:** add Nepali (नेपाली) translations - ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) - ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), - closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) - [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) + ([#1111](https://personal.github.com/team-spotube/spotube/issues/1111)) + ([c3ebf56](https://personal.github.com/team-spotube/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), + closes [#1074](https://personal.github.com/team-spotube/spotube/issues/1074) + [#1100](https://personal.github.com/team-spotube/spotube/issues/1100) ### Bug Fixes - alternative searched sources doesn't play - [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) - ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) + [#1059](https://personal.github.com/team-spotube/spotube/issues/1059) + ([a8e9b82](https://personal.github.com/team-spotube/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) - alternative source doesn't persist on next restart - [#840](https://personal.github.com/krtirtho/spotube/issues/840) - ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) + [#840](https://personal.github.com/team-spotube/spotube/issues/840) + ([62fde50](https://personal.github.com/team-spotube/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) - **android:** download failing for permission issues - [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) - ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) + [#1015](https://personal.github.com/team-spotube/spotube/issues/1015) + ([5509cae](https://personal.github.com/team-spotube/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) - artist page error - [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) - ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) + [#1018](https://personal.github.com/team-spotube/spotube/issues/1018) + ([8cd650b](https://personal.github.com/team-spotube/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) - audio resumes after a phone call even if it was paused before - [#926](https://personal.github.com/krtirtho/spotube/issues/926) - ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) + [#926](https://personal.github.com/team-spotube/spotube/issues/926) + ([fd1899f](https://personal.github.com/team-spotube/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) - better error message for failing to find lyrics - [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) - ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) + [#1085](https://personal.github.com/team-spotube/spotube/issues/1085) + ([e58e18d](https://personal.github.com/team-spotube/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) - Black window flash when starting the app - ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) - ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) + ([#1003](https://personal.github.com/team-spotube/spotube/issues/1003)) + ([02e44fc](https://personal.github.com/team-spotube/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) - **linux:** crash after login - ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) + ([0dfd401](https://personal.github.com/team-spotube/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) - **macos:** backbutton and window button overlap and unused empty space on home - ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) + ([b9417ca](https://personal.github.com/team-spotube/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) - **macos:** download folder unchangeable - ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) + ([9d74cf5](https://personal.github.com/team-spotube/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) - **macos:** Respect Minimize to tray option - ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) - ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) + ([#1001](https://personal.github.com/team-spotube/spotube/issues/1001)) + ([69559ba](https://personal.github.com/team-spotube/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) - **macos:** system tray shows name and sidebar weird gap - [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) - ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) + [#1083](https://personal.github.com/team-spotube/spotube/issues/1083) + ([27057ea](https://personal.github.com/team-spotube/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) - releases section is empty when user doesn't follow any artists - [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) - ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) + [#1104](https://personal.github.com/team-spotube/spotube/issues/1104) + ([682e88e](https://personal.github.com/team-spotube/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) - search page vertical scrollbar moves on horizontal scroll - [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) - ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) + [#1017](https://personal.github.com/team-spotube/spotube/issues/1017) + ([c203ac6](https://personal.github.com/team-spotube/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) - songs doesn't play when sources with preferred audio codec is empty - ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) - ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) + ([#976](https://personal.github.com/team-spotube/spotube/issues/976)) + ([ba4e11a](https://personal.github.com/team-spotube/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) - track index not showing after 200 - ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) + ([a752cf4](https://personal.github.com/team-spotube/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) - track pad horizontal scrolling not working - ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) + ([59e0e6b](https://personal.github.com/team-spotube/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) -## [3.4.0](https://github.com/KRTirtho/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) +## [3.4.0](https://github.com/team-spotube/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) ### Features - Add Go to Album option in track option - [#917](https://github.com/KRTirtho/spotube/issues/917) - ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) + [#917](https://github.com/team-spotube/spotube/issues/917) + ([b0beeca](https://github.com/team-spotube/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) - **translations:** add Italian language translations - ([#818](https://github.com/KRTirtho/spotube/issues/818)) - ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), - closes [#676](https://github.com/KRTirtho/spotube/issues/676) - [#676](https://github.com/KRTirtho/spotube/issues/676) + ([#818](https://github.com/team-spotube/spotube/issues/818)) + ([e4eb0e2](https://github.com/team-spotube/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), + closes [#676](https://github.com/team-spotube/spotube/issues/676) + [#676](https://github.com/team-spotube/spotube/issues/676) - compact genre view in home page - ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) -- Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) - ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) + ([82ed5e9](https://github.com/team-spotube/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) +- Deep link support ([#950](https://github.com/team-spotube/spotube/issues/950)) + ([4050f55](https://github.com/team-spotube/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) - improve loading animations - ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) + ([b92583d](https://github.com/team-spotube/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) - toggle for discord rpc - ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) + ([24a2294](https://github.com/team-spotube/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) - **translations:** add Dutch Language - ([#969](https://github.com/KRTirtho/spotube/issues/969)) - ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) + ([#969](https://github.com/team-spotube/spotube/issues/969)) + ([3ad7ba6](https://github.com/team-spotube/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) ### Bug Fixes - add safe area in home - ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) + ([9ee6067](https://github.com/team-spotube/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) - amoled mode and color scheme can't be changed - ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) + ([840e014](https://github.com/team-spotube/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) - doesn't minimize to tray when system title bar close button is used - [#866](https://github.com/KRTirtho/spotube/issues/866) - ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) + [#866](https://github.com/team-spotube/spotube/issues/866) + ([bb8f250](https://github.com/team-spotube/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) - genre border issues - ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) + ([2fb16e6](https://github.com/team-spotube/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) - Incorrect "Artist" label/heading on Search Results Page - [#920](https://github.com/KRTirtho/spotube/issues/920) - ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) + [#920](https://github.com/team-spotube/spotube/issues/920) + ([f86d544](https://github.com/team-spotube/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) - metadata not getting added for YouTube tracks - [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of - downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) - ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) + [#916](https://github.com/team-spotube/spotube/issues/916) and Wrong duration of + downloaded tracks [#912](https://github.com/team-spotube/spotube/issues/912) + ([a7b9398](https://github.com/team-spotube/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) - Playlist refresh not working - [#915](https://github.com/KRTirtho/spotube/issues/915) - ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) + [#915](https://github.com/team-spotube/spotube/issues/915) + ([5f1df5a](https://github.com/team-spotube/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) - track view header title overflow and player view drag glitch - ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) + ([b04d884](https://github.com/team-spotube/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) - wrong artist name sent while scrobbling - [#958](https://github.com/KRTirtho/spotube/issues/958) - ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) + [#958](https://github.com/team-spotube/spotube/issues/958) + ([dcbe729](https://github.com/team-spotube/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) -## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) +## [3.3.0](https://github.com/team-spotube/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) ### Features - Add JioSaavn as audio source - ([#881](https://github.com/KRTirtho/spotube/issues/881)) - ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) + ([#881](https://github.com/team-spotube/spotube/issues/881)) + ([14069cd](https://github.com/team-spotube/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) - **android:** better quick scroll/drag to scroll implementation - ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) + ([2e2c44f](https://github.com/team-spotube/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) - **artist:** modularize page and add wikipedia section - ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) -- discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) - ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) + ([2a69886](https://github.com/team-spotube/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) +- discord RPC integration [#98](https://github.com/team-spotube/spotube/issues/98) + ([88b8785](https://github.com/team-spotube/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) - **mini_player:** show/hide lyrics - [#851](https://github.com/KRTirtho/spotube/issues/851) - ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) + [#851](https://github.com/team-spotube/spotube/issues/851) + ([dcbb156](https://github.com/team-spotube/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) - paginated playlist and album page - ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) + ([28a5d6b](https://github.com/team-spotube/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) - **translations:** add Turkish translations - ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) + ([0c22469](https://github.com/team-spotube/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) ### Bug Fixes - "Add () to Playlist" option not showing in favorited playlists - [#904](https://github.com/KRTirtho/spotube/issues/904) - ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) + [#904](https://github.com/team-spotube/spotube/issues/904) + ([96021e1](https://github.com/team-spotube/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) - 0:00 media duration in queue after application restart - [#782](https://github.com/KRTirtho/spotube/issues/782) - ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) + [#782](https://github.com/team-spotube/spotube/issues/782) + ([83c0b49](https://github.com/team-spotube/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) - Add to Playlist Dialog memory leak - [#817](https://github.com/KRTirtho/spotube/issues/817) - ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) + [#817](https://github.com/team-spotube/spotube/issues/817) + ([fed36ec](https://github.com/team-spotube/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) - **album_card:** show loading state during adding track to queue/play - ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) + ([5633367](https://github.com/team-spotube/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) - alternative track source safearea overflow - [#876](https://github.com/KRTirtho/spotube/issues/876) - ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) + [#876](https://github.com/team-spotube/spotube/issues/876) + ([7b72a90](https://github.com/team-spotube/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) - android invalid download location Download not starting or not explaining - error [#720](https://github.com/KRTirtho/spotube/issues/720) - ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) + error [#720](https://github.com/team-spotube/spotube/issues/720) + ([d056dbf](https://github.com/team-spotube/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) - changed settings are not persisting after force stop - [#821](https://github.com/KRTirtho/spotube/issues/821) - ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) + [#821](https://github.com/team-spotube/spotube/issues/821) + ([e29a38d](https://github.com/team-spotube/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) - check for unsynced lyrics and error handling for timed lyrics query - ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) + ([1d77556](https://github.com/team-spotube/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) - **genres:** lag while scrolling - ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) + ([dc980b0](https://github.com/team-spotube/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) - infinite list disappearing for a moment everytime new page is fetched - ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) + ([1334a62](https://github.com/team-spotube/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) - last track of queue keeps repeating - [#718](https://github.com/KRTirtho/spotube/issues/718) - ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) + [#718](https://github.com/team-spotube/spotube/issues/718) + ([58e5698](https://github.com/team-spotube/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) - Navigating to settings, redirects to home page - [#812](https://github.com/KRTirtho/spotube/issues/812) - ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) + [#812](https://github.com/team-spotube/spotube/issues/812) + ([da04f06](https://github.com/team-spotube/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) - new releases section flickering on scroll glitch - ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) + ([ee94b7c](https://github.com/team-spotube/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) - **playbutton_card:** annoying animation - ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) + ([574406d](https://github.com/team-spotube/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) - scrobbling not working for first track or single track - ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) + ([0a6b54d](https://github.com/team-spotube/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) - settings page scrollbar position - ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) + ([ee82290](https://github.com/team-spotube/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) - shuffle doesn't move active track to top - ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) + ([4956bf3](https://github.com/team-spotube/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) - spotube doesn't exit properly, hangs in infinite loop - [#768](https://github.com/KRTirtho/spotube/issues/768) - ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) + [#768](https://github.com/team-spotube/spotube/issues/768) + ([353ca79](https://github.com/team-spotube/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) - trim login field padding - ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) + ([286ef83](https://github.com/team-spotube/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) - use CustomScrollView for personalized page - ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) + ([7d05c40](https://github.com/team-spotube/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) - user_playlists layout, track tile index, - ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) + ([487c2ed](https://github.com/team-spotube/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) - **windows:** media control not working - [#641](https://github.com/KRTirtho/spotube/issues/641) - ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) + [#641](https://github.com/team-spotube/spotube/issues/641) + ([7818574](https://github.com/team-spotube/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) -## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) +## [3.2.0](https://github.com/team-spotube/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) ### Features - ability to select/copy lyrics - [#802](https://github.com/KRTirtho/spotube/issues/802) - ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) -- add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) - ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) -- add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) - ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) + [#802](https://github.com/team-spotube/spotube/issues/802) + ([0eb9ee8](https://github.com/team-spotube/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) +- add Amoled theme [#724](https://github.com/team-spotube/spotube/issues/724) + ([5c5dbf6](https://github.com/team-spotube/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) +- add audio normalization [#164](https://github.com/team-spotube/spotube/issues/164) + ([da10ab2](https://github.com/team-spotube/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) - add restore default settings button - ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) + ([94c3866](https://github.com/team-spotube/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) - configurable audio normalization switch - ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) + ([c325911](https://github.com/team-spotube/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) - customizable stream/download file formats - ([#757](https://github.com/KRTirtho/spotube/issues/757)) - ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) + ([#757](https://github.com/team-spotube/spotube/issues/757)) + ([e54762b](https://github.com/team-spotube/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) - improve and unify the logging framework - ([#738](https://github.com/KRTirtho/spotube/issues/738)) - ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) + ([#738](https://github.com/team-spotube/spotube/issues/738)) + ([c7432bb](https://github.com/team-spotube/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) - LastFM scrobbling support - ([#761](https://github.com/KRTirtho/spotube/issues/761)) - ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) + ([#761](https://github.com/team-spotube/spotube/issues/761)) + ([f5bd907](https://github.com/team-spotube/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) - loading indicator for genre and personalized pages - ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) + ([ffe8d9c](https://github.com/team-spotube/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) - manual offline detection - ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) + ([854ab89](https://github.com/team-spotube/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) - show error dialog on failed to login - ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) + ([101c325](https://github.com/team-spotube/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) - sliding up player support - ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) + ([083319f](https://github.com/team-spotube/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) - swipe to open player view - ([#765](https://github.com/KRTirtho/spotube/issues/765)) - ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) + ([#765](https://github.com/team-spotube/spotube/issues/765)) + ([9aee056](https://github.com/team-spotube/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) - thicken the scrollbars & make 'em interactive for mobile - ([#764](https://github.com/KRTirtho/spotube/issues/764)) - ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) + ([#764](https://github.com/team-spotube/spotube/issues/764)) + ([84a4bcd](https://github.com/team-spotube/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) - **translations:** add Arabic Translations - ([#740](https://github.com/KRTirtho/spotube/issues/740)) - ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) + ([#740](https://github.com/team-spotube/spotube/issues/740)) + ([38493f9](https://github.com/team-spotube/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) - **translations:** add Farsi Translations - ([#760](https://github.com/KRTirtho/spotube/issues/760)) - ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) + ([#760](https://github.com/team-spotube/spotube/issues/760)) + ([fe42cfe](https://github.com/team-spotube/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) ### Bug Fixes - add libmpv1 for ubuntu-based systems - ([#739](https://github.com/KRTirtho/spotube/issues/739)) - ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) + ([#739](https://github.com/team-spotube/spotube/issues/739)) + ([5115e04](https://github.com/team-spotube/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) - add xdg-user-dirs as deps - ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) + ([f3e331e](https://github.com/team-spotube/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) - **android :** file_selector getDirectoryPath returns unusable content urls - [#720](https://github.com/KRTirtho/spotube/issues/720) - ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) + [#720](https://github.com/team-spotube/spotube/issues/720) + ([b3cf639](https://github.com/team-spotube/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) - **android:** audio doesn't resume on interruption end - ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) + ([15d466a](https://github.com/team-spotube/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) - **android:** system navigator back doesn't close player - ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) + ([20d7092](https://github.com/team-spotube/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) - get rid of overflow errors & status bar dark color - ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) + ([5bb8231](https://github.com/team-spotube/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) - keyboard shortcuts changing route but not update sidebar - ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) + ([2d93441](https://github.com/team-spotube/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) - last track repeats - ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) + ([ed6ca00](https://github.com/team-spotube/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) - minor glitches - ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) + ([e5d0aaf](https://github.com/team-spotube/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) - not fetching all followed artists - ([#759](https://github.com/KRTirtho/spotube/issues/759)) - ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) + ([#759](https://github.com/team-spotube/spotube/issues/759)) + ([c09a572](https://github.com/team-spotube/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) - use audio_service_mpris plugin - ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) + ([e29cc25](https://github.com/team-spotube/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) - valid non-ASCII characters get removed from downloaded file name - [#745](https://github.com/KRTirtho/spotube/issues/745) - ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) + [#745](https://github.com/team-spotube/spotube/issues/745) + ([a7e102f](https://github.com/team-spotube/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) -## [3.1.2](https://github.com/KRTirtho/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) +## [3.1.2](https://github.com/team-spotube/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) ### Features - **player_queue:** filtering track support - ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) + ([d4f99ec](https://github.com/team-spotube/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) - right click to open track option - ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) + ([1540999](https://github.com/team-spotube/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) - search loading animation - ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) + ([b9d5c70](https://github.com/team-spotube/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) - show loading indicator on play track - ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) + ([d12ea48](https://github.com/team-spotube/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) ### Bug Fixes - add missing dependency in debian package - ([#704](https://github.com/KRTirtho/spotube/issues/704)) - ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) + ([#704](https://github.com/team-spotube/spotube/issues/704)) + ([c987ea7](https://github.com/team-spotube/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) - hour not showing for tracks longer than 60 minutes - ([#648](https://github.com/KRTirtho/spotube/issues/648)) - ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) + ([#648](https://github.com/team-spotube/spotube/issues/648)) + ([de335f4](https://github.com/team-spotube/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) - liked tracks card play not working - ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) + ([d3e1cef](https://github.com/team-spotube/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) - limit cover image upload to allowed 256kb size - ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) -- playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) - ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) + ([1c50612](https://github.com/team-spotube/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) +- playlist grey page ([#707](https://github.com/team-spotube/spotube/issues/707)) + ([0df8d9c](https://github.com/team-spotube/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) - rewind breaks track progress bar - ([#695](https://github.com/KRTirtho/spotube/issues/695)) - ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) + ([#695](https://github.com/team-spotube/spotube/issues/695)) + ([e321743](https://github.com/team-spotube/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) - Windows memory leak due refetchOnStale user-liked-tracks - ([#705](https://github.com/KRTirtho/spotube/issues/705)) - ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) + ([#705](https://github.com/team-spotube/spotube/issues/705)) + ([142dc49](https://github.com/team-spotube/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) -## [3.1.1](https://github.com/KRTirtho/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) +## [3.1.1](https://github.com/team-spotube/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) ### Features - ability to toggle system title bar & custom title bar - ([#185](https://github.com/KRTirtho/spotube/issues/185)) - ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) + ([#185](https://github.com/team-spotube/spotube/issues/185)) + ([8d46029](https://github.com/team-spotube/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) - jump to specific time on lyric click - ([#590](https://github.com/KRTirtho/spotube/issues/590)) - ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) + ([#590](https://github.com/team-spotube/spotube/issues/590)) + ([a14fb9e](https://github.com/team-spotube/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) - paginated user albums - ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) + ([d239d64](https://github.com/team-spotube/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) - **translations:** add Russian translation - ([#661](https://github.com/KRTirtho/spotube/issues/661)) - ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), - closes [#625](https://github.com/KRTirtho/spotube/issues/625) + ([#661](https://github.com/team-spotube/spotube/issues/661)) + ([e9a0911](https://github.com/team-spotube/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), + closes [#625](https://github.com/team-spotube/spotube/issues/625) - **translations:** added Portuguese (Brazil) translation - ([#634](https://github.com/KRTirtho/spotube/issues/634)) - ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) + ([#634](https://github.com/team-spotube/spotube/issues/634)) + ([76f30a0](https://github.com/team-spotube/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) ### Bug Fixes - always fetching SponsorBlock if no segments found & download failing - ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) + ([6ced0a0](https://github.com/team-spotube/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) - debian bookworm invalid dependencies - ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) + ([633415d](https://github.com/team-spotube/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) - disable android auto for playstore version :"( - ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) + ([0f0d240](https://github.com/team-spotube/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) - infinite route push glitch - ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) + ([e90eceb](https://github.com/team-spotube/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) - jump to track going to wrong track - ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) + ([190df17](https://github.com/team-spotube/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) - last track of queue never plays & repeat playlist never works - ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) + ([c3c09f5](https://github.com/team-spotube/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) - lyrics page text contrast - ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) + ([179d536](https://github.com/team-spotube/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) - replace connectivity_plus with internet_connection_checker - ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) + ([f23e871](https://github.com/team-spotube/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) - sanitize song title for file name - ([#644](https://github.com/KRTirtho/spotube/issues/644)) - ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) + ([#644](https://github.com/team-spotube/spotube/issues/644)) + ([1a7ea0c](https://github.com/team-spotube/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) - sorting by date crashes app - ([#551](https://github.com/KRTirtho/spotube/issues/551)) - ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) + ([#551](https://github.com/team-spotube/spotube/issues/551)) + ([48e90a4](https://github.com/team-spotube/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) - window size remains same after exiting mini player - ([#618](https://github.com/KRTirtho/spotube/issues/618)) - ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) + ([#618](https://github.com/team-spotube/spotube/issues/618)) + ([fb36003](https://github.com/team-spotube/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) -## [3.1.0](https://github.com/KRTirtho/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) +## [3.1.0](https://github.com/team-spotube/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) ### Features - add android auto media session control support - ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) + ([0f5748a](https://github.com/team-spotube/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) - better track matching on youtube API - ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) + ([904a0d3](https://github.com/team-spotube/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) - blazingly™ fast download manager - ([#619](https://github.com/KRTirtho/spotube/issues/619)) - ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) + ([#619](https://github.com/team-spotube/spotube/issues/619)) + ([38dc4be](https://github.com/team-spotube/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) - paginated user playlists - ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) + ([e7c6813](https://github.com/team-spotube/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) - show error dialog on piped API 500 error - ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) + ([c69f81e](https://github.com/team-spotube/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) - **translation:** add catalan translations - ([#621](https://github.com/KRTirtho/spotube/issues/621)) - ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) + ([#621](https://github.com/team-spotube/spotube/issues/621)) + ([c94e5ba](https://github.com/team-spotube/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) - **translations:** add polish translation - ([#631](https://github.com/KRTirtho/spotube/issues/631)) - ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) + ([#631](https://github.com/team-spotube/spotube/issues/631)) + ([f90e9be](https://github.com/team-spotube/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) - web compatibility - ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) + ([cf7b849](https://github.com/team-spotube/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) ### Bug Fixes - always showing play in playlist/album views - ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) + ([8521cc5](https://github.com/team-spotube/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) - **android:** android 13 local tracks not showing up - ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) + ([e3f4344](https://github.com/team-spotube/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) - default to youtube API by default - ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) + ([5a8a1e4](https://github.com/team-spotube/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) - generate playlist page max width - ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) + ([4adf695](https://github.com/team-spotube/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) - tracks doesn't change when ended - ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) + ([aa4ac86](https://github.com/team-spotube/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) - windows media controls not working - ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) + ([ae5edd1](https://github.com/team-spotube/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) -## [3.0.1](https://github.com/KRTirtho/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) +## [3.0.1](https://github.com/team-spotube/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) ### Features - Force High Refresh Rate on some Android devices - ([#607](https://github.com/KRTirtho/spotube/issues/607)) - ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) + ([#607](https://github.com/team-spotube/spotube/issues/607)) + ([6dff099](https://github.com/team-spotube/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) - **translations:** add spanish translations - ([#585](https://github.com/KRTirtho/spotube/issues/585)) - ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) + ([#585](https://github.com/team-spotube/spotube/issues/585)) + ([042d7a4](https://github.com/team-spotube/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) - **translations:** add Simplified Chinese translation. - ([#556](https://github.com/KRTirtho/spotube/issues/556)) - ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) + ([#556](https://github.com/team-spotube/spotube/issues/556)) + ([26dbd52](https://github.com/team-spotube/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) ### Bug Fixes - alternative track source textfield safe area - ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) + ([b8c6d7e](https://github.com/team-spotube/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) - avoid sponsor block for first few seconds to not break the stream - ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) + ([d8cf2ae](https://github.com/team-spotube/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) - cache segments casting error - ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) + ([dfd60bd](https://github.com/team-spotube/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) - duration is always zero in PlayerView - ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) + ([4885dca](https://github.com/team-spotube/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) - flags not showing up and html in descriptions - ([5a563ef](https://github.com/KRTirtho/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) + ([5a563ef](https://github.com/team-spotube/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) - **linux:** crash when no secret service provider found - ([#608](https://github.com/KRTirtho/spotube/issues/608)) - ([888a4b1](https://github.com/KRTirtho/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) + ([#608](https://github.com/team-spotube/spotube/issues/608)) + ([888a4b1](https://github.com/team-spotube/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) - login dialog stays after login, mention sp_gaid in tutorial - ([b492840](https://github.com/KRTirtho/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) + ([b492840](https://github.com/team-spotube/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) - **album_sync**: negative index exception in update palette - ([#561](https://github.com/KRTirtho/spotube/issues/561)) - ([0089d47](https://github.com/KRTirtho/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) + ([#561](https://github.com/team-spotube/spotube/issues/561)) + ([0089d47](https://github.com/team-spotube/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) - remove adaptive widgets - ([#520](https://github.com/KRTirtho/spotube/issues/520)) - ([e4cbdd3](https://github.com/KRTirtho/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) -- shuffle not working ([#562](https://github.com/KRTirtho/spotube/issues/562)) - ([dc76634](https://github.com/KRTirtho/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) + ([#520](https://github.com/team-spotube/spotube/issues/520)) + ([e4cbdd3](https://github.com/team-spotube/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) +- shuffle not working ([#562](https://github.com/team-spotube/spotube/issues/562)) + ([dc76634](https://github.com/team-spotube/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) - track not skipping to next even when source is available - ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) + ([0b7affd](https://github.com/team-spotube/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) -## [3.0.0](https://github.com/KRTirtho/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) +## [3.0.0](https://github.com/team-spotube/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) ### Features - adaptive controllers - ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) + ([c8b7de0](https://github.com/team-spotube/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) - adaptive popup and bottom sheet list widget - ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) + ([ddc1c5f](https://github.com/team-spotube/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) - add generated to playlist(s) - ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) + ([c91d8c8](https://github.com/team-spotube/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) - add german locale - ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) + ([ba3f428](https://github.com/team-spotube/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) - add piped search mode - ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) + ([17a25a5](https://github.com/team-spotube/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) - add sleep timer support - ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) + ([4a75f3d](https://github.com/team-spotube/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) - adjust lyric page blurriness and player playbutton - ([54d5907](https://github.com/KRTirtho/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) + ([54d5907](https://github.com/team-spotube/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) - album art dominant color as accent color - ([#447](https://github.com/KRTirtho/spotube/issues/447)) - ([31b9249](https://github.com/KRTirtho/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) + ([#447](https://github.com/team-spotube/spotube/issues/447)) + ([31b9249](https://github.com/team-spotube/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) - **android:** add splash screen - ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) + ([c232fcc](https://github.com/team-spotube/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) - **android:** disable battery optimization for better playback - ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) + ([fe5b429](https://github.com/team-spotube/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) - artist card redesign - ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) + ([92a418c](https://github.com/team-spotube/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) - Better download manager with download progress - ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) + ([6752adc](https://github.com/team-spotube/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) - better language picker, adaptive select tile and settings section contrast - ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) + ([6430a25](https://github.com/team-spotube/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) - cache encryption for sensitive data - ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) + ([b110d83](https://github.com/team-spotube/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) - color scheme picker dialog vertical list view instead of wrap - ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) + ([bb60b01](https://github.com/team-spotube/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) - compact and adaptive playbutton card design - ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) + ([eeb8cab](https://github.com/team-spotube/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) - compact button tabbar - ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) + ([67380f6](https://github.com/team-spotube/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) - create a basic installer script - ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) + ([1763a36](https://github.com/team-spotube/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) - curved navigation bar - ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) + ([776edf8](https://github.com/team-spotube/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) - custom error toast - ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) + ([96f04c1](https://github.com/team-spotube/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) - custom playlist generator - ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) + ([f4b0d13](https://github.com/team-spotube/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) - desktop mini player support - ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) + ([471812d](https://github.com/team-spotube/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) - **desktop:** close button for minimize notification - ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) + ([1688f99](https://github.com/team-spotube/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) - **desktop:** show minimized to system tray notification - ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) + ([296f96c](https://github.com/team-spotube/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) - disable/enable smtc on demand - ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) + ([7fa50e5](https://github.com/team-spotube/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) - download button on each track - ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) + ([925fa86](https://github.com/team-spotube/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) - enable caching of queue - ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) + ([ec11af5](https://github.com/team-spotube/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) - heart button animation - ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) + ([8432dc6](https://github.com/team-spotube/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) - improved track item API and UI - ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) + ([617aa89](https://github.com/team-spotube/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) - **installer:** get latest version from Github API - ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) + ([957c085](https://github.com/team-spotube/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) - **local_tracks:** delete local track - ([#484](https://github.com/KRTirtho/spotube/issues/484)) - ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) + ([#484](https://github.com/team-spotube/spotube/issues/484)) + ([52835b2](https://github.com/team-spotube/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) - locale category/genre title - ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) + ([88137f0](https://github.com/team-spotube/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) - **locale:** add bengali translations for search page - ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) + ([a1cdbad](https://github.com/team-spotube/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) - **locale:** localize search, library, lyrics, artist with both Bengali and English - ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) + ([11fe9ec](https://github.com/team-spotube/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) - **locale:** player, playlist view, track tile bengali and english translations - ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) + ([c55133d](https://github.com/team-spotube/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) - localize settings, about, login, player queue with Bengali and English translations - ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) + ([a5c36bb](https://github.com/team-spotube/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) - logs page in settings - ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) + ([b78e7f5](https://github.com/team-spotube/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) - macos title bar spacing and lyrics page margin separate - ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) + ([a0b3771](https://github.com/team-spotube/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) - make snackbar floating - ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) + ([9dbb817](https://github.com/team-spotube/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) - merge floating player with nav bar and nav bar translucent bg - ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) + ([a90261e](https://github.com/team-spotube/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) - **mini_player:** remove window shadow - ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) + ([6259014](https://github.com/team-spotube/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) - **mini_player:** show/hide UI on hover toggle - ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) + ([2e8b647](https://github.com/team-spotube/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) - new sidebar widget and translucent bottom player - ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) + ([4ba1e70](https://github.com/team-spotube/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) - newly released albums of user followed artist - ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) + ([33cb794](https://github.com/team-spotube/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) - optimize image load + genre page and reduce page size of loaded categories - ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) + ([7131efa](https://github.com/team-spotube/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) - persistent volume percentage - ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) + ([3724bd5](https://github.com/team-spotube/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) - personal playlist recommendations - ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) + ([ae820a2](https://github.com/team-spotube/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) - piped instance picker on settings - ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) + ([bed0d3b](https://github.com/team-spotube/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) - platform specific title bar buttons - ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) + ([6267720](https://github.com/team-spotube/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) - **playback:** integrate android, ios, macos with JustAudio - ([d487fe5](https://github.com/KRTirtho/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) + ([d487fe5](https://github.com/team-spotube/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) - **playback:** use assets_audio_player to fix macos double duration problems and android high loading latency - ([1fff0f1](https://github.com/KRTirtho/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) + ([1fff0f1](https://github.com/team-spotube/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) - player details dialog and separate location of lyrics button in player page - ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) + ([ce38233](https://github.com/team-spotube/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) - **player:** add playlist related methods to audio player - ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) + ([f1080e1](https://github.com/team-spotube/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) - **player:** animated gradient background - ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) + ([49b5d0e](https://github.com/team-spotube/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) - **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv - ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) + ([eaf65b6](https://github.com/team-spotube/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) - **player:** proper coloring of elements - ([b2c4ea1](https://github.com/KRTirtho/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) + ([b2c4ea1](https://github.com/team-spotube/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) - **player:** replace bg blur with gradient, proper fg color and align title and artist name - ([159f03e](https://github.com/KRTirtho/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) + ([159f03e](https://github.com/team-spotube/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) - playlist create support for generated playlist - ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) + ([91c72f9](https://github.com/team-spotube/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) - playlist generation all parameters support - ([9877d5f](https://github.com/KRTirtho/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) + ([9877d5f](https://github.com/team-spotube/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) - **playlist,album page:** play and shuffle take full width on smaller screens, add new xs breakpoint - ([dce1b88](https://github.com/KRTirtho/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) + ([dce1b88](https://github.com/team-spotube/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) - **queue:** add track(s) for playing next - ([#460](https://github.com/KRTirtho/spotube/issues/460)) - ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) + ([#460](https://github.com/team-spotube/spotube/issues/460)) + ([cac8ea6](https://github.com/team-spotube/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) - **queue:** reorder tracks support - ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) + ([441b43b](https://github.com/team-spotube/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) - re-designed playlist/album page - ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) + ([0cedc7a](https://github.com/team-spotube/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) - re-introduce youtube API along with piped - ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) + ([b54ee96](https://github.com/team-spotube/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) - reactive volume slider and slicker bottom bar with lowered height - ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) + ([9d14517](https://github.com/team-spotube/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) - remove SponsorBlock in favor of YT Music and remove pocketbase backend track support - ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) + ([fb780da](https://github.com/team-spotube/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) - repeat button all 3 mode and disable player controls when track is fetching - ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) + ([1418378](https://github.com/team-spotube/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) - replace YouTube API with piped API - ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) + ([1ecc36d](https://github.com/team-spotube/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) - responsive playlist generate page and scrollable multi autocomplete - ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) + ([d57aad5](https://github.com/team-spotube/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) - search alternative track source - ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) + ([dfea195](https://github.com/team-spotube/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) - setup localization (l10n) and language switcher, add sidebar and navbar locale - ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) + ([f12d812](https://github.com/team-spotube/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) - show album release year - ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of + ([#387](https://github.com/team-spotube/spotube/issues/387)) and fix layout of artist's album - ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) + ([6a6ddf6](https://github.com/team-spotube/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) - show country code piped instance list - ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) + ([60328a6](https://github.com/team-spotube/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) - show loading when track metadata is being fetched, android, ios, macos enable shuffling - ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) + ([bf59570](https://github.com/team-spotube/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) - sort tracks by newest and oldest dates - ([b4713e3](https://github.com/KRTirtho/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) + ([b4713e3](https://github.com/team-spotube/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) - supabase integration - ([8bcce92](https://github.com/KRTirtho/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) -- system tray support ([#31](https://github.com/KRTirtho/spotube/issues/31)) - ([06a0437](https://github.com/KRTirtho/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) + ([8bcce92](https://github.com/team-spotube/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) +- system tray support ([#31](https://github.com/team-spotube/spotube/issues/31)) + ([06a0437](https://github.com/team-spotube/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) - track populate sibling support - ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) + ([3aeb026](https://github.com/team-spotube/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) - **translation:** add hindi and french translations using ChatGPT - ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) + ([6d836bd](https://github.com/team-spotube/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) - **translation:** add Japanase locale - ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) + ([4b52a71](https://github.com/team-spotube/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) - use system color scheme - ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) + ([862c4b8](https://github.com/team-spotube/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) - volume slider in player page - ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) + ([7abe2c1](https://github.com/team-spotube/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) - windows OS media control panel support - ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) + ([f0b426a](https://github.com/team-spotube/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) ### Bug Fixes - add to playlist dialog not showing playlist name - ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) + ([8944581](https://github.com/team-spotube/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) - album sync not working - ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) + ([74906f3](https://github.com/team-spotube/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) - alternative track source not playing new source - ([a9b5a71](https://github.com/KRTirtho/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) + ([a9b5a71](https://github.com/team-spotube/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) - **android:** use multi assetAudioPlayer instance fix patch and disable Pre-download and play by default in Android too - ([cdb3268](https://github.com/KRTirtho/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) + ([cdb3268](https://github.com/team-spotube/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) - **artist:** follower count shows as float when < 1000 - ([#482](https://github.com/KRTirtho/spotube/issues/482)) - ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) + ([#482](https://github.com/team-spotube/spotube/issues/482)) + ([fd1846e](https://github.com/team-spotube/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) - bottom navbar first item icon color not changing on primary color change - ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) + ([6eb4244](https://github.com/team-spotube/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) - cached currently playing track infinite loading - ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) + ([9401718](https://github.com/team-spotube/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) - cached queue tracks expired stream - ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) + ([ed29ab5](https://github.com/team-spotube/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) - collection currently playing state persist on restart - ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) + ([1c89e3e](https://github.com/team-spotube/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) - color not syncing and add new screenshot - ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) + ([6205501](https://github.com/team-spotube/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) - content going below bottom player or nav bar - ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) + ([1bdce9f](https://github.com/team-spotube/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) - disable background_downloader due to android build failures - ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) + ([7d23bee](https://github.com/team-spotube/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) - disable play when loading track and buffering event - ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) + ([30c933c](https://github.com/team-spotube/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) - error log - ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) + ([e3d8239](https://github.com/team-spotube/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) - excessive repaints caused by Player progress bar - ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) + ([09b24cf](https://github.com/team-spotube/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) - failed download no error icon - ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) + ([1266a3f](https://github.com/team-spotube/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) - **home:** bottom player transparency - ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) + ([20c424c](https://github.com/team-spotube/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) - language changer not working - ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) + ([7b7b1f2](https://github.com/team-spotube/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) - less frequent position updates - ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) + ([0a49b56](https://github.com/team-spotube/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) - linux mpris not showing up and overall media notification service - ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) + ([1abcad1](https://github.com/team-spotube/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) - local tracks getting fetched on first load - ([73c012c](https://github.com/KRTirtho/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) + ([73c012c](https://github.com/team-spotube/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) - local tracks not working when there's a invalid music file in the folder - ([5855820](https://github.com/KRTirtho/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) + ([5855820](https://github.com/team-spotube/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) - lyrics page blur in player and cut off text when line too big - ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) + ([6b4584e](https://github.com/team-spotube/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) - macos build by removing media_kit native event loop - ([62fc773](https://github.com/KRTirtho/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) + ([62fc773](https://github.com/team-spotube/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) - macos build error, mobile player duration and playing state and background disposal of player - ([be91e33](https://github.com/KRTirtho/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) + ([be91e33](https://github.com/team-spotube/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) - **macos,ios:** use regular shared prefs - ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) + ([1b5bfec](https://github.com/team-spotube/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) - memoize child of animated widget and make player bg animation faster - ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) + ([fcb5c8f](https://github.com/team-spotube/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) - mini player not working in release mode - ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) + ([28ff321](https://github.com/team-spotube/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) - **mkPlayer:** remove method and wrong active index on modifying playlist - ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) + ([3bafa7b](https://github.com/team-spotube/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) - mobile audio notification not working - ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) + ([8f9303b](https://github.com/team-spotube/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) - multiple instance of theme - ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) + ([4ec0424](https://github.com/team-spotube/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) - navigation to settings not working - ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) + ([ce10aa1](https://github.com/team-spotube/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) - no progress update when track changed - ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) + ([6ae8964](https://github.com/team-spotube/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) - null exception on proxy playlist and audio player - ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) + ([a455a89](https://github.com/team-spotube/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) - overflowing clickable artists links - ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) + ([4077fac](https://github.com/team-spotube/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) - personalized playlists not loading - ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) + ([caa3408](https://github.com/team-spotube/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) - playback not moving to next track after a track ends - ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) + ([27e8acb](https://github.com/team-spotube/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) - **player:** gradient bg not taking full height - ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) + ([62ad86e](https://github.com/team-spotube/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) - **player:** playback element placement - ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) + ([5e47faa](https://github.com/team-spotube/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) - **player:** queue button not showing when not logged in - ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) + ([6c2d655](https://github.com/team-spotube/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) - **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip - ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) + ([1f32554](https://github.com/team-spotube/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) - playlist generate slider shape - ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) + ([2b35c04](https://github.com/team-spotube/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) - pop sheet list not scrollable - ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) + ([cca5625](https://github.com/team-spotube/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) - re-enable add to queue and play next support, favorite button query exceptions - ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) + ([e529c79](https://github.com/team-spotube/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) - re-enable download manager - ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) + ([ea45c4f](https://github.com/team-spotube/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) - remove unnecessary broadcast stream conversions - ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) + ([bf04962](https://github.com/team-spotube/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) - remove useBreakpoints as it clogs up memory with unnecessary state updates - ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) + ([e1c0f5c](https://github.com/team-spotube/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) - replace download multiple pops and add translations - ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) + ([4a21249](https://github.com/team-spotube/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) - screen breakpoints and persist lyrics delay across screens - ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) + ([df79638](https://github.com/team-spotube/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) - sidebar task counter badge and bottom player play button progress color - ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) + ([af278d8](https://github.com/team-spotube/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) - status bar color of playlist/album page - ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) + ([65fa3cb](https://github.com/team-spotube/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) - system color scheme not persisting on restart when system color scheme changed - ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) + ([e04515d](https://github.com/team-spotube/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) - track collection view status bar not transparent - ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) + ([9251121](https://github.com/team-spotube/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) - track doesn't play after change - ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) + ([17e5ab6](https://github.com/team-spotube/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) - track stops at last second - ([f554f6d](https://github.com/KRTirtho/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) + ([f554f6d](https://github.com/team-spotube/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) - **track_collection_view:** keyboard focus on scroll and no space for search results in playlist/album - ([7a8bd92](https://github.com/KRTirtho/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) + ([7a8bd92](https://github.com/team-spotube/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) - track_table_view table headers - ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) + ([d88d287](https://github.com/team-spotube/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) - track_tile active and blacklist color, playbutton card action positioning - ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) + ([3f5a1b9](https://github.com/team-spotube/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) - use id based source getters instead of index - ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) + ([a074463](https://github.com/team-spotube/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) -### [2.7.1](https://github.com/KRTirtho/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) +### [2.7.1](https://github.com/team-spotube/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) ### Bug Fixes - fallback for lyrics when anonymous - ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) + ([f160ec7](https://github.com/team-spotube/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) - **android:** audio notification stuck in play state - ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) + ([448c9b3](https://github.com/team-spotube/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) - **macos:** crashing on startup - ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) -- spotify query hooks overriding default query params - ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) + ([c46b428](https://github.com/team-spotube/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) +- Greedy Green Corp query hooks overriding default query params + ([ec9a02e](https://github.com/team-spotube/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) -## [2.7.0](https://github.com/KRTirtho/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) +## [2.7.0](https://github.com/team-spotube/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) ### Features - add or remove track, playlist or album to queue support - ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) + ([b8f3493](https://github.com/team-spotube/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) - basic command line argument support - ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) + ([025c1ae](https://github.com/team-spotube/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) - black list artist or track - ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) + ([947c143](https://github.com/team-spotube/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) - bring pre download on desktop, disable pre download for long videos - ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) + ([1d82bb0](https://github.com/team-spotube/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) - category/genre filter - ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) + ([1dfec05](https://github.com/team-spotube/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) - centralized icon collection with new icon set and nav bar labels hidden - ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) + ([e7acb9e](https://github.com/team-spotube/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) - compact search bar for genres and user_local_tracks page - ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) + ([c343ccc](https://github.com/team-spotube/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) - compatibility with fl-query nextPage method change - ([7617439](https://github.com/KRTirtho/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) + ([7617439](https://github.com/team-spotube/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) - configure pocketbase, generate dart types, update playback to use server instead of hive cache - ([ad90c11](https://github.com/KRTirtho/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) + ([ad90c11](https://github.com/team-spotube/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) - failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions - ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) + ([d68d150](https://github.com/team-spotube/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) - **home:** personalized section - ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) + ([9080441](https://github.com/team-spotube/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) - individual shuffle and repeat/loop button of player - ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) -- **lyrics:** use official spotify API for fetching lyrics and add zoom controls - ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) + ([f79223c](https://github.com/team-spotube/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) +- **lyrics:** use official Greedy Green Corp API for fetching lyrics and add zoom controls + ([10d0660](https://github.com/team-spotube/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) - **mobile:** pull to refresh support in all refreshable list views - ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) + ([9f959ce](https://github.com/team-spotube/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) - new logo and compact search in playlist/album in mobile - ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) + ([dc96cb3](https://github.com/team-spotube/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) - search/filter tracks inside playlist or album - ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) + ([a06cd0d](https://github.com/team-spotube/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) - show snackbar on adding playlist or tracks to queue - ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) + ([6bc1d32](https://github.com/team-spotube/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) - **theme:** use material3 monet for colors and remove background color preference - ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) + ([60ede5f](https://github.com/team-spotube/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) - use catcher to handle exceptions - ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) + ([84d94b0](https://github.com/team-spotube/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) - use typed assets instead of hard coded paths - ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) + ([59561ab](https://github.com/team-spotube/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) - user local tracks searchbar - ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) + ([e7f3f4e](https://github.com/team-spotube/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) - **user-library:** filtering support for user albums and user artists - ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) + ([0b58155](https://github.com/team-spotube/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) - **user-library:** search for user playlists - ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) + ([af4d56f](https://github.com/team-spotube/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) ### Bug Fixes - **about:** license text hidden in the bottom of smaller screen devices - ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) + ([e158dd0](https://github.com/team-spotube/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) - **about:** wrong link of License - ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) + ([a4a7f1a](https://github.com/team-spotube/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) - genre and sidebar user logo not loading - ([710f172](https://github.com/KRTirtho/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) + ([710f172](https://github.com/team-spotube/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) - lyrics modal sheet out of safe area so use 80% of screen height instead of full - ([3db28f4](https://github.com/KRTirtho/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) + ([3db28f4](https://github.com/team-spotube/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) - lyrics not changing on track change - ([c809d2d](https://github.com/KRTirtho/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) + ([c809d2d](https://github.com/team-spotube/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) - lyrics not refetching when tracked changed while being in another page and sidebar user avatar not showing on startup - ([bd12675](https://github.com/KRTirtho/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) + ([bd12675](https://github.com/team-spotube/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) - macOS logo placement - ([c6a5d5f](https://github.com/KRTirtho/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) + ([c6a5d5f](https://github.com/team-spotube/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) - mobile track collection search bar position and page_window_title_bar exception on mobile platforms - ([d0aaa97](https://github.com/KRTirtho/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) + ([d0aaa97](https://github.com/team-spotube/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) - **play_overlay:** show progress indicator on song loading - ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) + ([7803a48](https://github.com/team-spotube/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) - **playback:** not skipping track's sponsorblock segments - ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) + ([60a5847](https://github.com/team-spotube/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) - playbutton card play state not changing - ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) + ([ee46d09](https://github.com/team-spotube/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) - **playbutton_card:** play and add to queue needs 2 clicks work - ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) + ([bdd7098](https://github.com/team-spotube/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) - **playbutton_card:** play and non play state correction - ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) + ([b327ffb](https://github.com/team-spotube/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) - **playbutton_card:** title text overflow - ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) + ([39ee0a9](https://github.com/team-spotube/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) - **playbutton:** playing state is not updating when playlist is actually playing - ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) + ([9bad8c9](https://github.com/team-spotube/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) - **player_queue:** large clear button and macos exception - ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) + ([0e43504](https://github.com/team-spotube/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) - **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks - ([42b3e11](https://github.com/KRTirtho/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) + ([42b3e11](https://github.com/team-spotube/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) - pre downloading not working properly, audio service circular deps and sibling not loading for backend track - ([3ccb525](https://github.com/KRTirtho/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) + ([3ccb525](https://github.com/team-spotube/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) - search track play button isn't working - ([0751f5e](https://github.com/KRTirtho/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) + ([0751f5e](https://github.com/team-spotube/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) - **search:** grey screen, only tracks update on new search string, playlists,albums,artists show up before hitting return/submit - ([a774817](https://github.com/KRTirtho/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) + ([a774817](https://github.com/team-spotube/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) - **search:** has to submit twice for search results - ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) + ([f5dc76a](https://github.com/team-spotube/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) - titlebar maximize+restore button not working and less responsive title bar buttons - ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) + ([8a6ba3b](https://github.com/team-spotube/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) - **track_collection_view:** hide search bar when sliver is collapsed - ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) + ([3d6d244](https://github.com/team-spotube/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) - **track_tile:** cannot see track index above 99 - ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) + ([78b3273](https://github.com/team-spotube/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) - **track_tile:** track action popup not showing on narrow screens - ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) + ([0c54f2d](https://github.com/team-spotube/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) - **ui:** scaffold exception in fluent_ui - ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) + ([8ce2192](https://github.com/team-spotube/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) - use chosen market for new release - ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) + ([c6bf9b6](https://github.com/team-spotube/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) -## [2.6.0](https://github.com/KRTirtho/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) +## [2.6.0](https://github.com/team-spotube/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) ### Features - add selected tracks to playlists, optimistic playlist remove track - ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) + ([3386dac](https://github.com/team-spotube/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) - added shuffle button in playlist and album section - ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) + ([1fad95f](https://github.com/team-spotube/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) - **android-playback:** option to download track bytes and play instead of Streaming - ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) + ([dcc8ba5](https://github.com/team-spotube/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) - change default platform option and platform specific back button - ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) + ([36c5e02](https://github.com/team-spotube/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) - dialog logo for macos, settings more width for country picker - ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) + ([5e96913](https://github.com/team-spotube/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) - initial platform_ui integration - ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) + ([9eee573](https://github.com/team-spotube/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) - libadwaita theming, track tile and PlayButtonCard play button icon fix - ([e795e23](https://github.com/KRTirtho/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) + ([e795e23](https://github.com/team-spotube/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) - **lyrics:** tabs for both synced and static lyrics - [#182](https://github.com/KRTirtho/spotube/issues/182) - ([6b6907a](https://github.com/KRTirtho/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) + [#182](https://github.com/team-spotube/spotube/issues/182) + ([6b6907a](https://github.com/team-spotube/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) - new refined about page, update checker only check for same update channel - ([4cadfa9](https://github.com/KRTirtho/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) + ([4cadfa9](https://github.com/team-spotube/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) - pause track when seeking forward/back and keep audio session alive when paused/interrupted - ([bc8a04e](https://github.com/KRTirtho/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) + ([bc8a04e](https://github.com/team-spotube/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) - platform bottom navigation bar add - ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) + ([ff14469](https://github.com/team-spotube/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) - platform slider and progress indicator integration - ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) + ([46b00ba](https://github.com/team-spotube/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) - platform title bar buttons add - ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) + ([54048cb](https://github.com/team-spotube/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) - **playback:** change current track youtube source panel and tooltips for player icon buttons - ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) + ([4b21cc8](https://github.com/team-spotube/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) - Player and Playbutton theme respect to platform - ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) + ([512446d](https://github.com/team-spotube/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) - player queue and sibling tracks platform decoration - ([39a7794](https://github.com/KRTirtho/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) + ([39a7794](https://github.com/team-spotube/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) - **PlayerView:** shortcut button for opening lyrics - [#273](https://github.com/KRTirtho/spotube/issues/273) - ([1d4847a](https://github.com/KRTirtho/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) + [#273](https://github.com/team-spotube/spotube/issues/273) + ([1d4847a](https://github.com/team-spotube/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) - rename files to snake_case and reorganize folder structure - ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) + ([7c25e1c](https://github.com/team-spotube/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) - replace all types of buttons with platform buttons - ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) + ([69739b4](https://github.com/team-spotube/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) - rpm packaging support - ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) + ([067e9ac](https://github.com/team-spotube/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) - **search:** infinite scroll for tracks, artists, playlists and albums - ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) + ([e6761a6](https://github.com/team-spotube/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) - set platform to default platform on start up - ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) + ([472da6b](https://github.com/team-spotube/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) - shuffle keep playing track at top, linux title bar drag no working - ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) + ([1223cf2](https://github.com/team-spotube/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) - sidebar download count and proper progress color in playbutton - ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) + ([a10bc5b](https://github.com/team-spotube/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) - static shimmer for track tile, playbutton card and track tile - ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) + ([3ed8b0f](https://github.com/team-spotube/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) - tablet mode navigation bar & windows semi transparent bg, - ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) + ([3282370](https://github.com/team-spotube/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) - **title_bar:** platform specific title bar - ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) + ([e659e3c](https://github.com/team-spotube/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) - titlebar complete compatibility, platform specific login, library tabbar in titlebar - ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) + ([b3c27d1](https://github.com/team-spotube/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) - use platform checkbox - ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) + ([2211505](https://github.com/team-spotube/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) - window blur effect add - ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) + ([b0db5e7](https://github.com/team-spotube/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) ### Bug Fixes - **ArtistCard:** linux shadow - ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) + ([c186881](https://github.com/team-spotube/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) - **auth:** refresh access token timer not working - ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) + ([b3ac5ca](https://github.com/team-spotube/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) - bottom navigation bar settings tile not active when selected - ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) + ([43557e4](https://github.com/team-spotube/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) - dialog logo in android, lyrics visible timer adjust button - ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) + ([3c6803b](https://github.com/team-spotube/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) - heart button showing when not logged in, wrong login redirect - ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) + ([4dc26af](https://github.com/team-spotube/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) - horizontal infinite lists doesn't fill the screen - ([69995be](https://github.com/KRTirtho/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) + ([69995be](https://github.com/team-spotube/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) - ios dialog action buttons, local tracks crashing app, shimmer color and android wrong status bar color - ([90c1200](https://github.com/KRTirtho/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) + ([90c1200](https://github.com/team-spotube/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) - **login:** not working in android in Brazil or Ukraine regions - ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) + ([0b79a11](https://github.com/team-spotube/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) - **macos:** black text in dark mode - ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) + ([fb9c0e4](https://github.com/team-spotube/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) - **macos:** white text color in dark mode, text field white background - ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) + ([e086b52](https://github.com/team-spotube/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) - **mobile:** SafeArea bugs and back button color - ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) + ([a8330ef](https://github.com/team-spotube/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) - null exception in themes - ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) + ([9465d92](https://github.com/team-spotube/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) - platform_ui local path - ([00d0d38](https://github.com/KRTirtho/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) + ([00d0d38](https://github.com/team-spotube/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) - player view artist link when local playlist is playing, lyric delay adjust button alignment - ([ee5c417](https://github.com/KRTirtho/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) + ([ee5c417](https://github.com/team-spotube/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) - remove windows background - ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) + ([6942964](https://github.com/team-spotube/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) - search field ios dark icon , lyrics tabbar ios background color - ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) + ([be56ad4](https://github.com/team-spotube/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) - settings Title alignment and play button card ripple effect in other platforms - ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) + ([3b6bf27](https://github.com/team-spotube/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) - shuffle play logic - ([65cad07](https://github.com/KRTirtho/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) + ([65cad07](https://github.com/team-spotube/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) - small minwidth of window in desktop, linux wrong light theme accent color, search field transparent background - ([5b0e22c](https://github.com/KRTirtho/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) + ([5b0e22c](https://github.com/team-spotube/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) - tooltips of menu and adaptive pop up menu - ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) + ([261aaf1](https://github.com/team-spotube/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) - update download dialog blocking the UI - ([3925f74](https://github.com/KRTirtho/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) + ([3925f74](https://github.com/team-spotube/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) - user playlists not updating after creating/deleting, artist follow not updating after follow/unfollow - ([6cc2a18](https://github.com/KRTirtho/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) + ([6cc2a18](https://github.com/team-spotube/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) - **windows:** windows global title bar - ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) + ([bd18f19](https://github.com/team-spotube/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) -## [2.5.0](https://github.com/KRTirtho/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) +## [2.5.0](https://github.com/team-spotube/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) ### Features - animated transition of root PageWindowTitleBar - ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) + ([ff35e06](https://github.com/team-spotube/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) - **auth:** new authentication flow using cookies and webview in android - ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) + ([756b910](https://github.com/team-spotube/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) - **downloader:** replace /skip all choice for downloaded tracks - ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) + ([88d7ce5](https://github.com/team-spotube/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) - implemented go_route shell/nested route - ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) + ([3e498a4](https://github.com/team-spotube/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) - **keyboard shortcuts:** play/pause on space, seek position on left/right - ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) + ([2734454](https://github.com/team-spotube/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) - **keyboard-shortcuts:** home sidebar tab navigation and close app - ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) + ([8f258e7](https://github.com/team-spotube/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) - smoother list using fl_query and waypoint - ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) + ([c77b0e1](https://github.com/team-spotube/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) - sort tracks in playlist, album and local tracks - ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) + ([cb4bd25](https://github.com/team-spotube/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) - use of smaller sized images in `TrackTile` - ([0ca97b4](https://github.com/KRTirtho/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) + ([0ca97b4](https://github.com/team-spotube/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) - volume slider mouse scroll and preference for Rotating Album Art - [#255](https://github.com/KRTirtho/spotube/issues/255) - ([edb6f3c](https://github.com/KRTirtho/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) + [#255](https://github.com/team-spotube/spotube/issues/255) + ([edb6f3c](https://github.com/team-spotube/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) ### Bug Fixes - **android:** file_picker and permission_handler failure for sdk < 33 - ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) + ([139d4dc](https://github.com/team-spotube/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) - cached local track is fetched from network - ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) + ([abf4a57](https://github.com/team-spotube/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) - categories not showing for oauth exception - ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) + ([4df917e](https://github.com/team-spotube/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) - **desktop:** maximized window size is stored and window maximized state doesn't persist - ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) + ([91d5d10](https://github.com/team-spotube/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) - local audio doesn't get refreshed after getting permission - ([618c6da](https://github.com/KRTirtho/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) + ([618c6da](https://github.com/team-spotube/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) - no appropriate output when playlist is empty - [#201](https://github.com/KRTirtho/spotube/issues/201) - ([dbb81de](https://github.com/KRTirtho/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) + [#201](https://github.com/team-spotube/spotube/issues/201) + ([dbb81de](https://github.com/team-spotube/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) - PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView - ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) + ([0ebac05](https://github.com/team-spotube/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) - **web:** not building due to metadata_god ffi - ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) + ([1191bf2](https://github.com/team-spotube/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) -## [2.4.1](https://github.com/KRTirtho/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) +## [2.4.1](https://github.com/team-spotube/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) ### Features - add macos audio metadata tags support - ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) + ([5866b0f](https://github.com/team-spotube/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) - remove macos bounds for reading and writing audio metadata - ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) + ([16064f6](https://github.com/team-spotube/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) - **search:** horizontal swipe scroll support for Desktop platform - ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) + ([d5ff927](https://github.com/team-spotube/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) ### Bug Fixes - **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app - ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) + ([4279541](https://github.com/team-spotube/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) - **layout:** Fix adaptive UI not working correctly by providing a overriding option - ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) + ([8c7adde](https://github.com/team-spotube/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) - **local-track:** throwing exception when downloadLocation is empty - ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) + ([1a3556d](https://github.com/team-spotube/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) -## [2.4.0](https://github.com/KRTirtho/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) +## [2.4.0](https://github.com/team-spotube/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) ### Features - Ability to change download location added - ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) + ([816707c](https://github.com/team-spotube/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) - add download multi tracks support for mobile platform - ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) + ([0476bf7](https://github.com/team-spotube/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) - add download queue for desktop & initial playlist download support - ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) + ([08f913e](https://github.com/team-spotube/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) - add download tab on library - ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) + ([8d77b69](https://github.com/team-spotube/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) - add web support although nothing works just as expected - ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) + ([2818ed5](https://github.com/team-spotube/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) - **broken:** Broken Warning! Initial Local Audio Player - ([c3bf511](https://github.com/KRTirtho/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) + ([c3bf511](https://github.com/team-spotube/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) - **download:** track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog - ([e217553](https://github.com/KRTirtho/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) + ([e217553](https://github.com/team-spotube/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) - **local-tracks:** complete support for local tracks - ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) + ([e206f16](https://github.com/team-spotube/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) - **mpris:** MPRIS metadata are now updated in realtime - ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) + ([d9addcd](https://github.com/team-spotube/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) - **playback:** add repeat track support - [#166](https://github.com/KRTirtho/spotube/issues/166) - ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) + [#166](https://github.com/team-spotube/spotube/issues/166) + ([cae9993](https://github.com/team-spotube/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) - **synced-lyrics:** animated active text size - ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) + ([531fae6](https://github.com/team-spotube/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) - **ui:** adaptive TrackTile actions & Setting ListTile - ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) + ([615d5ce](https://github.com/team-spotube/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) ### Bug Fixes - **adaptive-list-tile:** dialog content not updating when content has changed - ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) + ([a1d4230](https://github.com/team-spotube/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) - album & playlist card, player view and album view play button logic - ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) + ([55852bd](https://github.com/team-spotube/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) - **docs:** indentions - ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) + ([4a291d5](https://github.com/team-spotube/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) - **downloader:** downloaded track is corrupted for tagging - ([2ab1fba](https://github.com/KRTirtho/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) + ([2ab1fba](https://github.com/team-spotube/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) - **downloader:** flutter downloader exception on desktop platform and too much width of TrackTile index no. - ([d668760](https://github.com/KRTirtho/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) + ([d668760](https://github.com/team-spotube/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) - dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous - ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) + ([307a8e2](https://github.com/team-spotube/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) - flutter_downloader manifest configuration breaking android support - ([f3a0f78](https://github.com/KRTirtho/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) + ([f3a0f78](https://github.com/team-spotube/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) - login screen not using safearea and no dialog bg-color found on light mode in AdaptivePopupMenuButton - ([92bc611](https://github.com/KRTirtho/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) + ([92bc611](https://github.com/team-spotube/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) - **performance:** always running marquee text causes high GPU usage - [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow + [#175](https://github.com/team-spotube/spotube/issues/175) and UserArtist overflow on smaller displays - ([a23ce61](https://github.com/KRTirtho/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) + ([a23ce61](https://github.com/team-spotube/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) - **playback:** shuffle button sometimes gets stuck and stops working - [#183](https://github.com/KRTirtho/spotube/issues/183) - ([4240433](https://github.com/KRTirtho/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) + [#183](https://github.com/team-spotube/spotube/issues/183) + ([4240433](https://github.com/team-spotube/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) - **player-overlay:** flickering when a track is changed or navigated to another page - ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) + ([e48b67c](https://github.com/team-spotube/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) - **sidebar:** user image url - ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) + ([747efc6](https://github.com/team-spotube/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) - **synced-lyrics:** active lyrics contrast ratio - ([aba1ba9](https://github.com/KRTirtho/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) + ([aba1ba9](https://github.com/team-spotube/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) - tabbar overflow in small screen, artist card too small title and synced lyrics contrast increased - ([585de8c](https://github.com/KRTirtho/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) + ([585de8c](https://github.com/team-spotube/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) # v2.3.0 ### New +- Playback Cache Support. So unfinished playlist and tracks remains cached & + starts automatically when application is launched again - Playback Cache Support. So unfinished playlist and tracks remains cached & starts automatically when application is launched again - Login Screen guided tutorial about how to obtain Client ID & Client Secret +- Signed Android Application so now longer need to uninstall the old version for + installing the new one - Signed Android Application so now longer need to uninstall the old version for installing the new one - OS Media controls for Linux. Keyboard media keys now work in Linux - New better, consistent & predictable Audio engine with proper event firing - support (https://github.com/KRTirtho/spotube/pull/131) + support (https://github.com/team-spotube/spotube/pull/131) - Custom Lyrics delay time. Can be used to delay negative amount of time too - Playback Queue View support. Currently playing tracks or playlist can be viewed or changed from it or for doing other actions too - (https://github.com/KRTirtho/spotube/issues/126) + (https://github.com/team-spotube/spotube/issues/126) - Android SeekBar support in Notification Panel & Lock Screen - New Blur background design adapted to multiple components including Floating Player, Player View & Lyrics Tab - New HighContrast Color Scheme addition which reduces battery consumption on OLED or AMOLED display devices - (https://github.com/KRTirtho/spotube/issues/137) + (https://github.com/team-spotube/spotube/issues/137) ### Improved - Loading screens & animations. Now uses Skeleton Loading +- Playlist & Album Pages now show Album Art & extra metadata as Header with + vibrant gradient background in a Sliver +- Playback is now more consistent & the API is simpler. Also its the single + source of truth for AudioPlayback instead of the AudioServiceHandler - Playlist & Album Pages now show Album Art & extra metadata as Header with vibrant gradient background in a Sliver - Playback is now more consistent & the API is simpler. Also its the single @@ -1686,6 +1865,10 @@ in Desktop edition - Track match Cache support for previously played tracks. This dramatically reduces track change latency & load on the YouTube search engine too +- Home Genre playlists can be scrolled horizontally by dragging with mouse even + in Desktop edition +- Track match Cache support for previously played tracks. This dramatically + reduces track change latency & load on the YouTube search engine too ### Bug Fixes @@ -1693,9 +1876,9 @@ - Player doesn't stop when Application is exits or closed - First Track of Playlist doesn't load sometimes - Download Button doesn't show done symbol when track is already saved - (https://github.com/KRTirtho/spotube/issues/138) + (https://github.com/team-spotube/spotube/issues/138) - Downloaded Music is 0kb sized when lyrics are downloaded alongside - (https://github.com/KRTirtho/spotube/issues/122) + (https://github.com/team-spotube/spotube/issues/122) # v2.2.1 @@ -1717,7 +1900,7 @@ - Update checker - Share options for playlists & track - Android Skip to Next/Previous track from notification/lockscreen - (https://github.com/KRTirtho/spotube/issues/91) + (https://github.com/team-spotube/spotube/issues/91) - Custom Accent Color Scheme support (Dark + Light) - Custom Background Color Scheme support (Dark + Light) - User customizable Audio Quality Option @@ -1726,7 +1909,7 @@ - Caching in Playlists, Album, Search, Playlist Categories, Artist Profile & Lyrics - M1 Mac support via MacOS Universal Binary (untested) - (https://github.com/KRTirtho/spotube/pull/87) + (https://github.com/team-spotube/spotube/pull/87) ### Improved @@ -1737,8 +1920,8 @@ ### Bug fixes - private playlists of current user aren't shown fix - (https://github.com/KRTirtho/spotube/issues/92) -- refresh token error causing re-login (culprit: internal lib spotify-dart) + (https://github.com/team-spotube/spotube/issues/92) +- refresh token error causing re-login (culprit: internal lib Greedy Green Corp-dart) - Typo in Login instructions URL # v2.1.0 @@ -1754,28 +1937,28 @@ ### Improved -- Spotify track to youtube track algorithm +- Greedy Green Corp track to youtube track algorithm - Genius lyrics matching algorithm - Download track. Checks if already exists & replaces on user command - Wide screen responsiveness & adaptation - Bigger Title display (replaced word-break with Marquee Text for better - visibility) (https://github.com/KRTirtho/spotube/pull/47) + visibility) (https://github.com/team-spotube/spotube/pull/47) ### Bug fixes - Sequential playlist playback not working with latest webkit2gtk - (https://github.com/KRTirtho/spotube/issues/46) + (https://github.com/team-spotube/spotube/issues/46) - Theme modification state doesn't persist - (https://github.com/KRTirtho/spotube/issues/54) -- Wrong URI path for "Login with Spotify" tutorial - (https://github.com/KRTirtho/spotube/issues/69) + (https://github.com/team-spotube/spotube/issues/54) +- Wrong URI path for "Login with Greedy Green Corp" tutorial + (https://github.com/team-spotube/spotube/issues/69) - Card shadow showing in the background of TitleBar & Searchbar # v2.0.0 ### New -- Android Support https://github.com/KRTirtho/spotube/issues/24 +- Android Support https://github.com/team-spotube/spotube/issues/24 - Responsive UI (Mobile, Tablet) - Anonymous/Guest Account - Mini floating player @@ -1790,6 +1973,8 @@ - Search - now scrolls & paginates for Playlists & Albums - Authentication - allows guest accounts making authentication optional +- Lyrics - can be fetched without requiring GeniusAccessToken. This makes + geniusAccessToken optional - Lyrics - can be fetched without requiring GeniusAccessToken. This makes geniusAccessToken optional - UI snappiness & faster load times @@ -1798,7 +1983,7 @@ ### Bug fixes -- Can't play any song in macos https://github.com/KRTirtho/spotube/issues/23 +- Can't play any song in macos https://github.com/team-spotube/spotube/issues/23 - Downloaded tracks can't be played as they're WebAudio (.weba) instead of MP3 - delay while changing Playlist/Single tracks @@ -1806,6 +1991,8 @@ ### New +- Global custom reconfigurable _hotkey_ support for playback controls + (play-pause/next/previous) - Global custom reconfigurable _hotkey_ support for playback controls (play-pause/next/previous) - Credit section in the Settings page with important links @@ -1819,9 +2006,9 @@ ### Bug fixes - broken authentication IPC on Mac OS - (https://github.com/KRTirtho/spotube/pull/18) + (https://github.com/team-spotube/spotube/pull/18) - Mac OS's global appmenu's default APP_NAME replaced with Spotube -- location of back button on macOS (https://github.com/KRTirtho/spotube/pull/21) +- location of back button on macOS (https://github.com/team-spotube/spotube/pull/21) - windows titlebar buttons appears on Mac OS - genius access_token not loading on initial app start @@ -1829,7 +2016,7 @@ ### New -- MacOS support https://github.com/KRTirtho/spotube/pull/7 +- MacOS support https://github.com/team-spotube/spotube/pull/7 - Download currently playing track to `/home//Downloads/Spotube` (Linux, MacOS) or `C:\Users\\Downloads\Spotube` (Windows) - Play playlist from any song (index) instead of only the first track @@ -1853,6 +2040,10 @@ ### Bug fixes +- Unsafe access to album art/artist/user Images with `.first` or `.last` causing + accessing empty List error +- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain + \*nix OSs - Unsafe access to album art/artist/user Images with `.first` or `.last` causing accessing empty List error - `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain @@ -1876,6 +2067,8 @@ - Complete re-write in Flutter/Dart (799e13c) - mpv & youtube-dl runtime dependencies dropped (07b1891) +- just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based + playback & streaming - just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based playback & streaming - lyrics are provided by genius.com (requires access_token) (d647d5e) @@ -1893,6 +2086,8 @@ ### New +- Automated installer for Windows (now doesn't require manual mpv-player + install) - Automated installer for Windows (now doesn't require manual mpv-player install) - Playback caching @@ -1902,11 +2097,15 @@ ### Improved +- Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search + page - Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search page ### Bug fixes +- Cached image didn't get deleted after exiting certain cache limit fix. Cache + gets recreated after exiting the limit - Cached image didn't get deleted after exiting certain cache limit fix. Cache gets recreated after exiting the limit @@ -1925,13 +2124,17 @@ ### Bug Fixes -- `access_token not found` Error after OAuth Login with Spotify credentials +- `access_token not found` Error after OAuth Login with Greedy Green Corp credentials + (used to need a restart of the app to load the access_token) +- `access_token not found` Error after OAuth Login with Greedy Green Corp credentials (used to need a restart of the app to load the access_token) - Volume level wasn't cached even after changing volume # v0.0.1 -Spotube v0.0.1 - initial release of the open source software for playing Spotify +Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp +music using Youtube public API +Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp music using Youtube public API ### New @@ -1940,5 +2143,7 @@ music using Youtube public API - Playback Queue - Save to Liked Tracks/Playlists - Bypass API rate limitation on basic usage using personal developer Apps for - spotify API + Greedy Green Corp API +- Bypass API rate limitation on basic usage using personal developer Apps for + Greedy Green Corp API - Youtube search & get handled using scrape-yt diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index db77a5f1..94f053dd 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -30,19 +30,19 @@ All types of contributions are encouraged and valued. See the [Table of Contents ## Code of Conduct This project and everyone participating in it is governed by the -[Spotube Code of Conduct](https://github.com/KRTirtho/spotube/blob/master/CODE_OF_CONDUCT.md). +[Spotube Code of Conduct](https://github.com/team-spotube/spotube/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior -to krtirtho@gmail.com. +to team.spotube@proton.me. ## I Have a Question -> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/KRTirtho/spotube#readme). +> If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/team-spotube/spotube#readme). -Before you ask a question, it is best to search for existing [Issues](https://github.com/KRTirtho/spotube/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. +Before you ask a question, it is best to search for existing [Issues](https://github.com/team-spotube/spotube/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. If you then still feel the need to ask a question and need clarification, we recommend the following: -- Open an [Discussion](https://github.com/KRTirtho/spotube/discussions/new) with the question label. +- Open an [Discussion](https://github.com/team-spotube/spotube/discussions/new) with the question label. - Provide as much context as you can about what you're running into. - Provide project and platform versions (flutter, dart, pub, linux etc..), depending on what seems relevant. @@ -61,8 +61,8 @@ We will then take care of the issue as soon as possible. A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. - Make sure that you are using the latest version. -- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/KRTirtho/spotube#readme). If you are looking for support, you might want to check [this section](#i-have-a-question)). -- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/KRTirtho/spotubeissues?q=label%3Abug). +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/team-spotube/spotube#readme). If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/team-spotube/spotube/issues?q=label%3Abug). - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. - Collect information about the bug: - Stack trace (Traceback) @@ -77,7 +77,7 @@ A good bug report shouldn't leave others needing to chase you up for more inform We use GitHub issues to track bugs and errors. If you run into an issue with the project: -- Open an [Issue](https://github.com/KRTirtho/spotube/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) +- Open an [Issue](https://github.com/team-spotube/spotube/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) - Explain the behavior you would expect and the actual behavior. - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. - Provide the information you collected in the previous section. @@ -97,15 +97,15 @@ This section guides you through submitting an enhancement suggestion for Spotube #### Before Submitting an Enhancement - Make sure that you are using the latest version. -- Read the [documentation](https://github.com/KRTirtho/spotube#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration. -- Perform a [search](https://github.com/KRTirtho/spotube/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +- Read the [documentation](https://github.com/team-spotube/spotube#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration. +- Perform a [search](https://github.com/team-spotube/spotube/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset #### How Do I Submit a Good Enhancement Suggestion? -Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirtho/spotube/issues). +Enhancement suggestions are tracked as [GitHub issues](https://github.com/team-spotube/spotube/issues). - Use a **clear and descriptive title** for the issue to identify the suggestion. - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 274abdc8..e00abc7c 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -59,4 +59,4 @@ This policy is effective as of 2022-04-01 **Contact Us** -If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at krtirtho@gmail.com. \ No newline at end of file +If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at team.spotube@proton.me \ No newline at end of file diff --git a/README.md b/README.md index 129645d7..c65c9e2a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Spotube Logo A cross-platform extensible open-source music streaming platform.
-Bring your own music metadata/playlist/audio-source with plugins created by community or by yourself. A small step towards the decentralized music streaming era! +Bring your own music metadata/playlist/audio-source with plugins created by community or by +yourself. A small step towards the decentralized music streaming era! Visit the website Discord Server @@ -16,17 +17,16 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm --- -![Spotube Desktop](assets/branding/spotube-screenshot.png) +![Spotube Desktop](assets/feature-graphics/readme-hero.png) +![Spotube Mobile](assets/feature-graphics/readme-mobile.png) -![Spotube Mobile](assets/branding/mobile-screenshots/combined.jpg) - - +**** ## 🌃 Features - 🧩 Plugin powered, supports any platform or custom music service through plugins. - 🗺️ Community driven plugins for popular platforms or create your own. -- ⬇️ Freely downloadable tracks with tagged metadata. +- ⬇️ Freely downloadable tracks with tagged **metadata**. - 🖥️ 📱 Cross-platform support. - 🪶 Small size & less data usage. - 🕒 Time synced lyrics regardless of the plugin support. @@ -41,17 +41,187 @@ Download Spotube from the Official Website: https://spotube.cc ### 🔄 Nightly Builds -Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). +Grab the latest nightly builds of +Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). -## 🕳️ Building from source +
+ +

🕳️ Building from source [Click to expand]

+
+
+GitHub Workflow Status -GitHub Workflow Status +Spotube is a Compose Multiplatform app. -You can compile Spotube's source code by [following these instructions](CONTRIBUTION.md#your-first-code-contribution). +### 1. Prerequisites + +- **JDK 21** to run Gradle (the Gradle daemon JDK and GraalVM are provisioned automatically). +- **Rust** (`rustup` + `cargo`), for the `composeApp` library built + through [Gobley](https://gobley.dev). +- **Android SDK**, only for Android builds. +- **[ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview)**, which isn't on + Maven Central yet. Clone it at the commit pinned as `COMPOSE_WEBVIEW_COMMIT` in [ + `spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) and run + `./gradlew publishToMavenLocal` in it. +- **Linux only: runtime libraries, including VLC.** Needed to run the app, including + `:composeApp:run`: + + | Distro | Command | + | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` | + | Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender` (`vlc-libs` needs [RPM Fusion](https://rpmfusion.org/Configuration)) | + | Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` | + | openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` | + + On Ubuntu 24.04+/Debian 13, `libgtk-3-0` and `libasound2` are `libgtk-3-0t64` and `libasound2t64`; + apt resolves this itself. + +### 2. Run in development + +Desktop (JVM), fastest edit/run loop +```bash +./gradlew :composeApp:run +``` + +Android debug APK +```bash +./gradlew :composeApp:assembleStableDebug +``` +> Use `./gradlew :composeApp:assembleNightlyDebug` for the nightly channel. + +Development builds stay fast: the release-only optimizations below are skipped. + +### 3. Build the desktop app + +A native build takes **about 10 minutes**, uses **every CPU core** and peaks at **~15 GB of RAM**. +It only happens for the tasks below, never for `run`. + +**Native toolchain** (Nucleus downloads GraalVM CE on the first build; set `GRAALVM_HOME` to use +your own): + +| OS | Requirements | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Linux | `gcc`, `zlib` headers, `patchelf`; `rpm` for `.rpm`, `fuse` + `zstd` for `.AppImage`.
Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`.
Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` | +| macOS | Xcode Command Line Tools (`xcode-select --install`) | +| Windows | Visual Studio Build Tools with "Desktop development with C++" (use an "x64 Native Tools" prompt if `native-image` can't find MSVC) | + +**Build and run the app folder:** + +```bash +./gradlew :composeApp:createGraalvmNativeDistributable +``` +> Output folder: composeApp/build/compose/binaries/main/graalvm-app/ + +Build and launch in one step +```bash +./gradlew :composeApp:runGraalvmNativeDistributable +``` + +Keep the `spotube` binary next to the libraries it was built with; it loads them from its own +folder. + +**Installers** (each OS builds only its own): + +| OS | Command | +| ---------------- | ----------------------------------------------------------------------- | +| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` | +| Linux (AppImage) | `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | +| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | +| macOS | `./gradlew :composeApp:packageGraalvmDmg` | +> Output folder: `composeApp/build/compose/binaries/main/graalvm-` + +> [!Tip] +> Add `-Pnightly=true` for the nightly channel (separate icons and name). + +
+How the Linux AppImage bundle works + +- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) + downloads the Ubuntu 24.04 packages (audio-only VLC via + [vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin), GTK 3, WebKitGTK 4.1, + glib-networking), keeps only what GTK/WebKit reach and relocates the libraries with `patchelf`. + Needs `ar`, `tar`, `zstd`, `readelf`, `patchelf`. +- WebKitGTK has its helper-process path compiled in, so + [`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt) + patches it to a relative path and moves to that directory at startup. It also sets + `GIO_EXTRA_MODULES` and `WEBKIT_DISABLE_DMABUF_RENDERER=1` (the bundled WebKitGTK 2.44 never + paints against a newer host Mesa). +- `main()` must call `GraalVmInitializer.initialize()` first: the VLC loader and the environment + setup read `compose.application.resources.dir`. + +
+ +### 4. Native build breaks, JVM works + +A native image is a closed world: everything reached through reflection, JNI or JNA (vlcj, Zipline, +NewPipe, ktor, datastore, ...) must be declared at build time. Nucleus generates most of it; the +rest lives in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). + +Symptoms: `ClassNotFoundException`, `NoSuchMethodException` or `MissingReflectionRegistrationError` +in the native binary, while `:composeApp:run` works. + +1. `./gradlew :composeApp:runWithNativeAgent` starts the app on the JVM with GraalVM's tracing agent. +2. Use the broken feature, then **close the window normally**. Ctrl+C discards the recording. +3. Commit the updated JSON in `composeApp/graalvm/` and rebuild. + +> [!Tip] +> `./gradlew :composeApp:runGraalvmNative` is a faster debug loop: a quick `-Ob` build that names the +> missing registration. + +> [!Warning] +> Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry, or playback fails to initialize. + +### 5. Production vs development builds + +Production mode turns on automatically for `package*`, `create*`, `notarize*` and +`nativeImageCompile` tasks. Force it with `-Pproduction=true|false`. + +| Area | Development | Production | +| ----------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Rust lib | fast `debug` profile | `release` with LTO and stripping (31 MB → 1.8 MB) | +| Desktop app | hot-reloadable JVM app | size-optimized (`-Os`) GraalVM native image | +| Native libs | all | host OS/CPU only; Skia loaded from `lib/`; on Linux ~10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's 28 MB one | + +Android picks the Rust profile from its build type. + +### 6. Releases + +Run [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) manually +(`workflow_dispatch`) as `stable`, `beta` or `nightly`. + +- **Beta:** the stable build as a draft, tagged `v-beta.N`. Set `betaNumber` in + `composeApp/build.gradle.kts` to N, and back to `null` for stable. +- **Desktop:** each platform (Linux x86_64 and aarch64, Windows, macOS) builds its installers on its + own runner. +- **Android:** a signed APK. + +
+
+ +## 📚 Resources + +**Core stack** +- [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) +- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) +- [Compose Hot Reload](https://github.com/JetBrains/compose-hot-reload) +- [Koin for KMP](https://insert-koin.io/docs/reference/koin-mp/kmp) +- [Ktor client](https://ktor.io/docs/client-create-multiplatform-application.html) +- [DataStore for KMP](https://developer.android.com/kotlin/multiplatform/datastore) + +**Desktop and native** +- [Nucleus](https://github.com/kdroidFilter/Nucleus): desktop packaging and GraalVM integration +- [ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview) +- [GraalVM Native Image](https://www.graalvm.org/latest/reference-manual/native-image/) and its + [reachability metadata](https://www.graalvm.org/latest/reference-manual/native-image/metadata/) +- [Gobley](https://gobley.dev): Rust in Kotlin Multiplatform +- [vlcj](https://github.com/caprica/vlcj): VLC bindings for audio playback + +**Plugins** +- [Zipline](https://github.com/cashapp/zipline): runs the JS plugins ## 👥 The Spotube team -- [Kingkor Roy Tirtho](https://github.com/KRTirtho) - The Founder, Maintainer and Lead Developer +- [Kingkor Roy Tirtho](https://github.com/team-spotube) - The Founder, Maintainer and Lead Developer - [Owen Connor](https://github.com/owencz1998) - The Cool Discord Moderator - [Piotr Rogowski](https://github.com/karniv00l) - The MacOS Developer - [Rusty Apple](https://github.com/RustyApple) - The Mysterious Unknown Guy diff --git a/assets/branding/mobile-screenshots/android-1.jpg b/assets/branding/mobile-screenshots/android-1.jpg deleted file mode 100644 index 16debbd7..00000000 Binary files a/assets/branding/mobile-screenshots/android-1.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-2.jpg b/assets/branding/mobile-screenshots/android-2.jpg deleted file mode 100644 index d594227e..00000000 Binary files a/assets/branding/mobile-screenshots/android-2.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-3.jpg b/assets/branding/mobile-screenshots/android-3.jpg deleted file mode 100644 index ed2d61f2..00000000 Binary files a/assets/branding/mobile-screenshots/android-3.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-4.jpg b/assets/branding/mobile-screenshots/android-4.jpg deleted file mode 100644 index 36a3549f..00000000 Binary files a/assets/branding/mobile-screenshots/android-4.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-5.jpg b/assets/branding/mobile-screenshots/android-5.jpg deleted file mode 100644 index 29be481b..00000000 Binary files a/assets/branding/mobile-screenshots/android-5.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/combined.jpg b/assets/branding/mobile-screenshots/combined.jpg deleted file mode 100644 index 03740580..00000000 Binary files a/assets/branding/mobile-screenshots/combined.jpg and /dev/null differ diff --git a/assets/branding/spotube-hero-banner.png b/assets/branding/spotube-hero-banner.png index c5309b92..d0975f88 100644 Binary files a/assets/branding/spotube-hero-banner.png and b/assets/branding/spotube-hero-banner.png differ diff --git a/assets/branding/spotube-screenshot.png b/assets/branding/spotube-screenshot.png deleted file mode 100644 index c708e240..00000000 Binary files a/assets/branding/spotube-screenshot.png and /dev/null differ diff --git a/assets/feature-graphics/play-feature-graphic.png b/assets/feature-graphics/play-feature-graphic.png new file mode 100644 index 00000000..42a5c41c Binary files /dev/null and b/assets/feature-graphics/play-feature-graphic.png differ diff --git a/assets/feature-graphics/readme-hero.png b/assets/feature-graphics/readme-hero.png new file mode 100644 index 00000000..c7807f01 Binary files /dev/null and b/assets/feature-graphics/readme-hero.png differ diff --git a/assets/feature-graphics/readme-mobile.png b/assets/feature-graphics/readme-mobile.png new file mode 100644 index 00000000..a4275e32 Binary files /dev/null and b/assets/feature-graphics/readme-mobile.png differ diff --git a/assets/feature-graphics/store-desktop-01-home.png b/assets/feature-graphics/store-desktop-01-home.png new file mode 100644 index 00000000..edc82da0 Binary files /dev/null and b/assets/feature-graphics/store-desktop-01-home.png differ diff --git a/assets/feature-graphics/store-desktop-02-lyrics.png b/assets/feature-graphics/store-desktop-02-lyrics.png new file mode 100644 index 00000000..ddce019b Binary files /dev/null and b/assets/feature-graphics/store-desktop-02-lyrics.png differ diff --git a/assets/feature-graphics/store-desktop-03-playlists.png b/assets/feature-graphics/store-desktop-03-playlists.png new file mode 100644 index 00000000..53c8d403 Binary files /dev/null and b/assets/feature-graphics/store-desktop-03-playlists.png differ diff --git a/assets/feature-graphics/store-desktop-04-search.png b/assets/feature-graphics/store-desktop-04-search.png new file mode 100644 index 00000000..bed9cf37 Binary files /dev/null and b/assets/feature-graphics/store-desktop-04-search.png differ diff --git a/assets/feature-graphics/store-desktop-05-local-tracks.png b/assets/feature-graphics/store-desktop-05-local-tracks.png new file mode 100644 index 00000000..648a515e Binary files /dev/null and b/assets/feature-graphics/store-desktop-05-local-tracks.png differ diff --git a/assets/feature-graphics/store-desktop-06-group-jam.png b/assets/feature-graphics/store-desktop-06-group-jam.png new file mode 100644 index 00000000..1178633c Binary files /dev/null and b/assets/feature-graphics/store-desktop-06-group-jam.png differ diff --git a/assets/feature-graphics/store-mobile-01-home.png b/assets/feature-graphics/store-mobile-01-home.png new file mode 100644 index 00000000..adbafe11 Binary files /dev/null and b/assets/feature-graphics/store-mobile-01-home.png differ diff --git a/assets/feature-graphics/store-mobile-02-player.png b/assets/feature-graphics/store-mobile-02-player.png new file mode 100644 index 00000000..e4e91416 Binary files /dev/null and b/assets/feature-graphics/store-mobile-02-player.png differ diff --git a/assets/feature-graphics/store-mobile-03-library.png b/assets/feature-graphics/store-mobile-03-library.png new file mode 100644 index 00000000..b488d8c9 Binary files /dev/null and b/assets/feature-graphics/store-mobile-03-library.png differ diff --git a/assets/feature-graphics/store-mobile-04-devices.png b/assets/feature-graphics/store-mobile-04-devices.png new file mode 100644 index 00000000..32dc9f3a Binary files /dev/null and b/assets/feature-graphics/store-mobile-04-devices.png differ diff --git a/assets/feature-graphics/store-mobile-05-group-jam.png b/assets/feature-graphics/store-mobile-05-group-jam.png new file mode 100644 index 00000000..3ad3c81a Binary files /dev/null and b/assets/feature-graphics/store-mobile-05-group-jam.png differ diff --git a/assets/images/album-placeholder.png b/assets/images/album-placeholder.png deleted file mode 100644 index bcb25969..00000000 Binary files a/assets/images/album-placeholder.png and /dev/null differ diff --git a/assets/images/bengali-patterns-bg.jpg b/assets/images/bengali-patterns-bg.jpg deleted file mode 100644 index a4090a01..00000000 Binary files a/assets/images/bengali-patterns-bg.jpg and /dev/null differ diff --git a/assets/images/logos/dab-music.png b/assets/images/logos/dab-music.png deleted file mode 100644 index e09d3410..00000000 Binary files a/assets/images/logos/dab-music.png and /dev/null differ diff --git a/assets/images/logos/invidious.jpg b/assets/images/logos/invidious.jpg deleted file mode 100644 index 3a54ace1..00000000 Binary files a/assets/images/logos/invidious.jpg and /dev/null differ diff --git a/assets/images/logos/jiosaavn.png b/assets/images/logos/jiosaavn.png deleted file mode 100644 index 4d2d46e4..00000000 Binary files a/assets/images/logos/jiosaavn.png and /dev/null differ diff --git a/assets/images/placeholder.png b/assets/images/placeholder.png deleted file mode 100644 index 6f6d451f..00000000 Binary files a/assets/images/placeholder.png and /dev/null differ diff --git a/assets/images/user-placeholder.png b/assets/images/user-placeholder.png deleted file mode 100644 index cb2325ff..00000000 Binary files a/assets/images/user-placeholder.png and /dev/null differ diff --git a/assets/screenshots/devices-desktop-dark.png b/assets/screenshots/devices-desktop-dark.png new file mode 100644 index 00000000..0b53b385 Binary files /dev/null and b/assets/screenshots/devices-desktop-dark.png differ diff --git a/assets/screenshots/devices-mobile-light.png b/assets/screenshots/devices-mobile-light.png new file mode 100644 index 00000000..8d27ee61 Binary files /dev/null and b/assets/screenshots/devices-mobile-light.png differ diff --git a/assets/screenshots/downloads-desktop-light.png b/assets/screenshots/downloads-desktop-light.png new file mode 100644 index 00000000..d96385e2 Binary files /dev/null and b/assets/screenshots/downloads-desktop-light.png differ diff --git a/assets/screenshots/group-jam-desktop-dark.png b/assets/screenshots/group-jam-desktop-dark.png new file mode 100644 index 00000000..14720aff Binary files /dev/null and b/assets/screenshots/group-jam-desktop-dark.png differ diff --git a/assets/screenshots/group-jam-mobile-light.png b/assets/screenshots/group-jam-mobile-light.png new file mode 100644 index 00000000..7e80e0fa Binary files /dev/null and b/assets/screenshots/group-jam-mobile-light.png differ diff --git a/assets/screenshots/home-desktop-dark.png b/assets/screenshots/home-desktop-dark.png new file mode 100644 index 00000000..4f3a8a0d Binary files /dev/null and b/assets/screenshots/home-desktop-dark.png differ diff --git a/assets/screenshots/home-desktop-light.png b/assets/screenshots/home-desktop-light.png new file mode 100644 index 00000000..253ac4da Binary files /dev/null and b/assets/screenshots/home-desktop-light.png differ diff --git a/assets/screenshots/home-mobile-dark.png b/assets/screenshots/home-mobile-dark.png new file mode 100644 index 00000000..91bbb132 Binary files /dev/null and b/assets/screenshots/home-mobile-dark.png differ diff --git a/assets/screenshots/home-mobile-light.png b/assets/screenshots/home-mobile-light.png new file mode 100644 index 00000000..4ee5e6c2 Binary files /dev/null and b/assets/screenshots/home-mobile-light.png differ diff --git a/assets/screenshots/home-player-dark.png b/assets/screenshots/home-player-dark.png new file mode 100644 index 00000000..651ec5bc Binary files /dev/null and b/assets/screenshots/home-player-dark.png differ diff --git a/assets/screenshots/home-player-light.png b/assets/screenshots/home-player-light.png new file mode 100644 index 00000000..2fcd6ae5 Binary files /dev/null and b/assets/screenshots/home-player-light.png differ diff --git a/assets/screenshots/library-mobile-light.png b/assets/screenshots/library-mobile-light.png new file mode 100644 index 00000000..0ae4ddcc Binary files /dev/null and b/assets/screenshots/library-mobile-light.png differ diff --git a/assets/screenshots/local-tracks-desktop-dark.png b/assets/screenshots/local-tracks-desktop-dark.png new file mode 100644 index 00000000..9dea91bf Binary files /dev/null and b/assets/screenshots/local-tracks-desktop-dark.png differ diff --git a/assets/screenshots/local-tracks-desktop-light.png b/assets/screenshots/local-tracks-desktop-light.png new file mode 100644 index 00000000..548dbb38 Binary files /dev/null and b/assets/screenshots/local-tracks-desktop-light.png differ diff --git a/assets/screenshots/lyrics-desktop-dark.png b/assets/screenshots/lyrics-desktop-dark.png new file mode 100644 index 00000000..c2518be4 Binary files /dev/null and b/assets/screenshots/lyrics-desktop-dark.png differ diff --git a/assets/screenshots/lyrics-desktop-light.png b/assets/screenshots/lyrics-desktop-light.png new file mode 100644 index 00000000..5d80ec6f Binary files /dev/null and b/assets/screenshots/lyrics-desktop-light.png differ diff --git a/assets/screenshots/playlists-desktop-dark.png b/assets/screenshots/playlists-desktop-dark.png new file mode 100644 index 00000000..13d1adff Binary files /dev/null and b/assets/screenshots/playlists-desktop-dark.png differ diff --git a/assets/screenshots/playlists-desktop-light.png b/assets/screenshots/playlists-desktop-light.png new file mode 100644 index 00000000..0fd2622b Binary files /dev/null and b/assets/screenshots/playlists-desktop-light.png differ diff --git a/assets/screenshots/search-desktop-dark.png b/assets/screenshots/search-desktop-dark.png new file mode 100644 index 00000000..c432b317 Binary files /dev/null and b/assets/screenshots/search-desktop-dark.png differ diff --git a/assets/screenshots/search-desktop-light.png b/assets/screenshots/search-desktop-light.png new file mode 100644 index 00000000..33a24fb6 Binary files /dev/null and b/assets/screenshots/search-desktop-light.png differ diff --git a/assets/screenshots/search-mobile-light.png b/assets/screenshots/search-mobile-light.png new file mode 100644 index 00000000..cae0e677 Binary files /dev/null and b/assets/screenshots/search-mobile-light.png differ diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..0479ace4 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,14 @@ +// Build logic: the Linux AppImage bundle (DownloadLinuxGtkBundleTask) and the package manager manifests +// (GitCliffContextTask, RenderPackagingTask). Lives in buildSrc so the IDE resolves it; an +// `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio. +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.jmustache) +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..ac722dd3 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,8 @@ +// Shares the root version catalog (gradle/libs.versions.toml) with the build logic +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt new file mode 100644 index 00000000..b9331fb5 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt @@ -0,0 +1,260 @@ +/* + * 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 . + */ + +// Bundles GTK 3 and WebKitGTK 4.1 into the Linux AppImage, the way Tauri apps do, so it runs on a +// system that has neither. Used only with -PappImage=true; deb/rpm depend on the distro's packages. +// +// The libraries come from the Ubuntu archive. Like the AppImage exclude list prescribes, nothing that +// has to match the host is bundled: glibc, libstdc++, libz, GL/EGL/DRM, X11/xcb, ALSA, fontconfig, +// freetype, harfbuzz, expat, gmp and the wayland libraries. What is left is pruned to the libraries +// reachable from GTK/WebKit through their ELF NEEDED entries. +// +// Layout (copied into the AppDir root by Nucleus; AppRun puts $APPDIR/usr/lib on LD_LIBRARY_PATH): +// usr/lib/*.so.N shared libraries, RUNPATH=$ORIGIN +// usr/lib//webkit2gtk-4.1/WebKit*Process helper processes +// usr/lib//gio/modules/libgiognutls.so TLS backend for WebKit/libsoup +// libwebkit2gtk has the helper path compiled in as /usr/lib//webkit2gtk-4.1; "/usr" is +// patched to "././" (same length), so the helpers resolve relative to the working directory, which the +// app sets to $APPDIR/usr at startup (AppImageEnvironment). Needs `ar`, `tar` (+ `zstd`), `readelf` +// and `patchelf` on the build machine. + +package org.spotube.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import java.io.File +import java.net.URI +import java.util.zip.GZIPInputStream + +@DisableCachingByDefault(because = "Downloads from the network") +abstract class DownloadLinuxGtkBundleTask : DefaultTask() { + /** Root passed on to Nucleus' `appResourcesRootDir`; the files land in `/linux-/usr`. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + /** Ubuntu release the binaries come from. Its glibc is the minimum the bundle runs on. */ + @get:Input + abstract val ubuntuRelease: Property + + init { + group = "dependencies" + description = "Downloads and relocates GTK 3 and WebKitGTK 4.1 for the Linux AppImage." + } + + private val packages = listOf( + "libgtk-3-0t64", "libwebkit2gtk-4.1-0", "libjavascriptcoregtk-4.1-0", "glib-networking", + "libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatomic1", "libatspi2.0-0t64", "libbrotli1", + "libbz2-1.0", "libcairo-gobject2", "libcairo2", "libcap2", "libdatrie1", "libdbus-1-3", + "libdw1t64", "libelf1t64", "libenchant-2-2", "libepoxy0", "libevdev2", "libffi8", "libgcrypt20", + "libgdk-pixbuf-2.0-0", "libglib2.0-0t64", "libgnutls30t64", "libgssapi-krb5-2", + "libgstreamer-gl1.0-0", "libgstreamer-plugins-base1.0-0", "libgstreamer1.0-0", "libgudev-1.0-0", + "libharfbuzz-icu0", "libhogweed6t64", "libhyphen0", "libicu74", "libidn2-0", "libjpeg-turbo8", + "libjpeg8", "libk5crypto3", "libkeyutils1", "libkrb5-3", "libkrb5support0", "liblcms2-2", + "liblz4-1", "liblzma5", "libmanette-0.2-0", "libnettle8t64", "libnghttp2-14", "liborc-0.4-0t64", + "libp11-kit0", "libpango-1.0-0", "libpangocairo-1.0-0", "libpangoft2-1.0-0", "libpixman-1-0", + "libpng16-16t64", "libpsl5t64", "libseccomp2", "libsecret-1-0", "libsharpyuv0", "libsoup-3.0-0", + "libsqlite3-0", "libsystemd0", "libtasn1-6", "libthai0", "libudev1", "libunistring5", "libunwind8", + "libwebp7", "libwebpdemux2", "libwebpmux3", "libxcb-render0", "libxcb-shm0", "libxcomposite1", + "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxinerama1", "libxkbcommon0", + "libxml2", "libxrandr2", "libxrender1", "libxslt1.1", "libzstd1", + ) + + // Provided by the host, per the AppImage exclude list (which includes the wayland libraries). Never + // copied even if a bundled package carries them. + private val hostLibraries = setOf( + "libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", "libresolv.so.2", + "libutil.so.1", "libnsl.so.1", "libstdc++.so.6", "libgcc_s.so.1", "libz.so.1", "libGL.so.1", + "libEGL.so.1", "libGLX.so.0", "libGLdispatch.so.0", "libOpenGL.so.0", "libdrm.so.2", + "libgbm.so.1", "libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libasound.so.2", + "libfontconfig.so.1", "libfreetype.so.6", "libharfbuzz.so.0", "libexpat.so.1", "libgmp.so.10", + "libwayland-client.so.0", "libwayland-server.so.0", "libwayland-cursor.so.0", + "libwayland-egl.so.1", "libuuid.so.1", "libgpg-error.so.0", "libfribidi.so.0", "libXau.so.6", + "libXdmcp.so.6", "libpcre2-8.so.0", "libselinux.so.1", "libmount.so.1", "libblkid.so.1", + ) + + private val rootLibraries = listOf( + "libgtk-3.so.0", "libgdk-3.so.0", "libwebkit2gtk-4.1.so.0", "libjavascriptcoregtk-4.1.so.0", + ) + + @TaskAction + fun run() { + val arch = System.getProperty("os.arch").lowercase() + val isArm = arch.contains("aarch64") || arch.contains("arm64") + val resourceDir = if (isArm) "linux-arm64" else "linux-x64" + val debArch = if (isArm) "arm64" else "amd64" + val triplet = if (isArm) "aarch64-linux-gnu" else "x86_64-linux-gnu" + val mirror = if (isArm) "http://ports.ubuntu.com/ubuntu-ports" else "http://archive.ubuntu.com/ubuntu" + + val target = outputDir.dir(resourceDir).get().asFile + val marker = File(target, ".complete") + if (marker.exists()) { + println("Linux GTK/WebKit bundle already present. Skipping download.") + return + } + for (tool in listOf("ar", "tar", "readelf", "patchelf")) { + val found = try { + ProcessBuilder(tool, "--version").redirectErrorStream(true).start() + .also { it.inputStream.readBytes() }.waitFor() == 0 + } catch (_: Exception) { + false + } + if (!found) throw GradleException("`$tool` is required to bundle GTK/WebKit on Linux but was not found on PATH.") + } + + target.deleteRecursively() + val work = File(temporaryDir, "work").apply { deleteRecursively(); mkdirs() } + val root = File(work, "root").apply { mkdirs() } + + val index = fetchPackageIndex(mirror, debArch) + for (pkg in packages) { + val filename = index[pkg] ?: throw GradleException("Package $pkg not found in the ${ubuntuRelease.get()} index") + val deb = File(work, "$pkg.deb") + println("Downloading $pkg") + URI("$mirror/$filename").toURL().openStream().use { input -> deb.outputStream().use { input.copyTo(it) } } + extractDeb(deb, root, File(work, "ar-$pkg")) + } + + val srcLib = File(root, "usr/lib/$triplet") + val libOut = File(target, "usr/lib").apply { mkdirs() } + + // Libraries by SONAME (libbz2.so.1.0 and friends don't end in a single number) + val available = HashMap() + srcLib.listFiles { f -> f.isFile && ".so" in f.name }.orEmpty().forEach { file -> + soname(file)?.let { available.putIfAbsent(it, file) } + } + + val helperSrc = File(srcLib, "webkit2gtk-4.1") + val helperOut = File(libOut, "$triplet/webkit2gtk-4.1") + val helpers = listOf("WebKitWebProcess", "WebKitNetworkProcess", "WebKitGPUProcess") + for (name in helpers) { + // copyTo drops the mode bits; WebKit spawns these, so they must stay executable + File(helperSrc, name).copyTo(File(helperOut, name), overwrite = true).setExecutable(true, false) + } + val injected = File(helperSrc, "injected-bundle/libwebkit2gtkinjectedbundle.so") + injected.copyTo(File(helperOut, "injected-bundle/${injected.name}"), overwrite = true) + val gioTls = File(srcLib, "gio/modules/libgiognutls.so") + gioTls.copyTo(File(libOut, "$triplet/gio/modules/${gioTls.name}"), overwrite = true) + + // Keep only what GTK, WebKit, their helpers and the TLS module pull in via NEEDED + val pending = ArrayDeque(rootLibraries) + listOf(*helpers.map { File(helperOut, it) }.toTypedArray(), File(helperOut, "injected-bundle/${injected.name}"), + File(libOut, "$triplet/gio/modules/${gioTls.name}")).forEach { pending += needed(it) } + val keep = LinkedHashSet() + while (pending.isNotEmpty()) { + val name = pending.removeFirst() + if (name in hostLibraries || name in keep) continue + val file = available[name] ?: continue + keep += name + pending += needed(file) + } + for (name in keep) available.getValue(name).copyTo(File(libOut, name), overwrite = true) + + patchWebKitPaths(File(libOut, "libwebkit2gtk-4.1.so.0"), triplet) + + // Libraries find each other next to themselves; the helpers and modules sit two/three levels down + libOut.listFiles { f -> f.isFile }.orEmpty().forEach { patchRpath(it, "\$ORIGIN") } + helpers.forEach { patchRpath(File(helperOut, it), "\$ORIGIN/../..") } + patchRpath(File(helperOut, "injected-bundle/${injected.name}"), "\$ORIGIN/../../..") + patchRpath(File(libOut, "$triplet/gio/modules/${gioTls.name}"), "\$ORIGIN/../../..") + + marker.writeText("") + work.deleteRecursively() + println("GTK/WebKit bundled into $target (${keep.size} libraries)") + } + + /** "/usr/lib//webkit2gtk-4.1[/injected-bundle]" -> "././lib//webkit2gtk-4.1[/...]". */ + private fun patchWebKitPaths(lib: File, triplet: String) { + val bytes = lib.readBytes() + val needle = "/usr/lib/$triplet/webkit2gtk-4.1".toByteArray() + val replacement = "././".toByteArray() + var count = 0 + var i = 0 + while (i <= bytes.size - needle.size) { + var match = true + for (j in needle.indices) { + if (bytes[i + j] != needle[j]) { match = false; break } + } + if (match) { + replacement.copyInto(bytes, i) + count++ + i += needle.size + } else { + i++ + } + } + if (count == 0) throw GradleException("WebKit helper path not found in ${lib.name}; the bundling layout needs updating") + lib.writeBytes(bytes) + } + + private fun soname(file: File): String? = + exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence() + .firstOrNull { "(SONAME)" in it }?.substringAfter('[')?.substringBefore(']') + + private fun needed(file: File): List = + exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence() + .filter { "(NEEDED)" in it }.map { it.substringAfter('[').substringBefore(']') }.toList() + + /** name -> pool filename; `-updates` is read first so security/bugfix builds win. */ + private fun fetchPackageIndex(mirror: String, debArch: String): Map { + val release = ubuntuRelease.get() + val index = HashMap() + for (suite in listOf("$release-updates", release)) { + for (component in listOf("main", "universe")) { + val url = URI("$mirror/dists/$suite/$component/binary-$debArch/Packages.gz").toURL() + GZIPInputStream(url.openStream()).bufferedReader().useLines { lines -> + var name: String? = null + for (line in lines) { + when { + line.startsWith("Package: ") -> name = line.removePrefix("Package: ") + line.startsWith("Filename: ") -> name?.let { index.putIfAbsent(it, line.removePrefix("Filename: ")) } + line.isEmpty() -> name = null + } + } + } + } + } + return index + } + + private fun extractDeb(deb: File, into: File, scratch: File) { + scratch.mkdirs() + exec(scratch, "ar", "x", deb.absolutePath) + val data = scratch.listFiles { f -> f.name.startsWith("data.tar") }?.singleOrNull() + ?: throw GradleException("No data archive in ${deb.name}") + // GNU tar picks the decompressor from the content (zstd needs the zstd binary installed) + exec(scratch, "tar", "-xf", data.absolutePath, "-C", into.absolutePath) + } + + private fun patchRpath(file: File, rpath: String) { + exec(file.parentFile, "patchelf", "--set-rpath", rpath, file.absolutePath) + } + + private fun exec(dir: File, vararg command: String): String { + val process = ProcessBuilder(*command).directory(dir).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText() + if (process.waitFor() != 0) { + throw GradleException("`${command.joinToString(" ")}` failed:\n$output") + } + return output + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt new file mode 100644 index 00000000..88d13ed0 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/GitCliffContextTask.kt @@ -0,0 +1,92 @@ +/* + * 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 . + */ + +// Writes git-cliff's changelog context (every release with its parsed, grouped commits) as JSON, using +// the repository's cliff.toml. RenderPackagingTask turns it into release notes for the package manifests. +// Needs `git-cliff` on the PATH and the full git history (CI: `fetch-depth: 0`). + +package org.spotube.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject + +@DisableCachingByDefault(because = "Reads the git history, which is not a declared input") +abstract class GitCliffContextTask @Inject constructor(private val execOperations: ExecOperations) : DefaultTask() { + @get:Input + abstract val executable: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val configFile: RegularFileProperty + + @get:Internal + abstract val repositoryDir: DirectoryProperty + + /** Tag for the commits since the last release (`--tag`); unset leaves them unreleased (nightlies). */ + @get:Input + @get:Optional + abstract val tag: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + init { + group = "distribution" + description = "Writes the git-cliff changelog context (releases and their commits) as JSON." + executable.convention("git-cliff") + // New commits and tags change the result without touching any declared input + outputs.upToDateWhen { false } + } + + @TaskAction + fun run() { + val output = outputFile.get().asFile + output.parentFile.mkdirs() + val args = buildList { + add(executable.get()) + addAll(listOf("--config", configFile.get().asFile.absolutePath, "--context")) + tag.orNull?.let { addAll(listOf("--tag", it)) } + addAll(listOf("--output", output.absolutePath)) + } + try { + execOperations.exec { + workingDir = repositoryDir.get().asFile + commandLine(args) + } + } catch (e: Exception) { + throw GradleException( + "Running git-cliff failed (${args.joinToString(" ")}). Install it from https://git-cliff.org " + + "and make sure the full git history is available.", + e, + ) + } + } +} diff --git a/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt new file mode 100644 index 00000000..27b57fa3 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/spotube/gradle/RenderPackagingTask.kt @@ -0,0 +1,264 @@ +/* + * 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 . + */ + +// Renders one package manager's directory (packaging/) into the build dir: `*.mustache` files are +// rendered with JMustache (suffix dropped), everything else is copied as is. +// +// Template data: +// version, tag, pkgrel, repositoryUrl +// artifacts..{fileName, url, sha256, sha256Upper} release assets found in artifactsDir +// release.{version, date, url, groups, notes} the release being packaged +// releases tagged releases, newest first (metainfo) +// groups[].{title, entries[].text} changelog grouped like cliff.toml +// #indent2 / #indent4 indent a multi-line block (YAML block scalars) +// +// Missing variables fail the render, so a manifest never ships with an empty checksum or version. +// HTML/XML escaping is on for .xml and .nuspec output only. + +package org.spotube.gradle + +import com.samskivert.mustache.Mustache +import com.samskivert.mustache.MustacheException +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.security.MessageDigest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +@CacheableTask +abstract class RenderPackagingTask : DefaultTask() { + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val templateDir: DirectoryProperty + + @get:Input + abstract val version: Property + + @get:Input + abstract val pkgrel: Property + + @get:Input + abstract val repositoryUrl: Property + + /** Release assets by template key, e.g. `linuxX86_64` -> `spotube-linux-6.0.0-x86_64.tar.xz`. */ + @get:Input + abstract val artifactNames: MapProperty + + /** Directory holding the downloaded release assets; only needed by templates that use `artifacts`. */ + @get:Internal + abstract val artifactsDir: DirectoryProperty + + /** The release assets that exist in [artifactsDir]; their checksums are part of the output. */ + @get:Input + protected val artifactChecksums: Map + get() = artifactFiles().mapValues { (_, file) -> sha256(file) } + + /** git-cliff `--context` output (GitCliffContextTask). */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val changelogContext: RegularFileProperty + + /** How many tagged releases `releases` lists. */ + @get:Input + abstract val maxReleases: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + init { + group = "distribution" + pkgrel.convention("1") + maxReleases.convention(10) + } + + @TaskAction + fun render() { + val source = templateDir.get().asFile + val target = outputDir.get().asFile + target.deleteRecursively() + target.mkdirs() + + val data = templateData() + source.walkTopDown().filter { it.isFile }.forEach { file -> + val relative = file.relativeTo(source).path + if (!relative.endsWith(TEMPLATE_SUFFIX)) { + file.copyTo(target.resolve(relative), overwrite = true) + return@forEach + } + val output = target.resolve(relative.removeSuffix(TEMPLATE_SUFFIX)) + val compiler = Mustache.compiler() + .escapeHTML(output.extension in XML_EXTENSIONS) + .emptyStringIsFalse(true) + .zeroIsFalse(true) + val rendered = try { + compiler.compile(file.readText()).execute(data) + } catch (e: MustacheException) { + throw GradleException( + "Rendering $relative failed: ${e.message?.trimEnd('.')}. Templates that use `artifacts` need " + + "-PpackagingArtifactsDir=.", + e, + ) + } + output.parentFile.mkdirs() + output.writeText(rendered) + } + } + + private fun templateData(): Map { + val version = version.get() + val tag = "v$version" + val repositoryUrl = repositoryUrl.get() + val downloadUrl = "$repositoryUrl/releases/download/$tag" + + val artifacts = artifactFiles().mapValues { (_, file) -> + val sha256 = sha256(file) + mapOf( + "fileName" to file.name, + "url" to "$downloadUrl/${file.name}", + "sha256" to sha256, + "sha256Upper" to sha256.uppercase(), + ) + } + + val changelog = changelogContext.orNull?.asFile?.let(::readChangelog).orEmpty() + val tagged = changelog.filter { it.version != null }.sortedByDescending { it.timestamp } + // The release being packaged; nightlies have no tag, so they get the unreleased commits + val current = changelog.firstOrNull { it.version == tag } + ?: changelog.firstOrNull { it.version == null } + ?: ChangelogRelease(tag, Instant.now().epochSecond, emptyList()) + + return mapOf( + "version" to version, + "tag" to tag, + "pkgrel" to pkgrel.get(), + "repositoryUrl" to repositoryUrl, + "artifacts" to artifacts, + "release" to releaseData(current, version, repositoryUrl), + "releases" to tagged.take(maxReleases.get()).map { + releaseData(it, it.version!!.removePrefix("v"), repositoryUrl) + }, + "indent2" to indent(2), + "indent4" to indent(4), + ) + } + + private fun artifactFiles(): Map { + val dir = artifactsDir.orNull?.asFile ?: return emptyMap() + return artifactNames.get() + .mapValues { (_, name) -> dir.resolve(name) } + .filterValues { it.isFile } + } + + private fun releaseData(release: ChangelogRelease, version: String, repositoryUrl: String): Map { + val groups = release.commits + .groupBy { it.group } + .toSortedMap() + .map { (group, commits) -> + mapOf( + // cliff.toml orders groups with an HTML comment prefix: "Features" + "title" to group.replace(HTML_COMMENT, "").trim(), + "entries" to commits.map { mapOf("text" to it.text) }, + ) + } + val notes = groups.joinToString("\n\n") { group -> + @Suppress("UNCHECKED_CAST") + val entries = group["entries"] as List> + "${group["title"]}\n" + entries.joinToString("\n") { "- ${it["text"]}" } + } + return mapOf( + "version" to version, + "date" to LocalDate.ofInstant(Instant.ofEpochSecond(release.timestamp), ZoneOffset.UTC).toString(), + "url" to "$repositoryUrl/releases/tag/v$version", + "groups" to groups, + "notes" to notes, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun readChangelog(file: File): List { + val releases = JsonSlurper().parse(file) as List> + return releases.map { release -> + val commits = (release["commits"] as List>).mapNotNull { commit -> + val group = commit["group"] as String? ?: return@mapNotNull null + val message = (commit["message"] as String).plainText().replaceFirstChar { it.uppercase() } + val scope = commit["scope"] as String? + val breaking = commit["breaking"] == true + val text = buildString { + if (breaking) append("Breaking: ") + if (scope != null) append("$scope: ") + append(message) + } + ChangelogCommit(group, text) + } + ChangelogRelease( + version = release["version"] as String?, + timestamp = (release["timestamp"] as Number?)?.toLong() ?: Instant.now().epochSecond, + commits = commits, + ) + } + } + + private data class ChangelogRelease(val version: String?, val timestamp: Long, val commits: List) + + private data class ChangelogCommit(val group: String, val text: String) + + private companion object { + const val TEMPLATE_SUFFIX = ".mustache" + val XML_EXTENSIONS = setOf("xml", "nuspec") + val HTML_COMMENT = Regex("") + val MARKDOWN_LINK = Regex("""\[([^\]]+)]\([^)]+\)""") + + // cliff.toml's commit preprocessors turn issue numbers into Markdown links + fun String.plainText() = replace(MARKDOWN_LINK, "$1").replace("**", "").trim() + + fun indent(spaces: Int) = Mustache.Lambda { fragment, out -> + val prefix = " ".repeat(spaces) + out.write(fragment.execute().lines().joinToString("\n") { if (it.isEmpty()) it else prefix + it }) + } + + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/cliff.toml b/cliff.toml index 311a7d7c..1c98d090 100644 --- a/cliff.toml +++ b/cliff.toml @@ -27,7 +27,7 @@ render_always = true # An array of regex based postprocessors to modify the changelog. postprocessors = [ # Replace the placeholder with a URL. - { pattern = '', replace = "https://github.com/KRTirtho/spotube" }, + { pattern = '', replace = "https://github.com/team-spotube/spotube" }, ] # render body even when there are no releases to process # render_always = true diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 5367c7ae..d8199d57 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -56,6 +56,19 @@ dependencies = [ "winnow", ] +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -133,6 +146,10 @@ version = "0.1.0" dependencies = [ "discord-rich-presence", "lofty", + "log", + "parking_lot", + "thiserror", + "tokio", "uniffi", ] @@ -207,6 +224,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + [[package]] name = "getrandom" version = "0.2.17" @@ -286,6 +315,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "lofty" version = "0.24.0" @@ -365,6 +403,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -377,6 +438,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "plain" version = "0.2.3" @@ -407,6 +474,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -426,6 +502,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -522,6 +604,12 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "smawk" version = "0.3.3" @@ -598,6 +686,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "toml" version = "0.5.11" @@ -660,6 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" dependencies = [ "anyhow", + "async-compat", "bytes", "once_cell", "static_assertions", diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 4357cc68..85f1f4dd 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -4,10 +4,19 @@ version = "0.1.0" edition = "2024" [dependencies] -uniffi = "0.29.4" +uniffi = { version = "0.29.4", features = ["tokio"] } lofty = "0.24.0" discord-rich-presence = "1.1.0" +thiserror = "2.0" +parking_lot = "0.12" +log = "0.4" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } [lib] crate-type = ["cdylib", "staticlib"] -path = "src/commonMain/rust/lib.rs" \ No newline at end of file +path = "src/commonMain/rust/lib.rs" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 34b20dd2..4058d430 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -16,10 +16,17 @@ */ import gobley.gradle.GobleyHost +import gobley.gradle.Variant import gobley.gradle.cargo.dsl.jvm +import gobley.gradle.rust.targets.RustAndroidTarget +import dev.nucleusframework.desktop.application.dsl.NativeImageOptimization import dev.nucleusframework.desktop.application.dsl.TargetFormat import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.gradle.api.tasks.Copy +import org.spotube.gradle.DownloadLinuxGtkBundleTask +import org.spotube.gradle.GitCliffContextTask +import org.spotube.gradle.RenderPackagingTask import java.io.FileInputStream import java.util.Properties @@ -39,11 +46,132 @@ plugins { kotlin("plugin.atomicfu") version libs.versions.kotlin } -vlcjBundler { - packageName = "dev.krtirtho.spotube.core.generated" // choose a different package - objectName = "VLCBundleLoaderGenerated" // or rename the object +val bundleId = "org.spotube.spotube" +val version = "6.0.0" +// Set to N to release -beta.N (same app id, icons and name as stable); null for a plain stable release. +// Installer metadata (packageVersion) must stay numeric, so it keeps using plain `version`. +val betaNumber: Int? = 0 +// Betas share the stable app id, so bump this for every beta: Android only updates to a higher versionCode. +val buildNumber = 46 +// Desktop icon channel; Android picks its icons via the stable/nightly product flavor. Use -Pnightly=true. +val isNightly = providers.gradleProperty("nightly").map { it.toBoolean() }.getOrElse(false) +val desktopIconDir = if (isNightly) "icons/nightly" else "icons/stable" +// Nightlies are numbered by the CI run (-PbuildNumber=, or GITHUB_RUN_NUMBER) so every build is traceable to its run. +val nightlyBuildNumber = providers.gradleProperty("buildNumber").orNull?.toIntOrNull() + ?: System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() + ?: buildNumber +// Production = shipping artifacts (installers, distributables, native images). Size/speed +// optimizations that slow builds down (Rust release + LTO) only apply then, never to `run`/dev. +// Override with -Pproduction=true|false. +val isProductionBuild = providers.gradleProperty("production").map { it.toBoolean() }.getOrElse( + gradle.startParameter.taskNames.any { task -> + task.substringAfterLast(':').let { name -> + name.startsWith("package") || name.startsWith("create") || + name.startsWith("notarize") || name == "nativeImageCompile" + } + } +) +val targetName = "Spotube" +val homePageUrl = "https://spotube.cc" +val repositoryUrl = "https://github.com/team-spotube/spotube" +val appVersionName = if (betaNumber != null) "$version-beta.$betaNumber" else version +val appBuildNumber = if (isNightly) nightlyBuildNumber else buildNumber +val appRepositoryUrl = repositoryUrl +// CI reads the release version from here instead of scraping this file. +tasks.register("printVersion") { + val appVersion = appVersionName + doLast { println(appVersion) } } +// Package manager manifests: packaging/ rendered into build/packaging/ with the version, the +// release assets' checksums (-PpackagingArtifactsDir=) and the git-cliff changelog. See RenderPackagingTask. +val gitCliffContext = tasks.register("gitCliffContext") { + configFile.set(rootProject.layout.projectDirectory.file("cliff.toml")) + repositoryDir.set(rootProject.layout.projectDirectory) + // Nightlies are not a release: their commits stay unreleased + if (!isNightly) tag.set("v$appVersionName") + outputFile.set(layout.buildDirectory.file("packaging/changelog-context.json")) +} +val packagingRootDir = rootProject.layout.projectDirectory +val packagingArtifactsDir = packagingRootDir.dir(providers.gradleProperty("packagingArtifactsDir")) +listOf("linux", "aur", "flatpak", "winget", "chocolatey").forEach { target -> + tasks.register("renderPackaging${target.replaceFirstChar { it.uppercase() }}") { + description = "Renders packaging/$target into build/packaging/$target." + templateDir.set(packagingRootDir.dir("packaging/$target")) + version.set(appVersionName) + pkgrel.set(providers.gradleProperty("pkgrel").orElse("1")) + repositoryUrl.set(appRepositoryUrl) + artifactNames.set( + mapOf( + "linuxX86_64" to "spotube-linux-$appVersionName-x86_64.tar.xz", + "linuxAarch64" to "spotube-linux-$appVersionName-aarch64.tar.xz", + "windowsX64" to "Spotube-windows-x86_64-setup.exe", + ) + ) + artifactsDir.set(packagingArtifactsDir) + changelogContext.set(gitCliffContext.flatMap { it.outputFile }) + outputDir.set(layout.buildDirectory.dir("packaging/$target")) + } +} +val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/") +val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest" +val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin") +val generateAppBuildInfo by tasks.registering(Copy::class) { + description = "Generate AppBuildInfo.kt" + from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template")) + into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/org/spotube/spotube")) + rename("AppBuildInfo.kt.template", "AppBuildInfo.kt") + expand( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) + inputs.properties( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) +} + +tasks.configureEach { + if (name.startsWith("compile") && name.contains("Kotlin")) { + dependsOn(generateAppBuildInfo) + } +} + +// Windows/macOS always bundle VLC. On Linux only the self-contained AppImage (-PappImage=true) bundles +// VLC, GTK and WebKitGTK; deb/rpm use the distro's packages. +val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux") +val bundleForAppImage = isLinuxHost && providers.gradleProperty("appImage").map { it.toBoolean() }.getOrElse(false) + +vlcjBundler { + packageName = "$bundleId.core.generated" // choose a different package + objectName = "VLCBundleLoaderGenerated" // or rename the object + bundleVlc = !isLinuxHost || bundleForAppImage +} + +// GTK 3 + WebKitGTK for the AppImage (see DownloadLinuxGtkBundleTask in buildSrc) +val downloadLinuxGtkBundle = tasks.register("downloadLinuxGtkBundle") { + outputDir.set(layout.buildDirectory.dir("linux-gtk-bundle")) + ubuntuRelease.set("noble") +} +val linuxGtkBundleDir: Provider = downloadLinuxGtkBundle.flatMap { it.outputDir } + +// One resources root holding VLC and GTK/WebKit, laid out for the AppImage +val assembleAppImageResources = tasks.register("assembleAppImageResources") { + from(vlcjBundler.vlcNativesDirectory) + from(linuxGtkBundleDir) + exclude("**/.complete") + into(layout.buildDirectory.dir("appimage-resources")) +} + + kotlin { // Note: For Android application modules, androidTarget() is still required as of AGP 8.x. // The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead. @@ -65,9 +193,11 @@ kotlin { } jvm() + jvmToolchain(21) sourceSets { val commonMain by getting { + kotlin.srcDir(generatedAppInfoDir) dependencies { // Project dependencies implementation(project(":plugin_interfaces")) @@ -75,7 +205,6 @@ kotlin { // Jetpack Compose dependencies implementation(libs.compose.runtime) implementation(libs.compose.foundation) - implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) @@ -86,6 +215,10 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) + implementation(libs.dns.sd.kt) + + // Millennium UI + implementation(libs.millennium) // Navigation implementation(libs.jetbrains.navigation3.ui) @@ -103,9 +236,10 @@ kotlin { implementation(libs.ktor.client.logging) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) - implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.websockets) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.websockets) // Zipline api(libs.zipline.core) implementation(libs.zipline.loader) @@ -117,12 +251,6 @@ kotlin { // icons implementation(libs.feather.icons) - // Material3 adaptive - implementation(libs.jetbrains.material3.adaptive) - implementation(libs.jetbrains.material3.adaptive.layout) - implementation(libs.jetbrains.material3.adaptive.suite) - implementation(libs.jetbrains.material3.adaptiveNavigation3) - implementation(libs.jetbrains.material3.window.size) // androidx-datastore implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) @@ -137,8 +265,6 @@ kotlin { implementation(libs.semver) - implementation(libs.material.kolor) - // crypto implementation(libs.cryptography.core) implementation(libs.cryptography.provider.optimal) @@ -159,7 +285,13 @@ kotlin { // Shimmer effect implementation(libs.compose.shimmer) - implementation(libs.compose.placeholder.material3) + + // DLNA + implementation(libs.dns.sd.kt) + + // Markdown renderer + implementation(libs.multiplatform.markdown.renderer) + implementation(libs.multiplatform.markdown.renderer.m3) } } commonTest.dependencies { @@ -223,25 +355,40 @@ kotlin { implementation(libs.jna) implementation(libs.nucleus.core.runtime) + implementation(libs.nucleus.graalvm.runtime) implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.decorated.window.tao) implementation(libs.compose.native.tray) implementation(libs.nucleus.media.control) + implementation(libs.nucleus.darkmode.detector) } } } } android { - namespace = "dev.krtirtho.spotube" + namespace = "org.spotube.spotube" compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { - applicationId = "dev.krtirtho.spotube" + applicationId = bundleId minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = project.findProperty("versionName") as String? ?: "1.0" + versionCode = appBuildNumber + versionName = appVersionName + } + // stable = production channel, nightly = beta channel (own icon, name and installable side by side) + flavorDimensions += "channel" + productFlavors { + create("stable") { + dimension = "channel" + } + create("nightly") { + dimension = "channel" + applicationIdSuffix = ".nightly" + versionCode = nightlyBuildNumber + versionNameSuffix = "-nightly.$nightlyBuildNumber" + } } packaging { resources { @@ -273,6 +420,14 @@ android { isMinifyEnabled = false signingConfig = signingConfigs.getByName("release") } + getByName("debug") { + // Debug and release share the application id, so sign both with the release key + // when it's configured; otherwise one can't be installed over the other. + // Without local.properties signing (contributors) keep the default debug keystore. + if (signingConfigs.getByName("release").storeFile != null) { + signingConfig = signingConfigs.getByName("release") + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -288,23 +443,28 @@ dependencies { } nucleus.application { - mainClass = "dev.krtirtho.spotube.MainKt" + mainClass = "org.spotube.spotube.MainKt" jvmArgs += listOf( "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work // --- ADD THESE FOR SPEED & LOW RAM --- "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) - "-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) + "-Xmx1g", // Ceiling, not a reservation: the heap only grows to what is live (#3200, #3193) "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB ) nativeDistributions { - packageName = "dev.krtirtho.spotube" - packageVersion = project.findProperty("versionName") as String? ?: "6.0.0" + packageName = bundleId + // User-facing name (.app bundle, DMG productName, Launchpad/Dock); packageName above is the reverse-DNS id. + // The GraalVM DMG task reads only appName ?: packageName, not macOS.packageName + appName = if (isNightly) "Spotube Nightly" else "Spotube" + packageVersion = version licenseFile = project.file("../LICENSE") - appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) + appResourcesRootDir.set( + if (bundleForAppImage) assembleAppImageResources.map { layout.buildDirectory.dir("appimage-resources").get() } else vlcjBundler.vlcNativesDirectory + ) targetFormats( TargetFormat.Dmg, @@ -317,14 +477,54 @@ nucleus.application { modules("jdk.unsupported") - homepage = "https://spotube.cc" + homepage = homePageUrl linux { + iconFile.set(project.file("$desktopIconDir/icon.png")) modules("jdk.security.auth") debMaintainer = "Team Spotube " rpmLicenseType = "AGPL-3.0-or-later" + // deb/rpm use the distro's shared libraries (only the AppImage bundles VLC). VLC is not + // bundled on Linux otherwise, the webview needs WebKitGTK 4.1, and the window/tray/MPRIS + // natives link GTK 3, GLib/GIO and D-Bus. Setting these replaces electron-builder's + // Electron-specific defaults (libnss3, libnotify, ...). + debDepends = listOf( + "libvlc5", + "vlc-plugin-base", + "libwebkit2gtk-4.1-0", + "libgtk-3-0 | libgtk-3-0t64", + "libglib2.0-0 | libglib2.0-0t64", + "libdbus-1-3", + "libasound2 | libasound2t64", + "libgl1", + "libfontconfig1", + "libfreetype6", + "libx11-6", + "libxext6", + "libxi6", + "libxrender1", + "libxtst6", + ) + // vlc-libs comes from RPM Fusion (Fedora ships no VLC); the README tells users to enable it + rpmRequires = listOf( + "vlc-libs", + "webkit2gtk4.1", + "gtk3", + "glib2", + "dbus-libs", + "alsa-lib", + "mesa-libGL", + "fontconfig", + "freetype", + "libX11", + "libXext", + "libXi", + "libXrender", + "libXtst", + ) } windows { + iconFile.set(project.file("$desktopIconDir/icon.ico")) shortcut = true menu = true dirChooser = true @@ -332,6 +532,9 @@ nucleus.application { } macOS { + iconFile.set(project.file("$desktopIconDir/icon.icns")) + bundleID = if (isNightly) "$bundleId.nightly" else bundleId + dockName = appName notarization { } @@ -341,14 +544,100 @@ nucleus.application { buildTypes.release.proguard { configurationFiles.from(project.file("proguard-rules.pro")) } + + // AOT-compiled native binary (no bundled JVM). Build with `packageGraalvmNative`, try it with + // `runGraalvmNative`. Nucleus downloads GraalVM CE on first use (or uses $GRAALVM_HOME). + // Reflection/JNI metadata the automatic passes miss (vlcj, Zipline, NewPipe/Rhino, uniffi) + // lives in `graalvm/` and is (re)collected by exercising the app under `runWithNativeAgent`. + graalvm { + isEnabled = true + imageName = "spotube" + nativeImageConfigBaseDir.set(project.layout.projectDirectory.dir("graalvm")) + // ProGuard is not used for native images; -Os is the size equivalent + optimization = NativeImageOptimization.SIZE + // Same ceiling as the JVM distribution's -Xmx + maxHeapSize = "1g" + // Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed + // a second ~30 MB copy as a resource + buildArgs.add("-H:+UnlockExperimentalVMOptions") + buildArgs.add("-H:ExcludeResources=.*skiko-[^/]*\\.(so|dylib|dll)") + } +} + +// The uber JAR is what the GraalVM build compiles from, and native-image embeds every registered +// resource in the binary. Libraries ship natives for every OS/CPU, so keep only the host's +// (the uber JAR is per-OS/arch already; it isn't used by `run`). +tasks.withType().matching { it.name.endsWith("UberJarForCurrentOS") }.configureEach { + // Everything the exclude closure uses is local: the configuration cache can't serialize + // references to the build script object. + val hostOs = System.getProperty("os.name").lowercase() + val foreignExtensions = when { + hostOs.contains("win") -> setOf("so", "dylib", "jnilib", "a") + hostOs.contains("mac") -> setOf("so", "dll", "a") + else -> setOf("dll", "dylib", "jnilib", "a") + } + val nativeExtensions = setOf("so", "dll", "dylib", "jnilib", "a") + // CPU names as libraries spell them in their native folders (linux-x86-64, jni/amd64, darwin-aarch64, …) + val cpuDirToken = Regex( + "(?:^|[-_])(x86[-_]64|amd64|x64|aarch64|arm64|armv6|armv7|armel|arm|x86|i[36]86|ppc64le|ppc64|ppc" + + "|s390x|riscv64|loongarch64|mips64el|sparcv9|sparc)(?=$|[-_])" + ) + val hostCpuTokens = when (System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> setOf("aarch64", "arm64") + else -> setOf("x86-64", "x86_64", "amd64", "x64") + } + // OS flavours we never ship desktop builds for (sqlite-jdbc and JNA bundle these) + val foreignOsDirToken = Regex("android|musl|freebsd|openbsd|dragonflybsd|aix|sunos") + exclude { element -> + val segments = element.path.lowercase().split('/') + val extension = segments.last().substringAfterLast('.', "") + !element.isDirectory && extension in nativeExtensions && ( + extension in foreignExtensions || + segments.dropLast(1).any { dir -> + foreignOsDirToken.containsMatchIn(dir) || + cpuDirToken.findAll(dir).any { it.groupValues[1] !in hostCpuTokens } + } + ) + } + // The locally built compose-webview SNAPSHOT carries stale CEF binaries (~1.3 GB unstripped + // libcef.so) that no code loads (Linux uses WebKitGTK). + exclude( + "nucleus/native/*/libcef.*", + "nucleus/native/*/libcompose_cef_*", + "nucleus/native/*/cef_subprocess*", + "nucleus/native/*/*.pak", + "nucleus/native/*/icudtl.dat", + "nucleus/native/*/v8_context_snapshot.bin", + ) +} + +// Linux: native-image emits 13 KB libjvm.so/libjava.so shims (the image itself provides the JVM +// symbols), but Nucleus copies the JDK's real ones next to the binary — libjvm.so alone is 28 MB. +// Ship native-image's own outputs instead: skip the libjvm copy, and in the AWT copy prefer +// nativeCompile's version of any library native-image produced over the JDK's. +tasks.matching { it.name == "copyGraalvmJvmSo" }.configureEach { enabled = false } +tasks.withType().matching { it.name == "copyGraalvmAwtSoLibs" }.configureEach { + val nativeCompileDir = layout.buildDirectory.dir("compose/tmp/main/graalvm/nativeCompile").get().asFile + from(nativeCompileDir) { include("*.so") } + eachFile { + if (file.parentFile != nativeCompileDir && nativeCompileDir.resolve(name).exists()) exclude() + } } // Issue with compose plugin // https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks tasks.withType().configureEach { + // Populates the resources dir below with the VLC natives (appResourcesRootDir); hot run doesn't depend on it by default + dependsOn("prepareAppResources") jvmArgs( "--enable-native-access=ALL-UNNAMED" ) + // Linux: the hot-reload agent starts AWT before main(), and JBR's X11 toolkit pins GTK to + // XWayland, where the embedded WebKit WebView renders black. Tao owns the windows, so the + // reloadable JVM stays headless (DevTools runs in its own JVM and is unaffected). + if (System.getProperty("os.name").lowercase().contains("linux")) { + jvmArgs("-Djava.awt.headless=true") + } systemProperty( "compose.application.resources.dir", project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get() @@ -356,7 +645,44 @@ tasks.withType().configureEach { } cargo { + // Gobley defaults desktop to the debug profile, which ships an unstripped ~31 MB library + if (isProductionBuild) jvmVariant = Variant.Release builds.jvm { embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget) } -} \ No newline at end of file +} + +// Gobley adds build/intermediates/rust// as a jniLibs source dir, but AGP leaves source dirs +// that don't exist at configuration time out of the merge inputs. On a clean build (CI) libcompose_app.so would +// then silently miss the APK and crash at runtime with UnsatisfiedLinkError, so create the dirs up front. +RustAndroidTarget.entries.forEach { target -> + Variant.entries.forEach { variant -> + layout.buildDirectory.dir("intermediates/rust/${target.rustTriple}/$variant").get().asFile.mkdirs() + } +} + +// Google only ships x86_64 Linux NDK toolchains, so on a Linux arm64 host the Android Rust targets can never +// link (clang: "Exec format error"). Desktop packaging doesn't need them; Android is built on x86_64 runners. +run { + val isLinuxArm64Host = System.getProperty("os.name").lowercase().contains("linux") && + System.getProperty("os.arch").lowercase() in setOf("aarch64", "arm64") + if (isLinuxArm64Host) { + // Gobley generates the UniFFI bindings from the first Android build's library by default; use the host's. + uniffi { + generateFromLibrary { + build = GobleyHost.current.rustTarget + } + } + tasks.matching { it.name.startsWith("cargoBuildAndroid") || it.name.startsWith("rustUpTargetAddAndroid") } + .configureEach { enabled = false } + } +} + +// Nucleus' resource copies (prepareAppResources, copyGraalvmAppResources) drop the execute bit, but WebKitGTK +// spawns its helper processes from the AppImage bundle (DownloadLinuxGtkBundleTask in buildSrc) and fails with +// "Permission denied" otherwise. +tasks.matching { it.name == "prepareAppResources" || it.name == "copyGraalvmAppResources" }.configureEach { + (this as? org.gradle.api.tasks.AbstractCopyTask)?.filesMatching("**/webkit2gtk-4.1/WebKit*Process") { + permissions { unix("rwxr-xr-x") } + } +} diff --git a/composeApp/graalvm/reachability-metadata.json b/composeApp/graalvm/reachability-metadata.json new file mode 100644 index 00000000..6d8dfc7c --- /dev/null +++ b/composeApp/graalvm/reachability-metadata.json @@ -0,0 +1,1332 @@ +{ + "reflection": [ + { + "type": "uk.co.caprica.vlcj.binding.lib.LibVlc", + "jniAccessible": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "type": "androidx.compose.ui.platform.GlobalSnapshotManager$Registration" + }, + { + "type": "androidx.datastore.preferences.PreferencesProto$PreferenceMap", + "fields": [ + { + "name": "preferences_" + } + ] + }, + { + "type": "androidx.datastore.preferences.PreferencesProto$Value", + "fields": [ + { + "name": "valueCase_" + }, + { + "name": "value_" + } + ] + }, + { + "type": "app.cash.zipline.InterruptHandler", + "jniAccessible": true, + "methods": [ + { + "name": "poll", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "app.cash.zipline.QuickJsException", + "jniAccessible": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String", + "java.lang.String" + ] + } + ] + }, + { + "type": "app.cash.zipline.internal.bridge.CallChannel", + "jniAccessible": true, + "methods": [ + { + "name": "call", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "disconnect", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "com.sun.crypto.provider.HKDFKeyDerivation$HKDFSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [ + "javax.crypto.KDFParameters" + ] + } + ] + }, + { + "type": "com.sun.crypto.provider.HmacCore$HmacSHA384", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "com.sun.jna.CallbackReference$AttachOptions", + "jniAccessible": true, + "fields": [ + { + "name": "daemon" + }, + { + "name": "detach" + }, + { + "name": "name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "com.sun.jna.Union" + }, + { + "type": "dev.nucleusframework.window.tao.event.TaoSyntheticKeyKt$SyntheticAwtKeyEventSource$1" + }, + { + "type": "dev.nucleusframework.window.tao.event.TaoSyntheticMouseWheelEvent$source$1" + }, + { + "type": "io.ktor.client.plugins.DefaultRequest" + }, + { + "type": "io.ktor.client.plugins.HttpTimeoutConfig" + }, + { + "type": "io.ktor.client.plugins.websocket.WebSockets" + }, + { + "type": "io.ktor.server.application.PluginInstance" + }, + { + "type": "io.ktor.server.plugins.MutableOriginConnectionPoint" + }, + { + "type": "io.ktor.utils.io.ByteReadChannel" + }, + { + "type": "java.lang.Boolean", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "boolean" + ] + }, + { + "name": "booleanValue", + "parameterTypes": [ + + ] + }, + { + "name": "getBoolean", + "parameterTypes": [ + "java.lang.String" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "boolean" + ] + } + ] + }, + { + "type": "java.lang.Double", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "double" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "double" + ] + } + ] + }, + { + "type": "java.lang.Integer", + "jniAccessible": true, + "fields": [ + { + "name": "TYPE" + }, + { + "name": "value" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "int" + ] + }, + { + "name": "valueOf", + "parameterTypes": [ + "int" + ] + } + ] + }, + { + "type": "java.lang.Module", + "methods": [ + { + "name": "getDescriptor", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "java.lang.StackWalker", + "methods": [ + { + "name": "getInstance", + "parameterTypes": [ + "java.lang.StackWalker$Option" + ] + }, + { + "name": "walk", + "parameterTypes": [ + "java.util.function.Function" + ] + } + ] + }, + { + "type": "java.lang.StackWalker$StackFrame", + "methods": [ + { + "name": "getDeclaringClass", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "kotlin.Any" + }, + { + "type": "kotlin.Boolean" + }, + { + "type": "kotlin.Cloneable" + }, + { + "type": "kotlin.Function0" + }, + { + "type": "kotlin.Function2" + }, + { + "type": "kotlin.Function3" + }, + { + "type": "kotlin.Int" + }, + { + "type": "kotlin.Long" + }, + { + "type": "kotlin.String" + }, + { + "type": "kotlin.Throwable" + }, + { + "type": "kotlin.collections.List" + }, + { + "type": "kotlin.collections.Map" + }, + { + "type": "kotlinx.atomicfu.AtomicBoolean" + }, + { + "type": "kotlinx.io.Source" + }, + { + "type": "org.freedesktop.dbus.connections.impl.DBusConnection", + "methods": [ + { + "name": "addGenericSigHandler", + "parameterTypes": [ + "org.freedesktop.dbus.matchrules.DBusMatchRule", + "org.freedesktop.dbus.interfaces.DBusSigHandler" + ] + } + ] + }, + { + "type": "org.robolectric.Robolectric" + }, + { + "type": "org.spotube.spotube.core.server.StreamProxy", + "methods": [ + { + "name": "respondWithUpstream", + "parameterTypes": [ + "io.ktor.server.application.ApplicationCall", + "io.ktor.client.statement.HttpResponse", + "java.lang.String", + "io.ktor.http.HttpMethod", + "kotlin.coroutines.Continuation" + ] + } + ] + }, + { + "type": "org.spotube.spotube.core.server.StreamProxy$respondWithUpstream$5" + }, + { + "type": "org.spotube.spotube.core.zipline.ZiplinePluginService$use$2", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "sun.net.www.protocol.http.Handler", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.net.www.protocol.https.Handler", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.security.rsa.RSASignature$SHA384withRSA", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "sun.text.resources.cldr.FormatData_en" + }, + { + "type": "sun.text.resources.cldr.FormatData_en_US" + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_callback_t", + "jniAccessible": true, + "methods": [ + { + "name": "callback", + "parameterTypes": [ + "uk.co.caprica.vlcj.binding.internal.libvlc_event_t", + "com.sun.jna.Pointer" + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_dialog_id", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_equalizer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_manager_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_t", + "fields": [ + { + "name": "obj" + }, + { + "name": "type" + }, + { + "name": "u" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_event_u", + "fields": [ + { + "name": "media_duration_changed" + }, + { + "name": "media_freed" + }, + { + "name": "media_list_item_added" + }, + { + "name": "media_list_item_deleted" + }, + { + "name": "media_list_player_next_item_set" + }, + { + "name": "media_list_will_add_item" + }, + { + "name": "media_list_will_delete_item" + }, + { + "name": "media_meta_changed" + }, + { + "name": "media_parsed_changed" + }, + { + "name": "media_player_audio_device" + }, + { + "name": "media_player_audio_volume" + }, + { + "name": "media_player_buffering" + }, + { + "name": "media_player_chapter_changed" + }, + { + "name": "media_player_es_changed" + }, + { + "name": "media_player_length_changed" + }, + { + "name": "media_player_media_changed" + }, + { + "name": "media_player_pausable_changed" + }, + { + "name": "media_player_position_changed" + }, + { + "name": "media_player_scrambled_changed" + }, + { + "name": "media_player_seekable_changed" + }, + { + "name": "media_player_snapshot_taken" + }, + { + "name": "media_player_time_changed" + }, + { + "name": "media_player_title_changed" + }, + { + "name": "media_player_vout" + }, + { + "name": "media_state_changed" + }, + { + "name": "media_subitem_added" + }, + { + "name": "media_subitemtree_added" + }, + { + "name": "media_thumbnail_generated" + }, + { + "name": "renderer_discoverer_item_added" + }, + { + "name": "renderer_discoverer_item_deleted" + }, + { + "name": "vlm_media_event" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_instance_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_log_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_discoverer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_list_player_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_list_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_player_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_stats_t", + "fields": [ + { + "name": "f_demux_bitrate" + }, + { + "name": "f_input_bitrate" + }, + { + "name": "f_send_bitrate" + }, + { + "name": "i_decoded_audio" + }, + { + "name": "i_decoded_video" + }, + { + "name": "i_demux_corrupted" + }, + { + "name": "i_demux_discontinuity" + }, + { + "name": "i_demux_read_bytes" + }, + { + "name": "i_displayed_pictures" + }, + { + "name": "i_lost_abuffers" + }, + { + "name": "i_lost_pictures" + }, + { + "name": "i_played_abuffers" + }, + { + "name": "i_read_bytes" + }, + { + "name": "i_sent_bytes" + }, + { + "name": "i_sent_packets" + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_media_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_picture_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_renderer_discoverer_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.libvlc_renderer_item_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_duration_changed", + "fields": [ + { + "name": "new_duration" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_freed", + "fields": [ + { + "name": "md" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_item_added", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_item_deleted", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_player_next_item_set", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_will_add_item", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_list_will_delete_item", + "fields": [ + { + "name": "index" + }, + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_meta_changed", + "fields": [ + { + "name": "meta_type" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_parsed_changed", + "fields": [ + { + "name": "new_status" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_audio_device", + "fields": [ + { + "name": "device" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_audio_volume", + "fields": [ + { + "name": "volume" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_buffering", + "fields": [ + { + "name": "new_cache" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_chapter_changed", + "fields": [ + { + "name": "new_chapter" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_es_changed", + "fields": [ + { + "name": "i_id" + }, + { + "name": "i_type" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_length_changed", + "fields": [ + { + "name": "new_length" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_media_changed", + "fields": [ + { + "name": "md" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_pausable_changed", + "fields": [ + { + "name": "new_pausable" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_position_changed", + "fields": [ + { + "name": "new_position" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_scrambled_changed", + "fields": [ + { + "name": "new_scrambled" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_seekable_changed", + "fields": [ + { + "name": "new_seekable" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_snapshot_taken", + "fields": [ + { + "name": "filename" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_time_changed", + "fields": [ + { + "name": "new_time" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_title_changed", + "fields": [ + { + "name": "new_title" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_player_vout", + "fields": [ + { + "name": "new_count" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_state_changed", + "fields": [ + { + "name": "new_state" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_subitem_added", + "fields": [ + { + "name": "new_child" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_subitemtree_added", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.media_thumbnail_generated", + "fields": [ + { + "name": "p_thumbnail" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.renderer_discoverer_item_added", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.renderer_discoverer_item_deleted", + "fields": [ + { + "name": "item" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.internal.vlm_media_event", + "fields": [ + { + "name": "psz_instance_name" + }, + { + "name": "psz_media_name" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.binding.support.types.size_t", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uk.co.caprica.vlcj.support.eventmanager.NativeEventManager$EventCallback" + }, + { + "type": "uniffi.compose_app.ForeignBytesStruct", + "fields": [ + { + "name": "data" + }, + { + "name": "len" + } + ] + }, + { + "type": "uniffi.compose_app.ForeignBytesStruct$ByValue", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uniffi.compose_app.IntegrityCheckingUniffiLib" + }, + { + "type": "uniffi.compose_app.RustBufferStruct", + "fields": [ + { + "name": "capacity" + }, + { + "name": "data" + }, + { + "name": "len" + } + ] + }, + { + "type": "uniffi.compose_app.RustBufferStruct$ByValue", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "type": "uniffi.compose_app.UniffiLib" + }, + { + "type": "uniffi.compose_app.UniffiRustCallStatusStruct", + "fields": [ + { + "name": "code" + }, + { + "name": "errorBuf" + } + ] + }, + { + "type": "uniffi.compose_app.UniffiRustCallStatusStruct$ByReference" + } + ], + "resources": [ + { + "glob": "okhttp3/internal/publicsuffix/PublicSuffixDatabase.list" + } + ] +} diff --git a/composeApp/icons/nightly/icon.icns b/composeApp/icons/nightly/icon.icns new file mode 100644 index 00000000..15e7c52a Binary files /dev/null and b/composeApp/icons/nightly/icon.icns differ diff --git a/composeApp/icons/nightly/icon.ico b/composeApp/icons/nightly/icon.ico new file mode 100644 index 00000000..aaee01a3 Binary files /dev/null and b/composeApp/icons/nightly/icon.ico differ diff --git a/composeApp/icons/nightly/icon.png b/composeApp/icons/nightly/icon.png new file mode 100644 index 00000000..33774b89 Binary files /dev/null and b/composeApp/icons/nightly/icon.png differ diff --git a/composeApp/icons/stable/icon.icns b/composeApp/icons/stable/icon.icns new file mode 100644 index 00000000..7110d55f Binary files /dev/null and b/composeApp/icons/stable/icon.icns differ diff --git a/composeApp/icons/stable/icon.ico b/composeApp/icons/stable/icon.ico new file mode 100644 index 00000000..0448c504 Binary files /dev/null and b/composeApp/icons/stable/icon.ico differ diff --git a/composeApp/icons/stable/icon.png b/composeApp/icons/stable/icon.png new file mode 100644 index 00000000..3a59c7aa Binary files /dev/null and b/composeApp/icons/stable/icon.png differ diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeDownloader.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt similarity index 92% rename from composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeDownloader.kt rename to composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt index afb9b395..5e3def2b 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeDownloader.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.paths.Paths import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json @@ -82,10 +82,16 @@ class NewPipeDownloader(private val cookieJar: PersistentCookieJar) : Downloader companion object { private var instance: NewPipeDownloader? = null + /** + * Safe to call on every Activity creation: the process (and so this singleton) outlives + * the Activity, and a second [PersistentCookieJar] would open a second DataStore on the + * same cookies file, which DataStore rejects. + */ + @Synchronized fun init(paths: Paths) { + if (instance != null) return val cookieJar = PersistentCookieJar(paths) - instance = NewPipeDownloader(cookieJar) - NewPipe.init(instance) + instance = NewPipeDownloader(cookieJar).also { NewPipe.init(it) } } } } diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt similarity index 50% rename from composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt rename to composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index cf8dd550..1500c6f9 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -15,24 +15,35 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.server.describeForLog import io.ktor.http.Url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory +import org.schabi.newpipe.extractor.stream.DeliveryMethod import org.schabi.newpipe.extractor.stream.StreamInfoItem +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 actual class NewPipeService { - actual suspend fun searchVideos(query: String): List { + private val logger = Logger.withTag("NewPipeService") + + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( query, - mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), "" ) extractor.fetchPage() @@ -46,9 +57,8 @@ actual class NewPipeService { title = item.name, url = item.url, uploader = item.uploaderName, - durationMs = item.duration, - thumbnailUrl = item.thumbnails.first().url, - ) + durationMs = secondsToMs(item.duration), + thumbnailUrl = item.thumbnails.first().url) } else { null } @@ -61,14 +71,33 @@ actual class NewPipeService { val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id") extractor.fetchPage() + // Raw extractor output, before any mapping/filtering, so stream-selection issues can be + // traced back to what YouTube actually offered for this video. + logger.i { + "[Streams $id] '${extractor.name}' raw audioStreams=${extractor.audioStreams.size}:\n" + + extractor.audioStreams.joinToString("\n") { stream -> + " itag=${stream.itag} format=${stream.format?.name} mime=${stream.format?.mimeType} " + + "suffix=${stream.format?.suffix} codec=${stream.codec} bitrate=${stream.bitrate} " + + "avgBitrate=${stream.averageBitrate} delivery=${stream.deliveryMethod} isUrl=${stream.isUrl} " + + "trackType=${stream.audioTrackType}" + } + } + logger.i { + "[Streams $id] dashMpdUrl=${extractor.dashMpdUrl.isNotBlank()} hlsUrl=${extractor.hlsUrl.isNotBlank()} " + + "raw videoStreams=${extractor.videoStreams.size} videoOnlyStreams=${extractor.videoOnlyStreams.size}" + } + val audioStreams = extractor.audioStreams .mapNotNull { stream -> + val format = stream.format ?: return@mapNotNull null + // Non-URL content is an inline manifest, not something a player or downloader can fetch. + if (!stream.isUrl) return@mapNotNull null AudioStream.Lossy( url = stream.content, - codec = stream.codec, - container = stream.format!!.suffix, + codec = normalizeCodec(stream.codec), + container = normalizeContainer(format.suffix), bitrate = stream.bitrate, - ) + protocol = stream.deliveryMethod.toProtocol()) } + buildList { if (extractor.dashMpdUrl.isNotBlank()) add( AudioStream.Lossy( @@ -90,7 +119,6 @@ actual class NewPipeService { ) } - val videoStreams = extractor.videoStreams .filter { stream -> stream.isUrl && stream.format != null } .map { stream -> @@ -98,22 +126,44 @@ actual class NewPipeService { url = stream.content, codec = stream.codec, bitrate = stream.bitrate, - container = stream.format!!.suffix, - ) + container = stream.format!!.suffix) } + logger.i { + "[Streams $id] mapped audio candidates (${audioStreams.size}):\n" + + audioStreams.joinToString("\n") { " ${it.describeForLog()}" } + } VideoInfo( id = id, title = extractor.name, url = extractor.url, uploader = extractor.uploaderName, - durationMs = extractor.length, + durationMs = secondsToMs(extractor.length), thumbnailUrl = extractor.thumbnails.first().url, // fallback to video streams if no audio streams are available audioStreams = audioStreams.ifEmpty { + logger.w { + "[Streams $id] no audio streams; FALLING BACK TO ${videoStreams.size} VIDEO streams " + + "(muxed video+audio, downloads will be video files):\n" + + extractor.videoStreams.joinToString("\n") { + " itag=${it.itag} format=${it.format?.name} res=${it.resolution} " + + "codec=${it.codec} videoOnly=${it.isVideoOnly} isUrl=${it.isUrl}" + } + } videoStreams }, videoStreams = videoStreams ) } } -} \ No newline at end of file +} + +/** + * NewPipe names AAC "mp4a.40.x" in an "m4a" container, but the plugin advertises (and users pick) + * "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded + * aac/mp4 DASH/HLS manifests always won instead. + */ +private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) { + DeliveryMethod.DASH -> StreamProtocol.DASH + DeliveryMethod.HLS -> StreamProtocol.HLS + else -> StreamProtocol.PROGRESSIVE +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 1ee6b8ae..9e832592 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -16,13 +16,19 @@ ~ along with this program. If not, see . --> - + + + diff --git a/composeApp/src/androidMain/ic_launcher-playstore.png b/composeApp/src/androidMain/ic_launcher-playstore.png new file mode 100644 index 00000000..5fd7f2f3 Binary files /dev/null and b/composeApp/src/androidMain/ic_launcher-playstore.png differ diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt similarity index 63% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt index f6c099fc..e8f0437b 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt @@ -15,14 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.deeplink.ExternalUriHandler +import org.spotube.spotube.core.newpipe.NewPipeDownloader +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.media.PlaybackService import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init @@ -31,9 +34,19 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) FileKit.init(this) - NewPipeDownloader.init(Paths(this)) + NewPipeDownloader.init(Paths()) + + // Plain start from the foreground; Media3 promotes PlaybackService itself once playback starts + startService(Intent(this, PlaybackService::class.java)) + + intent?.dataString?.let(ExternalUriHandler::onNewUri) setContent { App() } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.dataString?.let(ExternalUriHandler::onNewUri) + } } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt similarity index 77% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt index 306e4433..fff70175 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/MyApplication.kt @@ -15,13 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import android.app.Application -import android.content.Intent -import android.os.Build -import dev.krtirtho.spotube.core.di.initKoin -import dev.krtirtho.spotube.media.PlaybackService +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.paths.Paths import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent { override fun onCreate() { super.onCreate() + Paths.init(this) initKoin { androidContext(this@MyApplication) } - val intent = Intent(this, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(intent) - } else { - startService(intent) - } } override fun onTerminate() { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt similarity index 97% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt index cded046f..762ec487 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/Platform.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import android.content.Context import android.content.Intent diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt similarity index 78% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index 899cd58f..2bda1426 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import android.content.Context import android.content.Intent -import android.os.Build +import android.util.Log import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer -import dev.krtirtho.spotube.media.PlaybackService +import org.spotube.spotube.media.PlaybackService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -51,11 +51,16 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val appContext: Context = (context as Context).applicationContext private fun ensureServiceStarted() { - val intent = Intent(appContext, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - appContext.startForegroundService(intent) - } else { - appContext.startService(intent) + // A plain start, never startForegroundService: that obliges the service to call + // startForeground() within seconds of *every* call, and once Media3 has demoted it + // on pause nothing does, so the app is killed (ForegroundServiceDidNotStartInTime). + // Media3's MediaSessionService promotes itself to the foreground when playback starts. + // Starting from the background (jam sync, remote control) throws; playback still runs + // in-process without the service, so never let that crash the app. + try { + appContext.startService(Intent(appContext, PlaybackService::class.java)) + } catch (e: Exception) { + Log.w("AudioPlayer", "Failed to start playback service", e) } } @@ -75,6 +80,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private var disposed = false private val currentPlaylist = mutableListOf() + private val originalPlaylist = mutableListOf() + private var shuffleEnabled = false private val urlIndexMap = mutableMapOf() private val _playerState = MutableStateFlow(PlayerState.IDLE) @@ -134,10 +141,6 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface ) } - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - _shuffleMode.tryEmit(shuffleModeEnabled) - } - override fun onVolumeChanged(volume: Float) { _volume.tryEmit(volume) } @@ -195,7 +198,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface .setArtist(artist) .setAlbumTitle(album) .apply { - if (coverURL.isNotBlank()) { + if (coverBytes != null) { + setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER) + } else if (coverURL.isNotBlank()) { setArtworkUri(android.net.Uri.parse(coverURL)) } } @@ -226,7 +231,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun seekTo(position: Duration) { withContext(Dispatchers.Main) { - val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration) + val duration = exoPlayer.duration + val targetMs = if (duration > 0) { + position.inWholeMilliseconds.coerceIn(0, duration) + } else { + position.inWholeMilliseconds.coerceAtLeast(0) + } exoPlayer.seekTo(targetMs) _position.tryEmit(exoPlayer.currentPosition.milliseconds) } @@ -246,8 +256,37 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun shuffle(enabled: Boolean) { withContext(Dispatchers.Main) { - exoPlayer.shuffleModeEnabled = enabled + if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return@withContext + + val currentIndex = exoPlayer.currentMediaItemIndex + val current = currentPlaylist.getOrNull(currentIndex) ?: return@withContext + + val rest = if (enabled) { + originalPlaylist.filterNot { it.url == current.url }.shuffled() + } else { + originalPlaylist.filterNot { it.url == current.url } + } + // Disabling restores original order around the current item. + val before = if (enabled) emptyList() else originalPlaylist.takeWhile { it.url != current.url } + val after = if (enabled) rest else originalPlaylist.dropWhile { it.url != current.url }.drop(1) + + // Reorder in place around the playing item so playback is not interrupted. + if (currentIndex < exoPlayer.mediaItemCount - 1) { + exoPlayer.removeMediaItems(currentIndex + 1, exoPlayer.mediaItemCount) + } + if (currentIndex > 0) exoPlayer.removeMediaItems(0, currentIndex) + exoPlayer.addMediaItems(0, before.map { it.toExoMediaItem() }) + exoPlayer.addMediaItems(after.map { it.toExoMediaItem() }) + + currentPlaylist.clear() + currentPlaylist.addAll(before + current + after) + urlIndexMap.clear() + currentPlaylist.forEachIndexed { i, item -> urlIndexMap[item.url] = i } + + shuffleEnabled = enabled _shuffleMode.tryEmit(enabled) + _playlist.tryEmit(currentPlaylist.toList()) + syncCurrentItem() } } @@ -259,6 +298,10 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface withContext(Dispatchers.Main) { currentPlaylist.clear() currentPlaylist.addAll(playlist) + originalPlaylist.clear() + originalPlaylist.addAll(playlist) + shuffleEnabled = false + _shuffleMode.tryEmit(false) urlIndexMap.clear() playlist.forEachIndexed { index, item -> urlIndexMap[item.url] = index @@ -291,6 +334,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun addMediaItem(mediaItem: MediaItem) { withContext(Dispatchers.Main) { currentPlaylist.add(mediaItem) + originalPlaylist.add(mediaItem) urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex _playlist.tryEmit(currentPlaylist.toList()) exoPlayer.addMediaItem(mediaItem.toExoMediaItem()) @@ -303,6 +347,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size) currentPlaylist.add(insertIndex, mediaItem) + val origCurrent = currentPlaylist.getOrNull(currentIndex)?.let { c -> originalPlaylist.indexOfFirst { it.url == c.url } } ?: -1 + originalPlaylist.add((origCurrent + 1).coerceIn(0, originalPlaylist.size), mediaItem) urlIndexMap.clear() currentPlaylist.forEachIndexed { index, item -> urlIndexMap[item.url] = index @@ -317,6 +363,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface withContext(Dispatchers.Main) { val index = urlIndexMap[mediaItem.url] ?: return@withContext currentPlaylist.removeAt(index) + originalPlaylist.removeAll { it.url == mediaItem.url } urlIndexMap.clear() currentPlaylist.forEachIndexed { i, item -> urlIndexMap[item.url] = i @@ -332,6 +379,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface val item = currentPlaylist.removeAt(fromIndex) currentPlaylist.add(toIndex, item) + if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) { + originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex)) + } urlIndexMap.clear() currentPlaylist.forEachIndexed { i, it -> urlIndexMap[it.url] = i @@ -384,6 +434,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface disposed = true exoPlayer.release() currentPlaylist.clear() + originalPlaylist.clear() urlIndexMap.clear() _playerState.tryEmit(PlayerState.IDLE) diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt similarity index 70% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt index 1ee43348..a6e66693 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/di/Modules.android.kt @@ -15,21 +15,21 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di import android.content.Context -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.share.AndroidShareService -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.media.MediaBrowseHelper -import dev.krtirtho.spotube.modules.library.local_tracks.media.AndroidLocalMediaDiscoveryService -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.AndroidShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.media.MediaBrowseHelper +import org.spotube.spotube.modules.library.local_tracks.media.AndroidLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService import org.koin.dsl.module actual val platformModules = module { - single { Paths(get()) } + single { Paths() } single { AudioPlayer(get()) } single { AndroidLocalMediaDiscoveryService(get()) } single { AndroidShareService(get()) } @@ -45,7 +45,6 @@ actual val platformModules = module { collectionPlaybackHelper = get(), audioPlayerQueue = get(), settingsRepository = get(), - blacklistRepository = get(), - ) + blacklistRepository = get()) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Greeting.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt similarity index 80% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Greeting.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt index 5d53f202..2f73a824 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Greeting.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt @@ -15,12 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube.core.discovery -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file +// The OS's own service-discovery stack advertises on the right network. +internal actual fun preferredAdvertiseAddress(): String? = null diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt new file mode 100644 index 00000000..ba5f18c9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -0,0 +1,48 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +/** + * On Android 13+ (and especially 16+ where it became a runtime permission), + * mDNS/NSD discovery requires `NEARBY_WIFI_DEVICES`. Requests it when the + * returned lambda is invoked; the caller decides when (e.g. first visit to the + * Devices screen). + */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ } + + return { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED + if (!granted) { + launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt new file mode 100644 index 00000000..3676717e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.android.kt @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.i18n + +import android.content.res.Configuration +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import java.util.Locale + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + // System resources are never touched by the override below, so they always report the + // device language (also after the user changes it while the app is running). + val locale = languageTag?.let(Locale::forLanguageTag) + ?: Resources.getSystem().configuration.locales[0] + // Compose resources read Locale.getDefault(); also used by non-UI code (notifications). + Locale.setDefault(locale) + + val configuration = Configuration(LocalConfiguration.current).apply { setLocale(locale) } + val resources = LocalContext.current.resources + @Suppress("DEPRECATION") + resources.updateConfiguration(configuration, resources.displayMetrics) + return LocalConfiguration provides configuration +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt new file mode 100644 index 00000000..45d18513 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt @@ -0,0 +1,37 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import android.content.res.Resources +import android.os.Build +import java.util.Locale +import java.util.TimeZone + +internal actual fun jamDeviceTraits(): List { + val display = Resources.getSystem().displayMetrics + return listOf( + "android", + Build.MANUFACTURER, + Build.MODEL, + Build.DEVICE, + Build.VERSION.RELEASE, + Build.VERSION.SDK_INT.toString(), + "${minOf(display.widthPixels, display.heightPixels)}x${maxOf(display.widthPixels, display.heightPixels)}@${display.densityDpi}", + Locale.getDefault().toLanguageTag(), + TimeZone.getDefault().id) +} diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt similarity index 77% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt index e8f172ce..dfee7da5 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/paths/Paths.android.kt @@ -15,14 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths import android.content.Context import android.os.Environment -actual class Paths( - val context: Context -) { +actual class Paths { + private val context: Context + get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" } + actual fun getApplicationCacheDirPath(): String { return context.cacheDir.absolutePath } @@ -38,4 +39,13 @@ actual class Paths( actual fun getMusicCacheDirPath(): String { return context.cacheDir.absolutePath + "/music_cache" } + + companion object { + @Volatile + private var appContext: Context? = null + + fun init(context: Context) { + appContext = context.applicationContext + } + } } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/share/AndroidShareService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt similarity index 72% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/share/AndroidShareService.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt index 4624ff6d..f75039e5 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/share/AndroidShareService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/share/AndroidShareService.kt @@ -15,18 +15,22 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share import android.content.Context +import android.content.Intent import androidx.core.app.ShareCompat -import dev.krtirtho.spotube.core.share.ShareService +import org.spotube.spotube.core.share.ShareService class AndroidShareService(private val context: Context) : ShareService { override fun share(url: String, title: String) { - ShareCompat.IntentBuilder(context) + // context is the application context (Koin), which can only start activities in a new task + val chooser = ShareCompat.IntentBuilder(context) .setType("text/plain") .setSubject(title) .setText(url) - .startChooser() + .createChooserIntent() + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt similarity index 94% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt index 52b71013..2c05aec3 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import android.webkit.CookieManager import android.webkit.WebStorage diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt similarity index 96% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt index b05bcc5e..3591c937 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.Executors diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt similarity index 89% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt index cd37edbc..57533940 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt @@ -15,32 +15,43 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.media +package org.spotube.spotube.media import android.net.Uri import android.os.Bundle import android.util.Log import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -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.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.modules.album.AlbumRepository -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.home.HomeScreenRepository -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.playlist.PlaylistRepository -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository -import dev.krtirtho.spotube.modules.search.SearchRepository -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.home.HomeScreenRepository +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.modules.search.SearchRepository +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.flow.first +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.library_toolbar_compact_title +import spotube.composeapp.generated.resources.media_browser_browse +import spotube.composeapp.generated.resources.media_browser_featured +import spotube.composeapp.generated.resources.media_browser_now_playing +import spotube.composeapp.generated.resources.media_browser_saved_tracks +import spotube.composeapp.generated.resources.media_browser_top_tracks class MediaBrowseHelper( private val pluginManager: PluginManager, @@ -53,8 +64,7 @@ class MediaBrowseHelper( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val settingsRepository: SettingsRepository, - private val blacklistRepository: BlacklistRepository, -) { + private val blacklistRepository: BlacklistRepository) { companion object { const val MEDIA_ID_ROOT = "root" @@ -222,13 +232,13 @@ class MediaBrowseHelper( } } - private fun buildRootChildren(): List { + private suspend fun buildRootChildren(): List { return listOf( MediaItem.Builder() .setMediaId(MEDIA_ID_BROWSE) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Browse") + .setTitle(getString(Res.string.media_browser_browse)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -238,7 +248,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_LIBRARY) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Library") + .setTitle(getString(Res.string.library_toolbar_compact_title)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -248,13 +258,12 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_QUEUE) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Now Playing") + .setTitle(getString(Res.string.media_browser_now_playing)) .setIsBrowsable(true) .setIsPlayable(false) .build() ) - .build(), - ) + .build()) } private suspend fun buildBrowseItems(): List { @@ -266,7 +275,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_BROWSE_FEATURED) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Featured") + .setTitle(getString(Res.string.media_browser_featured)) .setIsBrowsable(true) .setIsPlayable(false) .setExtras(gridExtras) @@ -355,7 +364,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_PLAYLISTS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Playlists") + .setTitle(getString(Res.string.library_tab_playlists)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -365,7 +374,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_ALBUMS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Albums") + .setTitle(getString(Res.string.library_tab_albums)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -375,7 +384,7 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_ARTISTS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Artists") + .setTitle(getString(Res.string.library_tab_artists)) .setIsBrowsable(true) .setIsPlayable(false) .build() @@ -385,13 +394,12 @@ class MediaBrowseHelper( .setMediaId(MEDIA_ID_SAVED_TRACKS) .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Saved Tracks") + .setTitle(getString(Res.string.media_browser_saved_tracks)) .setIsPlayable(false) .setIsBrowsable(true) .build() ) - .build(), - ) + .build()) } private suspend fun buildSavedPlaylistsItems(): List { @@ -460,7 +468,7 @@ class MediaBrowseHelper( .setMediaId("$MEDIA_ID_ARTIST_TRACKS:$artistId") .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Top Tracks") + .setTitle(getString(Res.string.media_browser_top_tracks)) .setIsPlayable(false) .setIsBrowsable(true) .build() @@ -470,13 +478,12 @@ class MediaBrowseHelper( .setMediaId("$MEDIA_ID_ARTIST_ALBUMS:$artistId") .setMediaMetadata( MediaMetadata.Builder() - .setTitle("Albums") + .setTitle(getString(Res.string.library_tab_albums)) .setIsBrowsable(true) .setIsPlayable(false) .build() ) - .build(), - ) + .build()) } private suspend fun buildArtistTracksItems(artistId: String): List { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt similarity index 79% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt index 6db62be6..fb1fc057 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.media +package org.spotube.spotube.media import android.annotation.SuppressLint import android.app.NotificationChannel @@ -23,12 +23,14 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import android.os.Build +import android.os.Process import android.util.Log import androidx.annotation.OptIn import androidx.core.app.NotificationCompat import androidx.media3.common.MediaItem as Media3MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams @@ -40,11 +42,12 @@ import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture -import dev.krtirtho.spotube.MainActivity -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.MainActivity +import org.spotube.spotube.R +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -52,9 +55,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.notification_playback_channel_description +import spotube.composeapp.generated.resources.notification_playback_channel_name @OptIn(UnstableApi::class) class PlaybackService : MediaLibraryService(), KoinComponent { + private var taskRemoved = false + private val audioPlayer = inject().value as AudioPlayer private val audioPlayerQueue: AudioPlayerQueue by inject() @@ -69,6 +78,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent { createNotificationChannel() + // Media3 posts the real playback notification; point it at our channel, + // ID and monochrome icon so it replaces the placeholder below in place. + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setChannelId(CHANNEL_ID) + .setNotificationId(NOTIFICATION_ID) + .build() + .apply { setSmallIcon(R.drawable.ic_stat_spotube) } + ) + val sessionActivity = PendingIntent.getActivity( this, 0, @@ -78,15 +97,25 @@ class PlaybackService : MediaLibraryService(), KoinComponent { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + // Short-lived placeholder until Media3 posts the playback notification. No localized text: + // loading it here would block the main thread (see createNotificationChannel). val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Spotube") - .setContentText("Playback service") - .setSmallIcon(android.R.drawable.ic_media_play) + .setSmallIcon(R.drawable.ic_stat_spotube) .setContentIntent(sessionActivity) .setOngoing(true) .build() - startForeground(NOTIFICATION_ID, notification) + // On Android 15+ a service created from the background (e.g. by a jam + // sync or queue restoration) hits this in startForeground instead of at + // the startForegroundService call site. Don't crash: playback keeps + // running in-process, just without the notification until the app is + // foregrounded and the service can start properly. + try { + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Log.w(TAG, "startForeground not allowed; continuing without notification", e) + } librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) @@ -98,19 +127,32 @@ class PlaybackService : MediaLibraryService(), KoinComponent { Log.i(TAG, "Library session created") } + /** + * The channel must exist before startForeground, so it's created right away under the app name + * and renamed once the localized strings load. + * + * Never `runBlocking { getString(...) }` here: onCreate runs on the main thread, and Compose + * resources share one in-flight load per strings file. If App()'s stringResource started that + * load on the main dispatcher first, blocking main to await it deadlocks the app on its + * splash screen. + */ @SuppressLint("ObsoleteSdkInt") private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "Playback", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Media playback controls" + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + fun channel(name: String, description: String? = null) = + NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply { + this.description = description setShowBadge(false) } - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) + + manager.createNotificationChannel(channel("Spotube")) + serviceScope.launch { + // Re-creating a channel with the same ID updates its name and description. + manager.createNotificationChannel( + channel( + name = getString(Res.string.notification_playback_channel_name), + description = getString(Res.string.notification_playback_channel_description))) } } @@ -122,9 +164,13 @@ class PlaybackService : MediaLibraryService(), KoinComponent { removeSession(librarySession) librarySession.release() super.onDestroy() + // Swiping the app out of recents should fully close it: Android otherwise keeps this + // process cached with the proxy server, jam client and every singleton still alive. + if (taskRemoved) Process.killProcess(Process.myPid()) } override fun onTaskRemoved(rootIntent: Intent?) { + taskRemoved = true pauseAllPlayersAndStopSelf() } @@ -354,20 +400,20 @@ class PlaybackService : MediaLibraryService(), KoinComponent { when { mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") -> { val playlistId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") - val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playPlaylist(playlistId) return } mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> { val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") - val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playAlbum(albumId) return } mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> { - val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject() + val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject() helper.playSavedTracks() return } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt similarity index 66% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt index 56de67de..672d70f4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt @@ -15,19 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.downloads -import kotlinx.coroutines.flow.MutableStateFlow +import android.content.Context +import android.media.MediaScannerConnection +import org.koin.core.context.GlobalContext -class LibraryState { - val searchMode = MutableStateFlow(false) - val currentTab = MutableStateFlow(LibraryTab.Playlists) - - fun onTabSelected(tab: LibraryTab) { - currentTab.value = tab - } - - fun setSearchMode(enabled: Boolean) { - searchMode.value = enabled - } +internal actual fun addToMediaLibrary(path: String) { + val context = GlobalContext.get().get() + MediaScannerConnection.scanFile(context, arrayOf(path), null, null) } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt similarity index 56% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt index 228b7daf..f5d1d118 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import android.Manifest import android.content.Context @@ -25,25 +25,60 @@ import android.os.Build import android.os.Environment import android.os.Handler import android.os.Looper +import android.graphics.BitmapFactory +import android.graphics.Bitmap import android.provider.MediaStore -import dev.krtirtho.spotube.core.di.injectLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap +import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent import java.io.File import java.util.Locale class AndroidLocalMediaDiscoveryService( - private val context: Context, -) : LocalMediaDiscoveryService, KoinComponent { + private val context: Context) : LocalMediaDiscoveryService, KoinComponent { val logger by injectLogger() - override suspend fun discoverFolders(roots: List): List { + private class TagInfo( + val title: String?, + val artists: List, + val album: String?, + val durationMs: Long, + val cover: ByteArray?) + + private class PendingTrack( + val uri: String, + val filePath: String?, + val modifiedAt: Long, + val folderPath: String, + val fallbackName: String, + val mediaStoreArtists: List, + val mediaStoreAlbum: String?, + val mediaStoreDurationMs: Long) + + // Parsing tags + cover with lofty is the expensive part, so keep results across rescans. + private val tagCache = ConcurrentHashMap() + + override suspend fun discoverFolders(roots: List): List = withContext(Dispatchers.IO) { + discoverFoldersBlocking(roots) + } + + private suspend fun discoverFoldersBlocking(roots: List): List { logger.i { "discoverFolders: start roots=${roots.size}" } if (!hasReadPermission()) { logger.w { "discoverFolders: missing media permission, returning empty result" } return emptyList() } - val tracksByFolder = linkedMapOf>() + val pending = mutableListOf() + val knownFilePaths = hashSetOf() val resolver = context.contentResolver val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI var totalRows = 0 @@ -60,7 +95,8 @@ class AndroidLocalMediaDiscoveryService( MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.MIME_TYPE, - ) + if (Build.VERSION.SDK_INT >= 29) { + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.DATE_MODIFIED) + if (Build.VERSION.SDK_INT >= 29) { arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) } else { emptyArray() @@ -79,6 +115,8 @@ class AndroidLocalMediaDiscoveryService( val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE) val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH) + val dataCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val modifiedCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED) logger.d { "discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)" @@ -120,21 +158,23 @@ class AndroidLocalMediaDiscoveryService( .takeUnless { it.equals("", true) } val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() } val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L) + val filePath = if (dataCol >= 0) cursor.getString(dataCol)?.takeIf { it.isNotBlank() } else null + val modifiedAt = if (modifiedCol >= 0) cursor.getLong(modifiedCol) else 0L + filePath?.let(knownFilePaths::add) val name = rawTitle.ifBlank { displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName } - val track = LocalMediaTrack( - path = uri, - name = name.ifBlank { "Unknown track" }, - artists = listOfNotNull(artist), - durationMs = durationMs, - album = album, - coverBytes = null, - ) - - tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) + pending += PendingTrack( + uri = uri, + filePath = filePath, + modifiedAt = modifiedAt, + folderPath = folderPath, + fallbackName = name.ifBlank { "Unknown track" }, + mediaStoreArtists = listOfNotNull(artist), + mediaStoreAlbum = album, + mediaStoreDurationMs = durationMs) acceptedRows += 1 } } ?: run { @@ -145,21 +185,13 @@ class AndroidLocalMediaDiscoveryService( logger.w { "discoverFolders: MediaStore query returned null cursor" } } - val mediaStoreFolders = tracksByFolder.entries - .map { (folderPath, tracks) -> - LocalMediaFolder( - path = folderPath, - name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) - } - .sortedBy { it.name.lowercase(Locale.getDefault()) } + // Files MediaStore already indexed must not be listed a second time by the filesystem scan. + discoverFileSystemFallback(roots, knownFilePaths, pending) - val fallbackFolders = discoverFileSystemFallback(roots) - val merged = mergeFolders(mediaStoreFolders, fallbackFolders) + val merged = buildFolders(pending) logger.i { - "discoverFolders: done folders=${merged.size} mediaStoreFolders=${mediaStoreFolders.size} fallbackFolders=${fallbackFolders.size} tracks=$acceptedRows totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows" + "discoverFolders: done folders=${merged.size} mediaStoreTracks=$acceptedRows tracks=${pending.size} totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows" } if (merged.isNotEmpty()) { @@ -172,8 +204,7 @@ class AndroidLocalMediaDiscoveryService( override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { logger.i { "observeChanges: start roots=${roots.size}" } if (!hasReadPermission()) { logger.w { "observeChanges: missing media permission, observer not registered" } @@ -190,8 +221,7 @@ class AndroidLocalMediaDiscoveryService( context.contentResolver.registerContentObserver( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, - observer, - ) + observer) logger.i { "observeChanges: observer registered" } @@ -227,58 +257,109 @@ class AndroidLocalMediaDiscoveryService( return extension in SUPPORTED_EXTENSIONS } - private fun discoverFileSystemFallback(roots: List): List { + private fun discoverFileSystemFallback( + roots: List, + knownFilePaths: Set, + out: MutableList) { val candidateRoots = resolveCandidateRoots(roots) if (candidateRoots.isEmpty()) { logger.d { "discoverFileSystemFallback: no candidate roots" } - return emptyList() + return } - - logger.d { - "discoverFileSystemFallback: scanning roots=${candidateRoots.joinToString()}" - } - - val tracksByFolder = linkedMapOf>() - var scannedFiles = 0 - var acceptedFiles = 0 + val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/" + val seen = hashSetOf() + var added = 0 candidateRoots.forEach { root -> scanDirectory(root) { file -> - scannedFiles += 1 - val extension = file.extension.lowercase(Locale.getDefault()) - if (extension !in SUPPORTED_EXTENSIONS) return@scanDirectory - acceptedFiles += 1 + if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory + val absolute = file.absolutePath + if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory - val folderPath = file.parentFile?.absolutePath ?: "Unknown" - val name = file.nameWithoutExtension.ifBlank { file.name } - val track = LocalMediaTrack( - path = file.absolutePath, - name = name, - artists = emptyList(), - durationMs = 0L, - album = null, - coverBytes = null, - ) - - tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) + // Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge. + val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/" + val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent + out += PendingTrack( + uri = absolute, + filePath = absolute, + modifiedAt = file.lastModified() / 1000, + folderPath = folderPath, + fallbackName = file.nameWithoutExtension.ifBlank { file.name }, + mediaStoreArtists = emptyList(), + mediaStoreAlbum = null, + mediaStoreDurationMs = 0L) + added += 1 } } + logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" } + } - val folders = tracksByFolder.entries - .map { (folderPath, tracks) -> + private suspend fun buildFolders(pending: List): List { + val permits = Semaphore(4) + val tracks = coroutineScope { + pending.map { item -> + async(Dispatchers.IO) { + val tag = permits.withPermit { readTags(item) } + item.folderPath to LocalMediaTrack( + path = item.uri, + name = tag?.title?.takeIf { it.isNotBlank() } ?: item.fallbackName, + artists = tag?.artists?.takeIf { it.isNotEmpty() } ?: item.mediaStoreArtists, + durationMs = tag?.durationMs?.takeIf { it > 0 } ?: item.mediaStoreDurationMs, + album = tag?.album?.takeIf { it.isNotBlank() } ?: item.mediaStoreAlbum, + coverBytes = tag?.cover) + } + }.awaitAll() + } + return tracks.groupBy({ it.first }, { it.second }) + .map { (path, list) -> LocalMediaFolder( - path = folderPath, - name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + path = path, + name = normalizeFolderName(path), + tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } + } - logger.i { - "discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}" + private fun readTags(item: PendingTrack): TagInfo? { + val path = item.filePath ?: return null + val key = "$path:${item.modifiedAt}" + tagCache[key]?.let { return it } + val file = File(path) + if (!file.isFile || !file.canRead()) return null + val info = try { + val meta = uniffi.compose_app.readAudioMetadata(path) + TagInfo( + title = meta.title, + artists = meta.artists, + album = meta.album, + durationMs = meta.durationMs, + cover = meta.coverBytes?.let(::thumbnail)) + } catch (t: Throwable) { + logger.d { "readTags: failed path=$path reason=${t.message}" } + null } + if (info != null) tagCache[key] = info + return info + } - return folders + // Embedded covers can be several MB; the cache is serialised as JSON, so keep a small JPEG. + private fun thumbnail(bytes: ByteArray): ByteArray? { + if (bytes.isEmpty()) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (bounds.outWidth / (sample * 2) >= THUMBNAIL_SIZE && bounds.outHeight / (sample * 2) >= THUMBNAIL_SIZE) sample *= 2 + val decoded = BitmapFactory.decodeByteArray( + bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null + val scale = THUMBNAIL_SIZE.toFloat() / minOf(decoded.width, decoded.height) + val bitmap = if (scale < 1f) Bitmap.createScaledBitmap( + decoded, (decoded.width * scale).toInt().coerceAtLeast(1), + (decoded.height * scale).toInt().coerceAtLeast(1), true) else decoded + return ByteArrayOutputStream().use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) + it.toByteArray() + } } private fun resolveCandidateRoots(roots: List): List { @@ -292,8 +373,7 @@ class AndroidLocalMediaDiscoveryService( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - ) + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) return (configured + defaults) .map { it.absoluteFile } @@ -318,37 +398,8 @@ class AndroidLocalMediaDiscoveryService( } } - private fun mergeFolders( - mediaStoreFolders: List, - fallbackFolders: List, - ): List { - if (fallbackFolders.isEmpty()) return mediaStoreFolders - if (mediaStoreFolders.isEmpty()) return fallbackFolders - - val merged = linkedMapOf>() - val seenTrackPaths = mutableSetOf() - - (mediaStoreFolders + fallbackFolders).forEach { folder -> - val bucket = merged.getOrPut(folder.path) { mutableListOf() } - folder.tracks.forEach { track -> - if (seenTrackPaths.add(track.path)) { - bucket.add(track) - } - } - } - - return merged.entries - .map { (path, tracks) -> - LocalMediaFolder( - path = path, - name = normalizeFolderName(path), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) - } - .sortedBy { it.name.lowercase(Locale.getDefault()) } - } - companion object { + private const val THUMBNAIL_SIZE = 160 private val SUPPORTED_EXTENSIONS = setOf( "mp3", "m4a", @@ -357,7 +408,6 @@ class AndroidLocalMediaDiscoveryService( "wav", "ogg", "opus", - "wma", - ) + "wma") } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt similarity index 88% rename from composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt rename to composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt index 65c44906..6b066575 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import android.Manifest import android.content.pm.PackageManager @@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { var isGranted by remember { mutableStateOf( - context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED, - ) + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) } val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> + contract = ActivityResultContracts.RequestPermission()) { granted -> isGranted = granted || context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED } return LocalMediaPermissionState( isGranted = isGranted, - requestPermission = { launcher.launch(permission) }, - ) + requestPermission = { launcher.launch(permission) }) } diff --git a/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png new file mode 100644 index 00000000..3366b209 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-hdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-mdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-mdpi/ic_stat_spotube.png new file mode 100644 index 00000000..34ece2c6 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-mdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 5c58c8bf..00000000 --- a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png new file mode 100644 index 00000000..c6c20ea4 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xhdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-xxhdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_stat_spotube.png new file mode 100644 index 00000000..c1e5e263 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_stat_spotube.png b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_stat_spotube.png new file mode 100644 index 00000000..65dff9e5 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_stat_spotube.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 9faa29bb..00000000 --- a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml index 2ad9808b..8fe69f1a 100644 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -17,6 +17,7 @@ --> - - - \ No newline at end of file + + + + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml index 2ad9808b..8fe69f1a 100644 --- a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -17,6 +17,7 @@ --> - - - \ No newline at end of file + + + + diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png index a571e600..a5172fdf 100644 Binary files a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..c179cb79 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..66caafd4 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png index 61da551c..f454ac1a 100644 Binary files a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png index c41dd285..11b63a79 100644 Binary files a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..f096f291 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..108f8d41 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png index db5080a7..4d1c0d9d 100644 Binary files a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png index 6dba46da..25ce076f 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..50fa2ecc Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..c74596a5 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png index da31a871..c4e0a2f1 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png index 15ac6817..f7f2f2ff 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..57bc7641 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..9814b717 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png index b216f2d3..08078439 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png index f25a4197..dd0287fc 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..92478998 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..2b749b28 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png index e96783cc..f49af5e3 100644 Binary files a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/values/colors.xml b/composeApp/src/androidMain/res/values/colors.xml new file mode 100644 index 00000000..19670936 --- /dev/null +++ b/composeApp/src/androidMain/res/values/colors.xml @@ -0,0 +1,21 @@ + + + + + #000000 + diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png new file mode 100644 index 00000000..9da9cb69 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_lrclib.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png new file mode 100644 index 00000000..3bcf0799 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/plugin_logo_newpipe.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png b/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png new file mode 100644 index 00000000..d46d9ce5 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png differ diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml new file mode 100644 index 00000000..cc82d900 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -0,0 +1,950 @@ + + + + قائمة الانتظار + مسح قائمة الانتظار + تصفية قائمة الانتظار... + لا توجد عناصر في قائمة الانتظار + + ابحث عن أغانٍ وفنانين وألبومات... + مسح السجل بالكامل + بحث... + + + مكتبتك + المكتبة + قوائم التشغيل + الألبومات + الفنانون + المقاطع المحلية + التنزيلات + ابحث في قوائم التشغيل المحفوظة… + ابحث في الألبومات المحفوظة… + ابحث في الفنانين المحفوظين… + ابحث في المجلدات أو المقاطع… + تصفية المكتبة + إغلاق التصفية + إنشاء قائمة تشغيل + تحديث المقاطع المحلية + التبديل إلى عرض الشبكة + التبديل إلى عرض القائمة + لا توجد قوائم تشغيل محفوظة + لا توجد قوائم تشغيل تطابق “%1$s” + لا توجد ألبومات محفوظة + لا توجد ألبومات تطابق “%1$s” + لا يوجد فنانون محفوظون + لا يوجد فنانون يطابقون “%1$s” + + + المجلدات + مجلد + %1$s • %2$s + + %1$d مقطع + مقطع واحد + %1$d مقطعان + %1$d مقاطع + %1$d مقطعًا + %1$d مقطع + + + %1$d مجلد + مجلد واحد + %1$d مجلدان + %1$d مجلدات + %1$d مجلدًا + %1$d مجلد + + العودة إلى المجلدات + تشغيل المجلد + يلزم الوصول إلى الصوت + اسمح بالوصول إلى الصوت لتصفح الموسيقى على جهازك. + منح الإذن + لم يتم العثور على مجلدات محلية + لا توجد مقاطع في هذا المجلد + لا توجد مجلدات أو مقاطع مطابقة + تعذّر تحديث الوسائط المحلية + + + %1$d نشط • %2$d مكتمل + مسح المكتملة + لا توجد تنزيلات بعد + جارٍ التنزيل %1$d% + في قائمة الانتظار + مكتمل + ملغى + فشل + فشل: %1$s + إلغاء التنزيل + إعادة محاولة التنزيل + إزالة التنزيل + + + فنان + محظور + المقاطع التي أعجبتك + أغانيك المحفوظة + فنان غير معروف + عنوان المقطع + اسم الفنان + + محدد + + الإعدادات + + الإضافات + اللغة والمنطقة + المظهر + التشغيل + التنزيلات + التخزين المؤقت + سطح المكتب + التحديثات + الدعم + حول + + إغلاق + حفظ + إلغاء + + اللغة + لغة واجهة التطبيق. الحالية: %1$s + افتراضي النظام + البلد + البلد المستخدم للمحتوى والتوفر. الحالي: %1$s + %1$s (%2$s) + + السمة + اختر كيف يتوافق Spotube مع مظهر نظامك. الحالية: %1$s + فاتح + داكن + النظام + + السطح + مادة خلفية التطبيق. الحالية: %1$s + مائي + جليدي + كروم + أكريليك + زجاج + + لون التمييز + لوحة الألوان الحالية: %1$s + لون التمييز + اختر لوحة ألوان تمييز واعرض معاينتها في السمتين الفاتحة والداكنة. + فاتح + داكن + معاينة + زر + أخضر غوبلن + بنفسجي كهربائي + سماوي محيطي + برتقالي الغروب + حديقة الورد + أزرق منتصف الليل + أردوازي معدني + + الشكل والمظهر + مادة السطح ولون التمييز. الحالي: %1$s · %2$s + الشكل والمظهر + اختر مادة السطح، ثم لون تمييز يتناسب معها. كل خيار هو تركيبة منتقاة بعناية. + السطح + لون التمييز + ثانوي + + لوحة الألوان + لوحة ألوان تمييز Millennium. الحالية: %1$s + لوحة الألوان + اختر لوحة ألوان تمييز Millennium واعرض معاينتها في الوضعين الفاتح والداكن. + لوحة الألوان + لوحة ألوان Millennium ولون التمييز. الحالية: %1$s + أزرق + برتقالي + ليموني + + تخصيص مجلد التنزيل + المجلد الحالي: %1$s + اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي. + C:\Music\Spotube + مجلدات الوسائط المحلية + مجلدات مخصصة للفحص على سطح المكتب. المضافة: %1$d + لم تتم إضافة أي مجلد مخصص بعد + مجلدات مخصصة لمكتبة موسيقاك المحلية. + إدارة + إضافة مجلد + إزالة المجلد + لم تتم إضافة مجلدات مخصصة. + إزالة %1$s من قائمة الفحص + يتم تضمين مجلد التنزيل ومجلد ذاكرة التخزين المؤقت للموسيقى تلقائيًا. + يتم تضمين مجلد التنزيل تلقائيًا. + يتم تضمين مجلد ذاكرة التخزين المؤقت للموسيقى تلقائيًا. + جارٍ الحفظ… + تعذّر حفظ المجلدات. لا تزال تغييراتك موجودة؛ حاول مرة أخرى. + + %1$d مجلد + مجلد واحد + %1$d مجلدان + %1$d مجلدات + %1$d مجلدًا + %1$d مجلد + + صيغة تنزيل الموسيقى + الترميز والحاوية المفضلان للتنزيلات. الحالي: %1$s + جودة تنزيل الموسيقى + + صيغة بث الموسيقى + الترميز والحاوية المفضلان لبث الموسيقى. الحالي: %1$s + جودة بث الموسيقى + الحالي: %1$s + تفعيل التخزين المؤقت للموسيقى + خزّن الصوت الذي يتم بثه محليًا لتسريع إعادة التشغيل. + مجلد ذاكرة التخزين المؤقت + الحالي: %1$s + الحالي: ذاكرة التخزين المؤقت الافتراضية للتطبيق + الحد الأقصى لحجم ذاكرة التخزين المؤقت + بلا حد + الحد الحالي: %1$d MB + حدد الحجم الأقصى لذاكرة التخزين المؤقت بوحدة MB. أدخل 0 أو اتركه فارغًا لعدم وضع حد. + 500 + يجب أن يكون الحجم 0 أو رقمًا موجبًا. + تفعيل التشغيل اللانهائي + واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار. + منفذ خادم وكيل التشغيل + السماح بالتحكم عن بُعد + اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها. + اسم الجهاز للتحكم عن بُعد + الاسم الذي تراه الأجهزة الأخرى عند اكتشاف هذا الجهاز. الحالي: %1$s + اختر اسمًا مألوفًا يظهر للأجهزة الأخرى على شبكتك عند اكتشافها لنسخة Spotube هذه. + مثال: Spotube غرفة المعيشة + اسم مضيف الجهاز + المنفذ الذي يستخدمه خادم وكيل التشغيل. الحالي: %1$d + اختر منفذًا بين 1 و65535. + 14769 + أدخل عددًا صحيحًا صالحًا. + يجب أن يكون المنفذ بين 1 و65535. + + قائمة الحظر + إدارة المقاطع والفنانين المحظورين + + التصغير إلى شريط النظام + إبقاء Spotube قيد التشغيل في شريط النظام عند تصغير النافذة. + حالة Discord التفاعلية + اعرض حالة التشغيل الحالية في Discord على سطح المكتب. + + التحقق من التحديثات تلقائيًا + البحث عن إصدارات Spotube الجديدة في الخلفية. + + إدارة الإضافات + أدِر إضافاتك + إضافة %1$s الافتراضية + البيانات الوصفية + الصوت + كلمات الأغاني + تسجيل الاستماع + لم يتم تحديد إضافة + تغيير + تحديد + محددة + مسح التحديد + لا تتوفر إضافات + إضافة %1$s + مسح + + ضبط + مدير الإضافات + يُرجى إدخال URL + يجب أن يبدأ URL بـ http:// أو https:// + تعذّر تنزيل الإضافة + لم يتم تثبيت أي إضافات بعد + الصق رابط تنزيل أو اختر ملفًا أعلاه. + + %1$d إضافة مثبتة + إضافة واحدة مثبتة + %1$d إضافتان مثبتتان + %1$d إضافات مثبتة + %1$d إضافة مثبتة + %1$d إضافة مثبتة + + تثبيت إضافة + https://example.com/plugin.smplug + تنزيل + التثبيت من ملف (.smplug) + نشطة + v%1$s + مدمجة + إزالة الإضافة + تسجيل الدخول + تسجيل الخروج + التنزيل من URL + التثبيت من ملف + تثبيت + + • %1$s + الأذونات المطلوبة + لا تتطلب هذه الإضافة أي أذونات خاصة. + المثبتة مقابل المقدَّمة + المثبتة + المقدَّمة + API %1$s → %2$s + التخزين الدائم + قراءة البيانات وكتابتها على القرص + طلبات الشبكة + إرسال البيانات واستقبالها عبر الإنترنت + WebView + عرض محتوى الويب داخل التطبيق + الجام الجماعي + اختبار الاتصال + جارٍ الاختبار… + تم الاتصال خلال %1$d ms + فشل: %1$s + الاتصال + التشخيص + + + الجام الجماعي + إنشاء + انضمام + أنشئ غرفة، ثم شارك رمزها مع أصدقائك. + إنشاء غرفة + الانضمام إلى غرفة + جارٍ الاتصال… + رمز الغرفة + ABC123 + متصل + جارٍ إعادة الاتصال… + نسخ الرمز + تم النسخ! + مشاركة + المشاركون (%1$d) + المضيف + مستمع + في انتظار المستمعين… + إزالة + حظر + إدارة + مغادرة الغرفة + تعذّر الاتصال + تجاهل + تعذّر إنشاء جلسة الجام + تعذّر الانضمام إلى جلسة الجام + أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام. + انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s + خادم الغرف + رابط الخادم + أدخل رابطًا يبدأ بـ https:// + تحقّق من خادم الغرف قبل بدء غرفة. + يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر الخادم من github.com/team-spotube/jam-server وأدخل رابطه. + أنت + الخادم: %1$s + حدّد خادم غرف في الإعدادات لاستضافة غرفة أو الانضمام إليها. + إعدادات الخادم + %1$s (أنت) + اسمك + يراك الآخرون في الجام باسم %1$s + يظهر لجميع من في الجام. اتركه فارغًا لاستخدام %1$s. يُطبَّق الاسم الجديد في المرة القادمة التي تنشئ فيها غرفة أو تنضم إليها. + استخدم %1$d حرفًا كحد أقصى. + لم يتم إعداد خادم للجام + تعذّر الوصول إلى خادم الجام. تحقّق من الشبكة أو من رابط الخادم في الإعدادات ← الجام الجماعي. + يعمل خادم الجام بإصدار غير متوافق. + لا توجد غرفة بهذا الرمز. تحقّق منه وحاول مجددًا. + هذه الغرفة ممتلئة. + حظرك المضيف من هذه الغرفة. + أزالك المضيف من الغرفة. + فُتحت هذه الغرفة مجددًا على هذا الجهاز، لذا أُغلق هذا الاتصال. + + الانتقال إلى الألبوم + مؤقت النوم + تفاصيل المقطع + تشغيل + إيقاف مؤقت + لا شيء قيد التشغيل + إضافة إلى قائمة الانتظار + + تجاهل + تحديث + يتوفر Spotube %1$s + اطّلع على الجديد في هذا الإصدار في صفحة تنزيلات Spotube. + يتوفر %1$s %2$s + المثبت حاليًا: %1$s + اطّلع على الجديد في هذا التحديث في صفحة الإضافة. + + تخطٍّ + التالي + رجوع + البدء + Spotube + موسيقى بلا قيود. + مشغّل مفتوح المصدر لعالم من الموسيقى.\nلك أن تستكشفه. ولك أن تجعله على طريقتك. + ساعد في استمرار هذا المشروع. + يبني Spotube أناسٌ حقيقيون، لا شركة اشتراكات. إن أصبح جزءًا من حياتك اليومية، فإن القليل من الدعم يساعدنا على منحه الرعاية التي يستحقها.\n\nشكرًا لإفساحك المجال للبرمجيات المستقلة. + ابدأ بإضافة كتالوج للعثور على الأغاني وقوائم التشغيل. اختر إضافة صوت لتشغيلها. كلمات الأغاني وسجل الاستماع اختياريان. اكتشف إضافة أدناه، وراجع أذوناتها، وثبّتها، ثم حدّدها هنا. + اكتشاف الإضافات + مصادرك + إعادة المحاولة + المزيد من الإضافات + لم يتم العثور على إضافات جديدة. يمكنك أيضًا تثبيت إضافة من رابط أو من ملف .smplug أعلاه. + اكتشاف الإضافات غير متاح حاليًا. حاول مرة أخرى، أو ثبّت من رابط أو ملف. + مرحبًا + الدعم + الإضافات + الدليل + + كيفية استخدام Spotube + أربع خطوات سريعة وتبدأ الاستماع. + تسجيل الدخول + اربط حسابك، أو اختر إضافة الكتالوج الخاصة بك. + ابحث وشغّل + اعثر على أي أغنية أو ألبوم أو فنان، ثم اضغط تشغيل. + استمع بلا إنترنت + نزّل المقاطع لمواصلة التشغيل دون اتصال. + تابع كلمات الأغاني + افتح كلمات الأغاني المتزامنة من المشغّل أثناء تشغيل المقطع. + + + موسيقاك. مصادرك. قواعدك. + مشغّل موسيقى مجاني ومفتوح المصدر، بناه مجتمع يحب الموسيقى ببساطة. + مفتوح المصدر + بلا إعلانات + بلا قياس عن بُعد + مدعوم بالإضافات + هيا بنا + + + الداعمون يبقوننا مستمرين + صُنع بشغف. ويستمر بفضلك. + Spotube مجاني 100%، وحر، ومفتوح المصدر — بلا إعلانات، وبلا اشتراكات، وبلا تتبع. كل ميزة موجودة لأن أشخاصًا مثلك اختاروا المساعدة في استمرار تدفق الشيفرة. + Patreon + راعٍ شهري + ادعم التطوير والبنية التحتية بشكل مستمر + Buy Me a Coffee + قهوة لمرة واحدة + موّل جلسة برمجة ليلية بإكرامية سريعة + Open Collective + صندوق المجتمع + دعم مالي شفاف يديره المجتمع + نشكرك من أعماق قلوبنا لإيمانك بالموسيقى المفتوحة. + الدعم اختياري تمامًا — يبقى Spotube مجانيًا في كل الأحوال. + ادعم + فتح %1$s + + + ادعم Spotube + ادعم Spotube + Spotube مجاني وحر ومفتوح المصدر — بلا إعلانات، وبلا اشتراكات، وبلا تتبع. إن كان يُسعدك، ففكّر في دعم المشروع. + يمكنك إيقاف هذه التذكيرات من الإعدادات ← الدعم. + ربما لاحقًا + تذكيرات الدعم + عرض تذكير لطيف لدعم المشروع كل 7 أيام. + + + بنية معيارية + اختر مصدر موسيقاك. + يفصل Spotube بين اكتشاف الكتالوج وبث الصوت وكلمات الأغاني المتزامنة. اضبط محركاتك أدناه أو بدّلها في أي وقت. + محرك البيانات الوصفية + يشغّل اكتشاف الموسيقى والبحث وقوائم المقاطع وقوائم التشغيل. + محرك الصوت + يجلب تدفقات الصوت التي يتم تشغيلها ويفك ترميزها. + محرك كلمات الأغاني + يعرض كلمات كاريوكي متزامنة زمنيًا في الوقت الفعلي. + محرك تسجيل الاستماع + يسجّل سجل الاستماع في Last.fm أو ListenBrainz. + نشط + مدمج + جاهز + اختياري + اختر محركًا + المحركات المثبتة + تعيين كافتراضي + NewPipe YouTube وLRCLib Lyrics مدمجان وجاهزان للبث فورًا. + أدِر إضافات المجتمع أو أضف إضافات جديدة في أي وقت من الإعدادات ← الإضافات. + + + بنية عصرية بروح كلاسيكية + صوت Hi-Fi نقي، بلا وسطاء + إليك كيف ينسّق Spotube التشغيل اللامركزي مباشرةً على جهازك. + اتصال مباشر من نظير إلى نظير • 256 kbps Opus + الساعة الزرقاء + Spotube · عرض توضيحي للمشغّل + قليل من الموسيقى للحظات العابرة. + 1. اكتشاف لامركزي + تذهب الاستعلامات مباشرةً إلى إضافة الكتالوج التي اخترتها. لا ينشئ Spotube أبدًا ملفًا تعريفيًا لذوقك. + 2. تحديد البث المباشر + يُبث الصوت مباشرةً من خوادم المحتوى إلى جهازك. بلا قفزات عبر وكلاء ولا جمع للبيانات عن بُعد. + 3. معالجة صوتية وتشغيل محليان + يتم فك الترميز والتخزين المؤقت محليًا مع عناصر تحكم في المعادل وخيار التنزيل للاستماع دون اتصال. + 4. كلمات متزامنة وجام + تتبع كلمات الكاريوكي توقيت التشغيل، مع تحكم عن بُعد عبر LAN والجام الجماعي. + لا خوادم وسيطة. يتم التشغيل على جهازك مباشرةً — الخصوصية في صميم التصميم. + + + اختر جهازًا + %1$s: «%2$s» + تشغيل + إضافة إلى قائمة الانتظار + تشغيل التالي + هذا الجهاز + على هذا الجهاز + جهاز بعيد + جهاز بعيد (%1$s) + على الجهاز المتصل + + + فشل الاتصال + انقطع الاتصال + فشل الإرسال + فشل الاكتشاف: %1$s + تعذّر الاتصال: %1$s + + + الأجهزة + تحديث + قطع الاتصال + جارٍ الاتصال... + جارٍ البحث عن أجهزة على الشبكة... + لم يتم العثور على أجهزة + تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر. + جهاز غير معروف + جارٍ التحديد... + + + التحكم عن بُعد + غير متصل + خطأ في الاتصال: %1$s + مقطع غير معروف + فنان غير معروف + مستوى الصوت + إزالة من قائمة الانتظار + خيارات إضافية + + + أين تريد التشغيل؟ + لديك جلسة جام نشطة. اختر أين تريد تشغيل هذه المجموعة. + التشغيل هنا + اقتراح للجام + + + فشل الاتصال + انقطع الاتصال + + + + تمت الإضافة إلى قائمة انتظار الجام + + تمت إضافة %1$d مقطع إلى قائمة انتظار الجام + تمت إضافة مقطع واحد إلى قائمة انتظار الجام + تمت إضافة %1$d مقطعين إلى قائمة انتظار الجام + تمت إضافة %1$d مقاطع إلى قائمة انتظار الجام + تمت إضافة %1$d مقطعًا إلى قائمة انتظار الجام + تمت إضافة %1$d مقطع إلى قائمة انتظار الجام + + + + طلب تحكم عن بُعد + يريد \"%1$s\" التحكم في التشغيل على هذا الجهاز. + رفض + سماح + السماح دائمًا + + + نتائج البحث + الكل + المقاطع + المستخدمون + لا تتوفر أنواع بحث + ابدأ الكتابة للبحث + ابدأ الكتابة للبحث عن مقاطع + لم يتم العثور على نتائج + عرض الكل + جارٍ البحث + جارٍ تحميل المقاطع... + جارٍ تحميل الألبومات... + جارٍ تحميل الفنانين... + جارٍ تحميل قوائم التشغيل... + جارٍ تحميل المستخدمين... + لم يتم العثور على ألبومات + لم يتم العثور على فنانين + لم يتم العثور على قوائم تشغيل + لم يتم العثور على مستخدمين + نبحث بين صناديق الأسطوانات... + نبحث في أرجاء الكون... + نُحمّي مكبرات الصوت... + ننفض الغبار عن الأسطوانات... + نستشير آلهة الموسيقى... + نضبط الهوائيات... + نقلّب بين التسجيلات... + تعذّر تحميل إمكانات البحث + تعذّر البحث + تعذّر البحث عن المقاطع + تعذّر البحث عن الألبومات + تعذّر البحث عن الفنانين + تعذّر البحث عن قوائم التشغيل + تعذّر البحث عن المستخدمين + + + تصفّح + + + متزامنة + عادية + لا تتوفر كلمات متزامنة + لا تتوفر كلمات عادية + + + %1$s في %2$s + %1$d kbps + %1$s kHz • %2$d قناة + لم يتم تحديد إضافة بيانات وصفية + + + رجوع + إعادة المحاولة + إغلاق مربع الحوار + جارٍ التحميل... + غير معروف + خطأ غير معروف + + + مستخدم + قائمة تشغيل + + + نعتذر بشدة + هناك خلل ما في الوقت الحالي. تجربتك تهمنا، ونبذل قصارى جهدنا لإصلاح ذلك. يُرجى المحاولة مرة أخرى أو مراجعة تفاصيل الخطأ أدناه. + تفاصيل الخطأ + نسخ إلى الحافظة + عرض السجل الكامل + سجل الخطأ الكامل + + + ترتيب عشوائي + تشغيل عشوائي + متابعة + إلغاء المتابعة + تعديل قائمة التشغيل + بواسطة %1$s + + + خيارات المقطع + إضافة إلى الجام + بدء الراديو + تشغيل التالي + نقل إلى التالي + إزالة من قائمة الانتظار + إزالة من المفضلة + حفظ في المفضلة + إضافة إلى قائمة تشغيل + تنزيل + مشاركة + + + الأصلي + العنوان + الفنان + الألبوم + المدة + ترتيب + لم يتم العثور على مقاطع + تصفية + إجراءات جماعية + تنزيل الكل + تنزيل %1$d + إضافة الكل إلى قائمة الانتظار + إضافة %1$d إلى قائمة الانتظار + تشغيل الكل تاليًا + تشغيل %1$d تاليًا + إضافة الكل إلى قائمة تشغيل + إضافة %1$d إلى قائمة تشغيل + إضافة الكل إلى الجام + إضافة %1$d إلى الجام + +%1$d آخرين + + + لا يوجد URL لتحميله + الرجوع في سجل المتصفح + التقدم في سجل المتصفح + + + تصفية قائمة الحظر... + لا توجد عناصر محظورة + لا توجد عناصر محظورة تطابق \'%1$s\' + المقاطع + مقطع + إضافة إلى قائمة الحظر + إزالة من قائمة الحظر + + + لا توجد بيانات وصفية للمقطع + تعذّر تحديد URL البث + تعذّر تحديد URL البث. جرّب تشغيل المقطع أولًا. + + + المقاطع المحفوظة + أنت + + + تتابعه + أفضل المقاطع + تشغيل أفضل المقاطع + إضافة أفضل المقاطع إلى قائمة الانتظار + فنانون مشابهون + قوائم تشغيل مميزة + تعذّر تحميل نظرة عامة على الفنان + عدد المتابعين غير متاح + %1$s ألف متابع + %1$s مليون متابع + %1$s مليار متابع + + %1$d متابع + متابع واحد + %1$d متابعان + %1$d متابعين + %1$d متابعًا + %1$d متابع + + + + إعجاب + إلغاء الإعجاب + عنوان مقطع افتراضي + فنانون افتراضيون + ألبوم غير معروف + التقديم والترجيع + مستوى الصوت + تفعيل الترتيب العشوائي + إيقاف الترتيب العشوائي + السابق + التالي + تشغيل أو إيقاف مؤقت + التكرار متوقف + تكرار مقطع واحد + تكرار الكل + كتم الصوت + إلغاء كتم الصوت + خيارات إضافية + خيارات المشغّل + طي المشغّل + توسيع كلمات الأغنية + قيد التشغيل الآن + كلمات الأغنية + مصدر بديل + مصدر بديل + تنزيل + تم التنزيل + جارٍ التنزيل... + تم التنزيل مسبقًا + جارٍ إعادة محاولة التنزيل... + جارٍ تنزيل %1$s + سيتوقف التشغيل مؤقتًا بعد %1$s. + إيقاف التشغيل مؤقتًا بعد عدد الدقائق المحدد. + الدقائق + اختر بين 1 و600 دقيقة + إلغاء المؤقت + بدء + تم تحديد URL التشغيل بإعدادات بث مختلفة. يطابق البث المميز تفضيلك الحالي؛ وسيتم تحديث URL النشط عند طلب البث التالي. + مصدر الصوت المطابق + جارٍ تحميل المصدر المطابق… + لا حاجة إلى مصدر بعيد مطابق أثناء تشغيل الملف المخزن مؤقتًا محليًا. + لا يتوفر مصدر مطابق. + لا يوجد مصدر صوت مطابق مخزن مؤقتًا لهذا المقطع. + لم يتم تخزين تفاصيل البث مؤقتًا بعد. شغّل المقطع أولًا لتحميلها. + تعذّر تحميل تفاصيل المقطع. + التدفقات المتاحة + يتم تقديم هذا المقطع من ذاكرة التخزين المؤقت المحلية. لا تُستخدم متغيرات البث لهذا التشغيل. + جارٍ تحميل التدفقات… + لا تتوفر تدفقات لهذا المصدر. + البث المحدد حاليًا + يطابق تفضيلات صيغة البث وجودته + URL التشغيل النشط + مُقدَّم من ذاكرة التخزين المؤقت المحلية + مقطع البيانات الوصفية + العنوان + المعرّف + الألبوم + درجة الثقة + URI خارجي + معرّف الألبوم + نوع الألبوم + تاريخ إصدار الألبوم + عدد مقاطع الألبوم + أنواع الألبوم الموسيقية + المدة + رقم المقطع + رقم القرص + محتوى صريح + الشعبية + ISRC + عناوين URL للصور المصغرة + + + إظهار/إخفاء الشريط الجانبي + + + مصادر بديلة + لا يوجد مقطع قيد التشغيل حاليًا + جارٍ البحث عن مصادر بديلة... + لم يتم العثور على مصادر بديلة + درجة الثقة: %1$d% + المصدر النشط + + + طرد + إزالته من الجلسة + طرده ومنعه من الانضمام مجددًا + إزالة الاقتراحات + إزالة كل المقاطع التي أضافها إلى قائمة الانتظار + إعادة الترتيب + إزالة من قائمة الانتظار + %1$s • أضافه %2$s + + + غير معروف + جارٍ تحميل قائمة التشغيل... + إضافة مقاطع + إضافة مقاطع إلى قائمة التشغيل + ابحث عن مقاطع... + بحث + لم يتم العثور على مقاطع + ابحث عن مقاطع لإضافتها + تم + إضافة + تمت الإضافة + + + جارٍ تحميل الألبوم... + + + إضافة إلى قائمة تشغيل خاصة بك + بحث + ابحث في قوائم التشغيل الخاصة بك... + جارٍ تحميل قوائم التشغيل الخاصة بك... + لا تملك أي قوائم تشغيل بعد.\nأنشئ واحدة لإضافة المقاطع. + إنشاء قائمة تشغيل + تعديل قائمة التشغيل + إنشاء + الاسم + اسم قائمة التشغيل + الوصف + الوصف (اختياري) + عامة + يمكن للجميع رؤية قائمة التشغيل هذه + تعاونية + يمكن للآخرين إضافة مقاطع إلى قائمة التشغيل هذه + صورة الغلاف + غلاف قائمة التشغيل + لا توجد صورة + تغيير الصورة + اختيار صورة + + + تثبيت + استبدال + لم يتم العثور على plugin.json في ملف zip + لم يتم العثور على manifest.zipline.json في ملف zip + تنسيق plugin.json غير صالح: %1$s + تعذّرت قراءة الإضافة: %1$s + تعذّر تثبيت الإضافة: %1$s + يتوفر إصدار أحدث من %1$s. أكّد التحديث قبل التثبيت. + %1$s مثبتة بالفعل. أكّد استبدالها قبل التثبيت. + الإضافات المدمجة مضمّنة بالفعل ولا يمكن إضافتها مرة أخرى. + لا يمكن إزالة الإضافات المدمجة. + تعلن %1$s عن قيمة apiVersion غير صالحة (%2$s). المتوقع إصدار دلالي متوافق مع %3$s. + إصدار API الإضافات في Spotube %1$s غير صالح. + تستهدف %1$s إصدار API الإضافات %2$s، لكن Spotube يدعم إصدار API الإضافات الرئيسي %3$s. ثبّت إضافة مبنية لنفس الإصدار الرئيسي من API. + + + API الإضافة غير متوافق + تثبيت الإضافة؟ + ستتم إضافة %1$s إلى إضافاتك المثبتة. + تحديث الإضافة؟ + إصدار أقدم مثبت (%1$s). هل تريد التحديث إلى %2$s؟ + استبدال الإضافة المثبتة؟ + تستخدم %1$s إصدارًا دلاليًا غير صالح (%2$s) ولا يمكن مقارنتها كتحديث. هل تريد استبدال الإضافة المثبتة على أي حال؟ + لا يمكن مقارنة الإصدار المثبت (%1$s) باستخدام الإصدار الدلالي. هل تريد استبداله بـ %2$s؟ + الإصدار %1$s مثبت بالفعل. هل تريد استبدال الإضافة الحالية بالنسخة المقدَّمة؟ + الإصدار المثبت %1$s أحدث من الإصدار المقدَّم %2$s. هل تريد استبداله على أي حال؟ + هل تريد استبدال الإضافة المثبتة بالنسخة المقدَّمة؟ + + + %1$s: + الإصدار + إصدار API + المؤلف + الترخيص + الإمكانات + القدرات + المستودع + التواصل + الإبلاغ عن الأخطاء + ادعم %1$s + + + رسمية + موثّقة + نجوم GitHub + URL مستودع GitHub + + + لم يتم العثور على إصدارات + اختيار الإصدار + اختر إصدارًا + إصدار تجريبي + ملاحظات الإصدار + + + الرئيسية + بحث + على الجهاز + الاتصال + الأجهزة + + + إخفاء النافذة + إظهار النافذة + المقطع التالي + المقطع السابق + ترتيب عشوائي + تكرار + التكرار: متوقف + التكرار: مقطع واحد + التكرار: الكل + مستوى الصوت: %1$d% + رفع الصوت + + خفض الصوت - + كتم الصوت + إلغاء كتم الصوت + الإعجاب بالمقطع + إلغاء الإعجاب بالمقطع + خروج + + + تصغير + تكبير + استعادة + إغلاق + + + التشغيل + عناصر التحكم في تشغيل الوسائط + + + تصفّح + قيد التشغيل الآن + مميز + المقاطع المحفوظة + أفضل المقاطع + diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml new file mode 100644 index 00000000..a111017f --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -0,0 +1,926 @@ + + + + কিউ + কিউ খালি করুন + কিউ ফিল্টার করুন... + কিউতে কিছু নেই + + গান, শিল্পী, অ্যালবাম খুঁজুন... + সব হিস্ট্রি মুছুন + খুঁজুন... + + + আপনার লাইব্রেরি + লাইব্রেরি + প্লেলিস্ট + অ্যালবাম + শিল্পী + লোকাল ট্র্যাক + ডাউনলোড + সেভ করা প্লেলিস্ট খুঁজুন… + সেভ করা অ্যালবাম খুঁজুন… + সেভ করা শিল্পী খুঁজুন… + ফোল্ডার বা ট্র্যাক খুঁজুন… + লাইব্রেরি ফিল্টার করুন + ফিল্টার বন্ধ করুন + প্লেলিস্ট তৈরি করুন + লোকাল ট্র্যাক রিফ্রেশ করুন + গ্রিড ভিউতে যান + লিস্ট ভিউতে যান + কোনো সেভ করা প্লেলিস্ট নেই + “%1$s”-এর সাথে মেলে এমন কোনো প্লেলিস্ট নেই + কোনো সেভ করা অ্যালবাম নেই + “%1$s”-এর সাথে মেলে এমন কোনো অ্যালবাম নেই + কোনো সেভ করা শিল্পী নেই + “%1$s”-এর সাথে মেলে এমন কোনো শিল্পী নেই + + + ফোল্ডার + ফোল্ডার + %1$s • %2$s + + %1$dটি ট্র্যাক + %1$dটি ট্র্যাক + + + %1$dটি ফোল্ডার + %1$dটি ফোল্ডার + + ফোল্ডারে ফিরে যান + ফোল্ডার প্লে করুন + অডিও অ্যাক্সেস প্রয়োজন + আপনার ডিভাইসের গান দেখতে অডিও অ্যাক্সেসের অনুমতি দিন। + অনুমতি দিন + কোনো লোকাল ফোল্ডার পাওয়া যায়নি + এই ফোল্ডারে কোনো ট্র্যাক নেই + মেলে এমন কোনো ফোল্ডার বা ট্র্যাক নেই + লোকাল মিডিয়া রিফ্রেশ করা যায়নি + + + %1$dটি চলমান • %2$dটি সম্পন্ন + সম্পন্নগুলো মুছুন + এখনো কোনো ডাউনলোড নেই + ডাউনলোড হচ্ছে %1$d% + অপেক্ষমাণ + সম্পন্ন + বাতিল + ব্যর্থ + ব্যর্থ: %1$s + ডাউনলোড বাতিল করুন + আবার ডাউনলোড করুন + ডাউনলোড সরান + + + শিল্পী + ব্ল্যাকলিস্টেড + পছন্দের ট্র্যাক + আপনার সেভ করা গান + অজানা শিল্পী + ট্র্যাকের নাম + শিল্পীর নাম + + নির্বাচিত + + সেটিংস + + প্লাগইন + ভাষা ও অঞ্চল + অ্যাপিয়ারেন্স + প্লেব্যাক + ডাউনলোড + ক্যাশিং + ডেস্কটপ + আপডেট + সাপোর্ট + সম্পর্কে + + বন্ধ করুন + সেভ করুন + বাতিল + + ভাষা + অ্যাপ ইন্টারফেসের ভাষা। বর্তমান: %1$s + সিস্টেম ডিফল্ট + দেশ + কনটেন্ট ও প্রাপ্যতার জন্য দেশ। বর্তমান: %1$s + %1$s (%2$s) + + থিম + Spotube আপনার সিস্টেমের অ্যাপিয়ারেন্সের সাথে কীভাবে মিলবে তা বেছে নিন। বর্তমান: %1$s + লাইট + ডার্ক + সিস্টেম + + সারফেস + অ্যাপের ব্যাকগ্রাউন্ড ম্যাটেরিয়াল। বর্তমান: %1$s + জলীয় + বরফি + ক্রোম + অ্যাক্রিলিক + কাচ + + অ্যাকসেন্ট রং + বর্তমান প্যালেট: %1$s + অ্যাকসেন্ট রং + একটি অ্যাকসেন্ট প্যালেট বেছে নিন এবং লাইট ও ডার্ক দুই থিমেই প্রিভিউ দেখুন। + লাইট + ডার্ক + প্রিভিউ + বাটন + গ্রিন গবলিন + ইলেকট্রিক ভায়োলেট + ওশেনিক সায়ান + সানসেট অরেঞ্জ + রোজ গার্ডেন + মিডনাইট ব্লু + মেটালিক স্লেট + + লুক ও ফিল + সারফেস ম্যাটেরিয়াল ও অ্যাকসেন্ট। বর্তমান: %1$s · %2$s + লুক ও ফিল + একটি সারফেস ম্যাটেরিয়াল বেছে নিন, তারপর তার সাথে মানানসই একটি অ্যাকসেন্ট। প্রতিটি অপশনই বাছাই করা কম্বিনেশন। + সারফেস + অ্যাকসেন্ট + সেকেন্ডারি + + প্যালেট + Millennium অ্যাকসেন্ট প্যালেট। বর্তমান: %1$s + প্যালেট + একটি Millennium অ্যাকসেন্ট প্যালেট বেছে নিন এবং লাইট ও ডার্কে প্রিভিউ দেখুন। + প্যালেট + Millennium প্যালেট ও অ্যাকসেন্ট। বর্তমান: %1$s + নীল + কমলা + লাইম + + ডাউনলোড ফোল্ডার পরিবর্তন + বর্তমান ফোল্ডার: %1$s + ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন। + C:\Music\Spotube + লোকাল মিডিয়া ফোল্ডার + ডেস্কটপে স্ক্যান করার জন্য কাস্টম ফোল্ডার। যোগ করা হয়েছে: %1$d + এখনো কোনো কাস্টম ফোল্ডার যোগ করা হয়নি + আপনার লোকাল মিউজিক লাইব্রেরির জন্য কাস্টম ফোল্ডার। + ম্যানেজ করুন + ফোল্ডার যোগ করুন + ফোল্ডার সরান + কোনো কাস্টম ফোল্ডার যোগ করা হয়নি। + স্ক্যান তালিকা থেকে %1$s সরান + ডাউনলোড ও মিউজিক ক্যাশ ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + আপনার ডাউনলোড ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + মিউজিক ক্যাশ ফোল্ডার স্বয়ংক্রিয়ভাবে অন্তর্ভুক্ত হয়। + সেভ হচ্ছে… + ফোল্ডার সেভ করা যায়নি। আপনার পরিবর্তনগুলো রয়ে গেছে; আবার চেষ্টা করুন। + + %1$dটি ফোল্ডার + %1$dটি ফোল্ডার + + ডাউনলোডের মিউজিক ফরম্যাট + ডাউনলোডের জন্য পছন্দের কোডেক ও কন্টেইনার। বর্তমান: %1$s + ডাউনলোডের মিউজিক কোয়ালিটি + + স্ট্রিমিংয়ের মিউজিক ফরম্যাট + মিউজিক স্ট্রিমিংয়ের জন্য পছন্দের কোডেক ও কন্টেইনার। বর্তমান: %1$s + স্ট্রিমিংয়ের মিউজিক কোয়ালিটি + বর্তমান: %1$s + মিউজিক ক্যাশিং চালু করুন + বারবার প্লে দ্রুত করতে স্ট্রিম করা অডিও লোকালি সংরক্ষণ করুন। + ক্যাশ ফোল্ডার + বর্তমান: %1$s + বর্তমান: ডিফল্ট অ্যাপ্লিকেশন ক্যাশ + ক্যাশ সাইজের সীমা + কোনো সীমা নেই + বর্তমান সীমা: %1$d MB + MB-তে সর্বোচ্চ ক্যাশ সাইজ দিন। সীমাহীন রাখতে 0 দিন বা ফাঁকা রাখুন। + 500 + সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে। + অবিরাম প্লেব্যাক চালু করুন + কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন। + প্লেব্যাক প্রক্সি সার্ভার পোর্ট + রিমোট কন্ট্রোলের অনুমতি দিন + একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন। + রিমোট কন্ট্রোল ডিভাইসের নাম + অন্য ডিভাইস এই ডিভাইসটি খুঁজে পেলে যে নাম দেখবে। বর্তমান: %1$s + আপনার নেটওয়ার্কের অন্য ডিভাইস এই Spotube খুঁজে পেলে দেখানোর জন্য একটি সহজ নাম বেছে নিন। + যেমন: বসার ঘরের Spotube + ডিভাইসের হোস্টনেম + প্লেব্যাক প্রক্সি সার্ভারের ব্যবহৃত পোর্ট। বর্তমান: %1$d + 1 থেকে 65535-এর মধ্যে একটি পোর্ট বেছে নিন। + 14769 + একটি সঠিক পূর্ণসংখ্যা দিন। + পোর্ট 1 থেকে 65535-এর মধ্যে হতে হবে। + + ব্ল্যাকলিস্ট + ব্ল্যাকলিস্ট করা ট্র্যাক ও শিল্পী ম্যানেজ করুন + + ট্রেতে মিনিমাইজ করুন + উইন্ডো মিনিমাইজ করলে Spotube সিস্টেম ট্রেতে চালু রাখুন। + Discord রিচ প্রেজেন্স + ডেস্কটপে Discord-এ আপনার বর্তমান প্লেব্যাক স্ট্যাটাস দেখান। + + স্বয়ংক্রিয়ভাবে আপডেট চেক করুন + ব্যাকগ্রাউন্ডে Spotube-এর নতুন রিলিজ খুঁজুন। + + প্লাগইন ম্যানেজ করুন + আপনার প্লাগইনগুলো ম্যানেজ করুন + ডিফল্ট %1$s প্লাগইন + মেটাডেটা + অডিও + লিরিক্স + স্ক্রবল + কোনো প্লাগইন নির্বাচিত নয় + পরিবর্তন করুন + নির্বাচন করুন + নির্বাচিত + নির্বাচন মুছুন + কোনো প্লাগইন উপলব্ধ নেই + %1$s প্লাগইন + মুছুন + + কনফিগার করুন + প্লাগইন ম্যানেজার + অনুগ্রহ করে একটি URL দিন + URL অবশ্যই http:// বা https:// দিয়ে শুরু হতে হবে + প্লাগইন ডাউনলোড করা যায়নি + এখনো কোনো প্লাগইন ইনস্টল করা হয়নি + একটি ডাউনলোড URL পেস্ট করুন বা ওপর থেকে একটি ফাইল বেছে নিন। + + %1$dটি প্লাগইন ইনস্টল করা আছে + %1$dটি প্লাগইন ইনস্টল করা আছে + + প্লাগইন ইনস্টল করুন + https://example.com/plugin.smplug + ডাউনলোড + ফাইল থেকে ইনস্টল করুন (.smplug) + সক্রিয় + v%1$s + বিল্ট-ইন + প্লাগইন সরান + লগইন + লগআউট + URL থেকে ডাউনলোড করুন + ফাইল থেকে ইনস্টল করুন + ইনস্টল করুন + + • %1$s + অনুরোধ করা পারমিশন + এই প্লাগইনের কোনো বিশেষ পারমিশন প্রয়োজন নেই। + ইনস্টল করা বনাম প্রদত্ত + ইনস্টল করা + প্রদত্ত + API %1$s → %2$s + স্থায়ী স্টোরেজ + ডিস্কে ডেটা পড়া ও লেখা + নেটওয়ার্ক রিকোয়েস্ট + ইন্টারনেটে ডেটা পাঠানো ও গ্রহণ করা + WebView + অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো + গ্রুপ জ্যাম + কানেকশন পরীক্ষা করুন + পরীক্ষা চলছে… + %1$d ms-এ কানেক্ট হয়েছে + ব্যর্থ: %1$s + কানেকশন + ডায়াগনস্টিকস + + + গ্রুপ জ্যাম + তৈরি করুন + যোগ দিন + একটি রুম তৈরি করুন, তারপর এর কোড বন্ধুদের সাথে শেয়ার করুন। + রুম তৈরি করুন + রুমে যোগ দিন + কানেক্ট হচ্ছে… + রুম কোড + ABC123 + কানেক্টেড + আবার কানেক্ট হচ্ছে… + কোড কপি করুন + কপি হয়েছে! + শেয়ার করুন + অংশগ্রহণকারী (%1$d) + হোস্ট + শ্রোতা + শ্রোতাদের জন্য অপেক্ষা করা হচ্ছে… + সরান + ব্যান করুন + ম্যানেজ করুন + রুম ছেড়ে দিন + কানেক্ট করা যায়নি + বাদ দিন + জ্যাম সেশন তৈরি করা যায়নি + জ্যাম সেশনে যোগ দেওয়া যায়নি + অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন। + এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s + রুম সার্ভার + সার্ভার URL + https:// দিয়ে শুরু হওয়া একটি URL দিন + রুম শুরু করার আগে রুম সার্ভার পরীক্ষা করুন। + জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে github.com/team-spotube/jam-server থেকে সার্ভার ডিপ্লয় করে তার URL দিন। + আপনি + সার্ভার: %1$s + রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি রুম সার্ভার সেট করুন। + সার্ভার সেটিংস + %1$s (আপনি) + আপনার নাম + জ্যামের অন্যরা আপনাকে %1$s নামে দেখবে + জ্যামের সবাই দেখতে পাবে। %1$s ব্যবহার করতে ফাঁকা রাখুন। নতুন নাম পরের বার রুম তৈরি বা যোগ দেওয়ার সময় থেকে কার্যকর হবে। + সর্বোচ্চ %1$dটি অক্ষর ব্যবহার করুন। + কোনো জ্যাম সার্ভার সেট করা নেই + জ্যাম সার্ভারে পৌঁছানো যায়নি। আপনার নেটওয়ার্ক বা সেটিংস → গ্রুপ জ্যাম-এ সার্ভার URL পরীক্ষা করুন। + জ্যাম সার্ভারের সংস্করণটি সামঞ্জস্যপূর্ণ নয়। + এই কোডের কোনো রুম নেই। কোডটি দেখে আবার চেষ্টা করুন। + এই রুমটি পূর্ণ। + হোস্ট আপনাকে এই রুম থেকে ব্যান করেছেন। + হোস্ট আপনাকে রুম থেকে সরিয়ে দিয়েছেন। + এই রুমটি এই ডিভাইসে আবার খোলা হয়েছে, তাই এই কানেকশনটি বন্ধ করা হয়েছে। + + অ্যালবামে যান + স্লিপ টাইমার + ট্র্যাকের বিস্তারিত + প্লে + পজ + কিছুই বাজছে না + কিউতে যোগ করুন + + উপেক্ষা করুন + আপডেট করুন + Spotube %1$s উপলব্ধ + এই রিলিজে নতুন কী আছে তা Spotube ডাউনলোড পেজে দেখুন। + %1$s %2$s উপলব্ধ + বর্তমানে ইনস্টল করা: %1$s + এই আপডেটে নতুন কী আছে তা প্লাগইনের পেজে দেখুন। + + এড়িয়ে যান + পরবর্তী + পেছনে + শুরু করুন + Spotube + মিউজিক, কোনো বাঁধন ছাড়াই। + মিউজিকের জগতের জন্য একটি ওপেন-সোর্স প্লেয়ার।\nআপনার মতো করে ঘুরে দেখুন। নিজের মতো করে সাজিয়ে নিন। + এই প্রজেক্টটি বাঁচিয়ে রাখুন। + Spotube মানুষের তৈরি, কোনো সাবস্ক্রিপশন ব্যবসার নয়। এটি যদি আপনার প্রতিদিনের জীবনের অংশ হয়ে ওঠে, তাহলে একটু সাপোর্ট আমাদের এর প্রাপ্য যত্ন নিতে সাহায্য করে।\n\nস্বাধীন সফটওয়্যারকে জায়গা দেওয়ার জন্য ধন্যবাদ। + গান ও প্লেলিস্ট খুঁজতে একটি ক্যাটালগ প্লাগইন দিয়ে শুরু করুন। সেগুলো বাজাতে একটি অডিও প্লাগইন বেছে নিন। লিরিক্স ও শোনার হিস্ট্রি ঐচ্ছিক। নিচে একটি প্লাগইন খুঁজুন, এর পারমিশন দেখে নিন, ইনস্টল করুন, তারপর এখানে নির্বাচন করুন। + প্লাগইন খুঁজুন + আপনার সোর্স + আবার চেষ্টা করুন + আরও প্লাগইন + নতুন কোনো প্লাগইন পাওয়া যায়নি। ওপরের লিংক বা .smplug ফাইল থেকেও ইনস্টল করতে পারেন। + এই মুহূর্তে প্লাগইন খোঁজা যাচ্ছে না। আবার চেষ্টা করুন, অথবা লিংক বা ফাইল থেকে ইনস্টল করুন। + স্বাগতম + সাপোর্ট + প্লাগইন + গাইড + + Spotube যেভাবে ব্যবহার করবেন + চারটি সহজ ধাপ, তারপরই গান শুনুন। + সাইন ইন + আপনার অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন। + খুঁজুন ও প্লে করুন + যেকোনো গান, অ্যালবাম বা শিল্পী খুঁজুন, তারপর প্লে চাপুন। + অফলাইনে শুনুন + কানেকশন ছাড়াই শুনতে ট্র্যাক ডাউনলোড করুন। + লিরিক্সের সাথে গান + ট্র্যাক বাজার সময় প্লেয়ার থেকে সিঙ্ক করা লিরিক্স খুলুন। + + + আপনার মিউজিক। আপনার সোর্স। আপনার নিয়ম। + একটি ফ্রি, ওপেন-সোর্স মিউজিক প্লেয়ার, যা তৈরি করেছে মিউজিক ভালোবাসা একটি কমিউনিটি। + ওপেন সোর্স + বিজ্ঞাপন নেই + টেলিমেট্রি নেই + প্লাগইন চালিত + চলুন শুরু করি + + + সমর্থকরাই আমাদের এগিয়ে নেন + ভালোবাসায় তৈরি। আপনার কারণেই টিকে আছে। + Spotube ১০০% ফ্রি, লিব্রে ও ওপেন সোর্স — কোনো বিজ্ঞাপন, সাবস্ক্রিপশন বা ট্র্যাকিং নেই। প্রতিটি ফিচার আছে কারণ আপনার মতো মানুষ কোড চালু রাখতে সাহায্য করেছেন। + Patreon + মাসিক স্পনসর + নিয়মিত ডেভেলপমেন্ট ও ইনফ্রাস্ট্রাকচারে সাপোর্ট করুন + Buy Me a Coffee + এককালীন কফি + একটি ছোট্ট টিপ দিয়ে রাত জাগা কোডিং সেশনে শক্তি জোগান + Open Collective + কমিউনিটি ফান্ড + স্বচ্ছ, কমিউনিটি-পরিচালিত আর্থিক সহায়তা + ওপেন মিউজিকে বিশ্বাস রাখার জন্য অন্তরের অন্তস্তল থেকে ধন্যবাদ। + সাপোর্ট করা সম্পূর্ণ ঐচ্ছিক — যেকোনো অবস্থাতেই Spotube ফ্রি থাকবে। + স্পনসর করুন + %1$s খুলুন + + + Spotube-কে সাপোর্ট করুন + Spotube-কে সাপোর্ট করুন + Spotube ফ্রি, লিব্রে ও ওপেন সোর্স — কোনো বিজ্ঞাপন, সাবস্ক্রিপশন বা ট্র্যাকিং নেই। এটি যদি আপনাকে আনন্দ দেয়, তাহলে প্রজেক্টটিকে সাপোর্ট করার কথা ভাবুন। + এই রিমাইন্ডারগুলো সেটিংস → সাপোর্ট থেকে বন্ধ করতে পারেন। + পরে দেখব + সাপোর্ট রিমাইন্ডার + প্রতি ৭ দিনে প্রজেক্টটিকে সাপোর্ট করার একটি ছোট রিমাইন্ডার দেখান। + + + মডুলার আর্কিটেকচার + আপনার মিউজিক কোথা থেকে আসবে তা বেছে নিন। + Spotube ক্যাটালগ খোঁজা, অডিও স্ট্রিমিং ও সিঙ্ক করা লিরিক্স আলাদা রাখে। নিচে আপনার ইঞ্জিনগুলো কনফিগার করুন বা যেকোনো সময় বদলে নিন। + মেটাডেটা ইঞ্জিন + মিউজিক খোঁজা, সার্চ, ট্র্যাক তালিকা ও প্লেলিস্ট চালায়। + অডিও ইঞ্জিন + যে অডিও স্ট্রিম বাজে তা আনে ও ডিকোড করে। + লিরিক্স ইঞ্জিন + রিয়েল টাইমে সময়-সিঙ্ক করা কারাওকে লিরিক্স দেখায়। + স্ক্রবল ইঞ্জিন + Last.fm বা ListenBrainz-এ শোনার হিস্ট্রি রেকর্ড করে। + সক্রিয় + বিল্ট-ইন + প্রস্তুত + ঐচ্ছিক + একটি ইঞ্জিন বেছে নিন + ইনস্টল করা ইঞ্জিন + ডিফল্ট হিসেবে সেট করুন + NewPipe YouTube ও LRCLib Lyrics বিল্ট-ইন এবং শুরু থেকেই স্ট্রিম করার জন্য প্রস্তুত। + সেটিংস → প্লাগইন থেকে যেকোনো সময় কমিউনিটি প্লাগইন ম্যানেজ বা যোগ করুন। + + + রেট্রো-মডার্ন আর্কিটেকচার + খাঁটি Hi-Fi, কোনো মধ্যস্থতাকারী নেই + Spotube কীভাবে সরাসরি আপনার মেশিনে বিকেন্দ্রীভূত প্লেব্যাক পরিচালনা করে, দেখুন। + সরাসরি পিয়ার-টু-পিয়ার • 256 KBPS OPUS + নীল প্রহর + Spotube · প্লেয়ার ডেমো + মাঝের মুহূর্তগুলোর জন্য একটু মিউজিক। + ১. বিকেন্দ্রীভূত খোঁজ + সার্চ সরাসরি আপনার বেছে নেওয়া ক্যাটালগ প্লাগইনে যায়। Spotube কখনো আপনার রুচির প্রোফাইল তৈরি করে না। + ২. সরাসরি স্ট্রিম রেজোলিউশন + অডিও কনটেন্ট সার্ভার থেকে সরাসরি আপনার মেশিনে স্ট্রিম হয়। কোনো প্রক্সি বা রিমোট ডেটা সংগ্রহ নেই। + ৩. লোকাল DSP ও প্লেব্যাক + ইকুয়ালাইজার কন্ট্রোল ও ঐচ্ছিক অফলাইন ডাউনলোডসহ লোকালি ডিকোড ও ক্যাশ করা হয়। + ৪. সিঙ্ক করা লিরিক্স ও জ্যাম + কারাওকে লিরিক্স প্লেব্যাকের সময়ের সাথে মিলে চলে, সাথে আছে LAN রিমোট কন্ট্রোল ও গ্রুপ জ্যাম। + কোনো মধ্যস্থতাকারী সার্ভার নেই। প্লেব্যাক হয় সরাসরি আপনার ডিভাইসে — ডিজাইনেই প্রাইভেট। + + + একটি ডিভাইস বেছে নিন + %1$s: “%2$s” + প্লে + কিউতে যোগ + পরবর্তীতে প্লে + এই ডিভাইস + এই ডিভাইসে + রিমোট ডিভাইস + রিমোট ডিভাইস (%1$s) + সংযুক্ত ডিভাইসে + + + কানেকশন ব্যর্থ হয়েছে + কানেকশন বিচ্ছিন্ন হয়েছে + পাঠানো যায়নি + খোঁজা ব্যর্থ হয়েছে: %1$s + কানেক্ট করা যায়নি: %1$s + + + ডিভাইস + রিফ্রেশ + ডিসকানেক্ট + কানেক্ট হচ্ছে... + নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে... + কোনো ডিভাইস পাওয়া যায়নি + অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন। + অজানা ডিভাইস + খোঁজা হচ্ছে... + + + রিমোট কন্ট্রোল + ডিসকানেক্টেড + কানেকশন ত্রুটি: %1$s + অজানা ট্র্যাক + অজানা শিল্পী + ভলিউম + কিউ থেকে সরান + আরও অপশন + + + কোথায় প্লে করবেন? + আপনার একটি জ্যাম সেশন চালু আছে। এই কালেকশন কোথায় প্লে করবেন বেছে নিন। + এখানে প্লে করুন + জ্যামে সাজেস্ট করুন + + + কানেকশন ব্যর্থ হয়েছে + কানেকশন বিচ্ছিন্ন হয়েছে + + + + জ্যাম কিউতে যোগ করা হয়েছে + + জ্যাম কিউতে %1$dটি যোগ করা হয়েছে + জ্যাম কিউতে %1$dটি যোগ করা হয়েছে + + + + রিমোট কন্ট্রোলের অনুরোধ + \"%1$s\" এই ডিভাইসের প্লেব্যাক নিয়ন্ত্রণ করতে চায়। + প্রত্যাখ্যান + অনুমতি দিন + সবসময় অনুমতি দিন + + + সার্চের ফলাফল + সব + ট্র্যাক + ইউজার + কোনো সার্চ টাইপ উপলব্ধ নেই + খুঁজতে টাইপ করা শুরু করুন + ট্র্যাক খুঁজতে টাইপ করা শুরু করুন + কোনো ফলাফল পাওয়া যায়নি + সব দেখুন + খোঁজা হচ্ছে + ট্র্যাক লোড হচ্ছে... + অ্যালবাম লোড হচ্ছে... + শিল্পী লোড হচ্ছে... + প্লেলিস্ট লোড হচ্ছে... + ইউজার লোড হচ্ছে... + কোনো অ্যালবাম পাওয়া যায়নি + কোনো শিল্পী পাওয়া যায়নি + কোনো প্লেলিস্ট পাওয়া যায়নি + কোনো ইউজার পাওয়া যায়নি + রেকর্ডের বাক্স ঘাঁটা হচ্ছে... + মহাবিশ্বে খোঁজা হচ্ছে... + স্পিকার গরম করা হচ্ছে... + ভাইনিলের ধুলো ঝাড়া হচ্ছে... + সংগীতের দেবতাদের জিজ্ঞেস করা হচ্ছে... + অ্যান্টেনা টিউন করা হচ্ছে... + রেকর্ড উল্টে দেখা হচ্ছে... + সার্চের সক্ষমতা লোড করা যায়নি + সার্চ করা যায়নি + ট্র্যাক খোঁজা যায়নি + অ্যালবাম খোঁজা যায়নি + শিল্পী খোঁজা যায়নি + প্লেলিস্ট খোঁজা যায়নি + ইউজার খোঁজা যায়নি + + + ব্রাউজ + + + সিঙ্কড + সাধারণ + কোনো সিঙ্ক করা লিরিক্স নেই + কোনো সাধারণ লিরিক্স নেই + + + %2$s-এ %1$s + %1$d kbps + %1$s kHz • %2$d ch + কোনো মেটাডেটা প্লাগইন নির্বাচিত নয় + + + পেছনে + আবার চেষ্টা করুন + ডায়ালগ বন্ধ করুন + লোড হচ্ছে... + অজানা + অজানা ত্রুটি + + + ইউজার + প্লেলিস্ট + + + আমরা সত্যিই দুঃখিত + এই মুহূর্তে কিছু একটা ঠিকমতো কাজ করছে না। আপনার অভিজ্ঞতা আমাদের কাছে গুরুত্বপূর্ণ, এবং আমরা এটি ঠিক করার সর্বোচ্চ চেষ্টা করছি। অনুগ্রহ করে আবার চেষ্টা করুন অথবা নিচে ত্রুটির বিস্তারিত দেখুন। + ত্রুটির বিস্তারিত + ক্লিপবোর্ডে কপি করুন + সম্পূর্ণ লগ দেখুন + সম্পূর্ণ ত্রুটি লগ + + + শাফল + শাফল প্লে + ফলো করুন + আনফলো করুন + প্লেলিস্ট এডিট করুন + %1$s-এর + + + ট্র্যাক অপশন + জ্যামে যোগ করুন + রেডিও শুরু করুন + পরবর্তীতে প্লে করুন + পরবর্তীতে সরান + কিউ থেকে সরান + পছন্দের তালিকা থেকে সরান + পছন্দের তালিকায় সেভ করুন + প্লেলিস্টে যোগ করুন + ডাউনলোড + শেয়ার করুন + + + মূল ক্রম + নাম + শিল্পী + অ্যালবাম + দৈর্ঘ্য + সাজান + কোনো ট্র্যাক পাওয়া যায়নি + ফিল্টার + একসাথে অ্যাকশন + সব ডাউনলোড করুন + %1$dটি ডাউনলোড করুন + সব কিউতে যোগ করুন + %1$dটি কিউতে যোগ করুন + সব পরবর্তীতে প্লে করুন + %1$dটি পরবর্তীতে প্লে করুন + সব প্লেলিস্টে যোগ করুন + %1$dটি প্লেলিস্টে যোগ করুন + সব জ্যামে যোগ করুন + %1$dটি জ্যামে যোগ করুন + +আরও %1$d জন + + + লোড করার মতো কোনো URL নেই + ব্রাউজার হিস্ট্রিতে পেছনে যান + ব্রাউজার হিস্ট্রিতে সামনে যান + + + ব্ল্যাকলিস্ট ফিল্টার করুন... + ব্ল্যাকলিস্টে কিছু নেই + \'%1$s\'-এর সাথে মেলে এমন কিছু ব্ল্যাকলিস্টে নেই + ট্র্যাক + ট্র্যাক + ব্ল্যাকলিস্টে যোগ করুন + ব্ল্যাকলিস্ট থেকে সরান + + + ট্র্যাকের কোনো মেটাডেটা নেই + স্ট্রিম URL পাওয়া যায়নি + স্ট্রিম URL পাওয়া যায়নি। প্রথমে ট্র্যাকটি প্লে করে দেখুন। + + + সেভ করা ট্র্যাক + আপনি + + + ফলো করছেন + জনপ্রিয় ট্র্যাক + জনপ্রিয় ট্র্যাক প্লে করুন + জনপ্রিয় ট্র্যাক কিউতে যোগ করুন + সম্পর্কিত শিল্পী + ফিচার্ড প্লেলিস্ট + শিল্পীর ওভারভিউ লোড করা যায়নি + ফলোয়ার সংখ্যা পাওয়া যাচ্ছে না + %1$sK ফলোয়ার + %1$sM ফলোয়ার + %1$sB ফলোয়ার + + %1$d জন ফলোয়ার + %1$d জন ফলোয়ার + + + + লাইক + আনলাইক + No-op ট্র্যাকের নাম + No-op শিল্পী + অজানা অ্যালবাম + সিক + ভলিউম + শাফল চালু করুন + শাফল বন্ধ করুন + আগের + পরের + প্লে বা পজ + লুপ মোড বন্ধ + লুপ মোড: একটি + লুপ মোড: সব + মিউট + আনমিউট + আরও অপশন + প্লেয়ার অপশন + প্লেয়ার ছোট করুন + লিরিক্স বড় করুন + এখন বাজছে + লিরিক্স + বিকল্প সোর্স + বিকল্প সোর্স + ডাউনলোড + ডাউনলোড হয়েছে + ডাউনলোড হচ্ছে... + আগেই ডাউনলোড করা হয়েছে + আবার ডাউনলোডের চেষ্টা হচ্ছে... + %1$s ডাউনলোড হচ্ছে + %1$s পর প্লেব্যাক পজ হবে। + নির্বাচিত মিনিট পরে প্লেব্যাক পজ করুন। + মিনিট + 1 থেকে 600 মিনিটের মধ্যে বেছে নিন + টাইমার বাতিল করুন + শুরু করুন + প্লেব্যাক URL ভিন্ন স্ট্রিমিং সেটিংসে পাওয়া গেছে। হাইলাইট করা স্ট্রিমটি আপনার বর্তমান পছন্দের সাথে মেলে; পরবর্তী স্ট্রিম রিকোয়েস্টে সক্রিয় URL রিফ্রেশ হবে। + মিলে যাওয়া অডিও সোর্স + মিলে যাওয়া সোর্স লোড হচ্ছে… + লোকাল ক্যাশ ফাইল প্লে করার সময় মিলে যাওয়া রিমোট সোর্সের প্রয়োজন নেই। + মিলে যাওয়া কোনো সোর্স উপলব্ধ নেই। + এই ট্র্যাকের জন্য মিলে যাওয়া কোনো অডিও সোর্স ক্যাশ করা নেই। + স্ট্রিমের বিস্তারিত এখনো ক্যাশ করা হয়নি। লোড করতে প্রথমে ট্র্যাকটি প্লে করুন। + ট্র্যাকের বিস্তারিত লোড করা যায়নি। + উপলব্ধ স্ট্রিম + এই ট্র্যাকটি লোকাল ক্যাশ থেকে চালানো হচ্ছে। এই প্লেব্যাকে স্ট্রিম ভ্যারিয়েন্ট ব্যবহৃত হয় না। + স্ট্রিম লোড হচ্ছে… + এই সোর্সের জন্য কোনো স্ট্রিম উপলব্ধ নেই। + বর্তমানে নির্বাচিত স্ট্রিম + আপনার স্ট্রিমিং ফরম্যাট ও কোয়ালিটি পছন্দের সাথে মেলে + সক্রিয় প্লেব্যাক URL + লোকাল ক্যাশ থেকে চালানো হচ্ছে + মেটাডেটা ট্র্যাক + নাম + ID + অ্যালবাম + নির্ভরযোগ্যতা + এক্সটার্নাল URI + অ্যালবাম ID + অ্যালবামের ধরন + অ্যালবাম প্রকাশের তারিখ + অ্যালবামের ট্র্যাক সংখ্যা + অ্যালবামের জনরা + দৈর্ঘ্য + ট্র্যাক নম্বর + ডিস্ক নম্বর + এক্সপ্লিসিট + জনপ্রিয়তা + ISRC + থাম্বনেইল URL + + + সাইডবার টগল করুন + + + বিকল্প সোর্স + এখন কোনো ট্র্যাক বাজছে না + বিকল্প সোর্স খোঁজা হচ্ছে... + কোনো বিকল্প সোর্স পাওয়া যায়নি + নির্ভরযোগ্যতা: %1$d% + সক্রিয় সোর্স + + + বের করে দিন + তাকে সেশন থেকে সরিয়ে দিন + বের করে দিন এবং আবার যোগ দেওয়া আটকান + সাজেশন সরান + তার কিউতে যোগ করা সব ট্র্যাক সরিয়ে দিন + ক্রম বদলান + কিউ থেকে সরান + %1$s • যোগ করেছেন %2$s + + + অজানা + প্লেলিস্ট লোড হচ্ছে... + ট্র্যাক যোগ করুন + প্লেলিস্টে ট্র্যাক যোগ করুন + ট্র্যাক খুঁজুন... + খুঁজুন + কোনো ট্র্যাক পাওয়া যায়নি + যোগ করার জন্য ট্র্যাক খুঁজুন + সম্পন্ন + যোগ করুন + যোগ করা হয়েছে + + + অ্যালবাম লোড হচ্ছে... + + + আপনার প্লেলিস্টে যোগ করুন + খুঁজুন + আপনার প্লেলিস্ট খুঁজুন... + আপনার প্লেলিস্ট লোড হচ্ছে... + আপনার এখনো কোনো প্লেলিস্ট নেই।\nট্র্যাক যোগ করতে একটি তৈরি করুন। + প্লেলিস্ট তৈরি করুন + প্লেলিস্ট এডিট করুন + তৈরি করুন + নাম + প্লেলিস্টের নাম + বিবরণ + বিবরণ (ঐচ্ছিক) + পাবলিক + যে কেউ এই প্লেলিস্ট দেখতে পারবে + কোলাবোরেটিভ + অন্যরা এই প্লেলিস্টে ট্র্যাক যোগ করতে পারবে + কভার ছবি + প্লেলিস্ট কভার + কোনো ছবি নেই + ছবি পরিবর্তন করুন + ছবি বেছে নিন + + + ইনস্টল করুন + প্রতিস্থাপন করুন + zip ফাইলে plugin.json পাওয়া যায়নি + zip ফাইলে manifest.zipline.json পাওয়া যায়নি + plugin.json-এর ফরম্যাট সঠিক নয়: %1$s + প্লাগইন পড়া যায়নি: %1$s + প্লাগইন ইনস্টল করা যায়নি: %1$s + %1$s-এর একটি নতুন ভার্সন উপলব্ধ। ইনস্টল করার আগে আপডেট নিশ্চিত করুন। + %1$s আগেই ইনস্টল করা আছে। ইনস্টল করার আগে প্রতিস্থাপন নিশ্চিত করুন। + বিল্ট-ইন প্লাগইনগুলো আগে থেকেই অন্তর্ভুক্ত, আবার যোগ করা যাবে না। + বিল্ট-ইন প্লাগইন সরানো যাবে না। + %1$s একটি অবৈধ apiVersion (%2$s) ঘোষণা করেছে। %3$s-এর সাথে সামঞ্জস্যপূর্ণ সিমান্টিক ভার্সনিং প্রত্যাশিত। + Spotube প্লাগইন API ভার্সন %1$s অবৈধ। + %1$s প্লাগইন API %2$s-এর জন্য তৈরি, কিন্তু Spotube ব্রেকিং প্লাগইন API %3$s সাপোর্ট করে। একই ব্রেকিং API ভার্সনের জন্য তৈরি প্লাগইন ইনস্টল করুন। + + + প্লাগইন API সামঞ্জস্যপূর্ণ নয় + প্লাগইন ইনস্টল করবেন? + %1$s আপনার ইনস্টল করা প্লাগইনে যোগ হবে। + প্লাগইন আপডেট করবেন? + একটি পুরোনো ভার্সন (%1$s) ইনস্টল করা আছে। %2$s-এ আপডেট করবেন? + ইনস্টল করা প্লাগইন প্রতিস্থাপন করবেন? + %1$s একটি অবৈধ সিমান্টিক ভার্সন (%2$s) ব্যবহার করে, তাই আপডেট হিসেবে তুলনা করা যাচ্ছে না। তবুও ইনস্টল করা প্লাগইন প্রতিস্থাপন করবেন? + ইনস্টল করা ভার্সন (%1$s) সিমান্টিক ভার্সনিং দিয়ে তুলনা করা যাচ্ছে না। এটিকে %2$s দিয়ে প্রতিস্থাপন করবেন? + ভার্সন %1$s আগেই ইনস্টল করা আছে। বিদ্যমান প্লাগইনটি প্রদত্ত কপি দিয়ে প্রতিস্থাপন করবেন? + ইনস্টল করা ভার্সন %1$s প্রদত্ত ভার্সন %2$s-এর চেয়ে নতুন। তবুও প্রতিস্থাপন করবেন? + ইনস্টল করা প্লাগইনটি প্রদত্ত কপি দিয়ে প্রতিস্থাপন করবেন? + + + %1$s: + ভার্সন + API ভার্সন + লেখক + লাইসেন্স + সক্ষমতা + ক্ষমতা + রিপোজিটরি + যোগাযোগ + বাগ রিপোর্ট করুন + %1$s-কে সাপোর্ট করুন + + + অফিশিয়াল + ভেরিফায়েড + GitHub স্টার + GitHub রিপোজিটরি URL + + + কোনো রিলিজ পাওয়া যায়নি + রিলিজ নির্বাচন করুন + একটি রিলিজ বেছে নিন + প্রি-রিলিজ + রিলিজ নোট + + + হোম + সার্চ + এই ডিভাইসে + Connect + ডিভাইস + + + উইন্ডো লুকান + উইন্ডো দেখান + পরের ট্র্যাক + আগের ট্র্যাক + শাফল + লুপ + লুপ: বন্ধ + লুপ: একটি + লুপ: সব + ভলিউম: %1$d% + ভলিউম + + ভলিউম - + মিউট + আনমিউট + ট্র্যাক লাইক করুন + ট্র্যাক আনলাইক করুন + বের হন + + + মিনিমাইজ + ম্যাক্সিমাইজ + রিস্টোর + বন্ধ করুন + + + প্লেব্যাক + মিডিয়া প্লেব্যাক কন্ট্রোল + + + ব্রাউজ + এখন বাজছে + ফিচার্ড + সেভ করা ট্র্যাক + জনপ্রিয় ট্র্যাক + diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml new file mode 100644 index 00000000..3fb3063b --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -0,0 +1,926 @@ + + + + Warteschlange + Warteschlange leeren + Warteschlange filtern... + Keine Einträge in der Warteschlange + + Songs, Künstler, Alben suchen... + Verlauf löschen + Suchen... + + + Deine Bibliothek + Bibliothek + Playlists + Alben + Künstler + Lokale Titel + Downloads + Gespeicherte Playlists durchsuchen… + Gespeicherte Alben durchsuchen… + Gespeicherte Künstler durchsuchen… + Ordner oder Titel durchsuchen… + Bibliothek filtern + Filter schließen + Playlist erstellen + Lokale Titel aktualisieren + Zur Rasteransicht wechseln + Zur Listenansicht wechseln + Keine gespeicherten Playlists + Keine Playlists für „%1$s“ + Keine gespeicherten Alben + Keine Alben für „%1$s“ + Keine gespeicherten Künstler + Keine Künstler für „%1$s“ + + + Ordner + Ordner + %1$s • %2$s + + %1$d Titel + %1$d Titel + + + %1$d Ordner + %1$d Ordner + + Zurück zu den Ordnern + Ordner abspielen + Audiozugriff erforderlich + Erlaube den Audiozugriff, um Musik auf deinem Gerät zu durchsuchen. + Zugriff erlauben + Keine lokalen Ordner gefunden + Keine Titel in diesem Ordner + Keine passenden Ordner oder Titel + Lokale Medien konnten nicht aktualisiert werden + + + %1$d aktiv • %2$d abgeschlossen + Abgeschlossene entfernen + Noch keine Downloads + Wird heruntergeladen: %1$d% + In Warteschlange + Abgeschlossen + Abgebrochen + Fehlgeschlagen + Fehlgeschlagen: %1$s + Download abbrechen + Download wiederholen + Download entfernen + + + Künstler + Auf der Sperrliste + Lieblingssongs + Deine gespeicherten Songs + Unbekannter Künstler + Titelname + Künstlername + + Ausgewählt + + Einstellungen + + Plugins + Sprache und Region + Darstellung + Wiedergabe + Downloads + Zwischenspeicher + Desktop + Updates + Unterstützen + Über + + Schließen + Speichern + Abbrechen + + Sprache + Sprache der App-Oberfläche. Aktuell: %1$s + Systemstandard + Land + Land für Inhalte und Verfügbarkeit. Aktuell: %1$s + %1$s (%2$s) + + Design + Lege fest, wie sich Spotube an das Erscheinungsbild deines Systems anpasst. Aktuell: %1$s + Hell + Dunkel + System + + Oberfläche + Hintergrundmaterial der App. Aktuell: %1$s + Wasser + Eis + Chrom + Acryl + Glas + + Akzentfarbe + Aktuelle Palette: %1$s + Akzentfarbe + Wähle eine Akzentpalette und sieh sie dir im hellen und dunklen Design an. + Hell + Dunkel + Vorschau + Schaltfläche + Koboldgrün + Elektroviolett + Ozeancyan + Abendrot + Rosengarten + Mitternachtsblau + Schiefermetallic + + Look & Feel + Oberflächenmaterial und Akzent. Aktuell: %1$s · %2$s + Look & Feel + Wähle ein Oberflächenmaterial und dann einen passenden Akzent. Jede Option ist eine sorgfältig abgestimmte Kombination. + Oberfläche + Akzent + Sekundär + + Palette + Millennium-Akzentpalette. Aktuell: %1$s + Palette + Wähle eine Millennium-Akzentpalette und sieh sie dir hell und dunkel an. + Palette + Millennium-Palette und Akzent. Aktuell: %1$s + Blau + Orange + Limette + + Eigener Download-Ordner + Aktueller Ordner: %1$s + Leer lassen, um den Standard-Musikordner zu verwenden. + C:\Music\Spotube + Lokale Medienordner + Eigene Ordner, die auf dem Desktop durchsucht werden. Hinzugefügt: %1$d + Noch kein eigener Ordner hinzugefügt + Eigene Ordner für deine lokale Musikbibliothek. + Verwalten + Ordner hinzufügen + Ordner entfernen + Keine eigenen Ordner hinzugefügt. + %1$s aus der Suchliste entfernen + Download- und Musik-Cache-Ordner werden automatisch einbezogen. + Dein Download-Ordner wird automatisch einbezogen. + Der Musik-Cache-Ordner wird automatisch einbezogen. + Wird gespeichert… + Ordner konnten nicht gespeichert werden. Deine Änderungen sind noch da – versuche es erneut. + + %1$d Ordner + %1$d Ordner + + Download-Format + Bevorzugter Codec und Container für Downloads. Aktuell: %1$s + Download-Qualität + + Streaming-Format + Bevorzugter Codec und Container für Musik-Streaming. Aktuell: %1$s + Streaming-Qualität + Aktuell: %1$s + Musik zwischenspeichern + Gestreamte Audiodaten lokal speichern, damit sie beim erneuten Abspielen schneller laden. + Cache-Ordner + Aktuell: %1$s + Aktuell: Standard-App-Cache + Cache-Größenlimit + Kein Limit + Aktuelles Limit: %1$d MB + Lege die maximale Cache-Größe in MB fest. Gib 0 ein oder lass das Feld leer für kein Limit. + 500 + Die Größe muss 0 oder eine positive Zahl sein. + Endlose Wiedergabe aktivieren + Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter. + Port des Wiedergabe-Proxyservers + Fernsteuerung erlauben + Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden. + Gerätename für die Fernsteuerung + Der Name, den andere Geräte bei der Suche sehen. Aktuell: %1$s + Wähle einen gut erkennbaren Namen, den andere Geräte in deinem Netzwerk sehen, wenn sie diese Spotube-Instanz finden. + z. B. Spotube Wohnzimmer + Hostname des Geräts + Vom Wiedergabe-Proxyserver verwendeter Port. Aktuell: %1$d + Wähle einen Port zwischen 1 und 65535. + 14769 + Gib eine gültige ganze Zahl ein. + Der Port muss zwischen 1 und 65535 liegen. + + Sperrliste + Gesperrte Titel und Künstler verwalten + + In den Infobereich minimieren + Spotube läuft im Infobereich weiter, wenn das Fenster minimiert ist. + Discord Rich Presence + Zeige auf dem Desktop deinen aktuellen Wiedergabestatus in Discord an. + + Automatisch nach Updates suchen + Im Hintergrund nach neuen Spotube-Versionen suchen. + + Plugins verwalten + Verwalte deine Plugins + Standard-Plugin für %1$s + Metadaten + Audio + Songtexte + Scrobbling + Kein Plugin ausgewählt + Ändern + Auswählen + Ausgewählt + Auswahl aufheben + Keine Plugins verfügbar + Plugin %1$s + Löschen + + Konfigurieren + Plugin-Verwaltung + Bitte gib eine URL ein + Die URL muss mit http:// oder https:// beginnen + Plugin konnte nicht heruntergeladen werden + Noch keine Plugins installiert + Füge oben eine Download-URL ein oder wähle eine Datei aus. + + %1$d Plugin installiert + %1$d Plugins installiert + + Plugin installieren + https://example.com/plugin.smplug + Herunterladen + Aus Datei installieren (.smplug) + Aktiv + v%1$s + Integriert + Plugin entfernen + Anmelden + Abmelden + Von URL herunterladen + Aus Datei installieren + Installieren + + • %1$s + Angeforderte Berechtigungen + Dieses Plugin benötigt keine besonderen Berechtigungen. + Installiert vs. neu + Installiert + Neu + API %1$s → %2$s + Dauerhafter Speicher + Daten auf dem Datenträger lesen und schreiben + Netzwerkanfragen + Daten über das Internet senden und empfangen + WebView + Webinhalte in der App anzeigen + Gruppen-Jam + Verbindung testen + Wird getestet… + Verbunden in %1$d ms + Fehlgeschlagen: %1$s + Verbindung + Diagnose + + + Gruppen-Jam + Erstellen + Beitreten + Erstelle einen Raum und teile den Code mit deinen Freunden. + Raum erstellen + Raum beitreten + Verbinden… + Raumcode + ABC123 + Verbunden + Verbindung wird wiederhergestellt… + Code kopieren + Kopiert! + Teilen + Teilnehmer (%1$d) + Host + Zuhörer + Warte auf Zuhörer… + Entfernen + Sperren + Verwalten + Raum verlassen + Verbindung fehlgeschlagen + Schließen + Jam-Session konnte nicht erstellt werden + Beitritt zur Jam-Session fehlgeschlagen + Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein. + Tritt meiner Spotube-Jam bei, Code: %1$s + Raumserver + Server-URL + Gib eine URL ein, die mit https:// beginnt. + Prüfe den Raumserver, bevor du einen Raum startest. + Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle den Server von github.com/team-spotube/jam-server bereit und gib seine URL ein. + Du + Server: %1$s + Lege in den Einstellungen einen Raumserver fest, um einen Raum zu hosten oder beizutreten. + Servereinstellungen + %1$s (du) + Dein Name + Andere im Jam sehen dich als %1$s + Wird allen im Jam angezeigt. Lass das Feld leer, um %1$s zu verwenden. Ein neuer Name gilt ab dem nächsten Raum, den du startest oder dem du beitrittst. + Verwende höchstens %1$d Zeichen. + Kein Jam-Server eingerichtet + Der Jam-Server ist nicht erreichbar. Prüfe dein Netzwerk oder die Server-URL unter Einstellungen → Gruppen-Jam. + Der Jam-Server verwendet eine inkompatible Version. + Kein Raum mit diesem Code. Prüfe ihn und versuche es erneut. + Dieser Raum ist voll. + Der Host hat dich aus diesem Raum gesperrt. + Der Host hat dich aus dem Raum entfernt. + Dieser Raum wurde auf diesem Gerät erneut geöffnet, daher wurde diese Verbindung getrennt. + + Zum Album + Sleep-Timer + Titeldetails + Abspielen + Pause + Keine Wiedergabe + Zur Warteschlange hinzufügen + + Ignorieren + Aktualisieren + Spotube %1$s ist verfügbar + Die Neuerungen dieser Version findest du auf der Spotube-Downloadseite. + %1$s %2$s ist verfügbar + Aktuell installiert: %1$s + Die Neuerungen dieses Updates findest du auf der Seite des Plugins. + + Überspringen + Weiter + Zurück + Los geht\'s + Spotube + Musik ohne Fesseln. + Ein Open-Source-Player für eine ganze Welt voller Musik.\nZum Entdecken. Zum Selbstgestalten. + Halte dieses Projekt am Leben. + Spotube wird von Menschen gemacht, nicht von einem Abo-Unternehmen. Wenn es Teil deines Alltags wird, hilft uns schon eine kleine Unterstützung, ihm die Pflege zu geben, die es verdient.\n\nDanke, dass du unabhängiger Software einen Platz gibst. + Beginne mit einem Katalog-Plugin, um Songs und Playlists zu finden. Wähle ein Audio-Plugin, um sie abzuspielen. Songtexte und Hörverlauf sind optional. Entdecke unten ein Plugin, prüfe seine Berechtigungen, installiere es und wähle es dann hier aus. + Plugins entdecken + Deine Quellen + Erneut versuchen + Weitere Plugins + Keine neuen Plugins gefunden. Du kannst auch oben eines über einen Link oder eine .smplug-Datei installieren. + Die Plugin-Suche ist gerade nicht verfügbar. Versuche es erneut oder installiere über einen Link oder eine Datei. + Willkommen + Unterstützen + Plugins + Anleitung + + So nutzt du Spotube + Vier schnelle Schritte und du hörst Musik. + Anmelden + Verbinde dein Konto oder wähle ein eigenes Katalog-Plugin. + Suchen und abspielen + Finde einen Song, ein Album oder einen Künstler und tippe auf Abspielen. + Offline hören + Lade Titel herunter, um auch ohne Verbindung weiterzuhören. + Songtexte mitlesen + Öffne im Player synchronisierte Songtexte, während ein Titel läuft. + + + Deine Musik. Deine Quellen. Deine Regeln. + Ein kostenloser Open-Source-Musikplayer, entwickelt von einer Community, die Musik einfach liebt. + Open Source + Keine Werbung + Keine Telemetrie + Mit Plugins + Los geht\'s + + + Unterstützer halten uns am Laufen + Mit Leidenschaft gebaut. Von dir am Leben gehalten. + Spotube ist zu 100 % kostenlos, frei und Open Source – keine Werbung, keine Abos, kein Tracking. Jede Funktion gibt es nur, weil Menschen wie du geholfen haben, die Entwicklung am Laufen zu halten. + Patreon + Monatlicher Sponsor + Unterstütze laufende Entwicklung und Infrastruktur + Buy Me a Coffee + Einmaliger Kaffee + Gib einer nächtlichen Coding-Session mit einem kleinen Trinkgeld Energie + Open Collective + Community-Fonds + Transparente, von der Community verwaltete Finanzierung + Von Herzen danke, dass du an offene Musik glaubst. + Die Unterstützung ist völlig freiwillig – Spotube bleibt so oder so kostenlos. + Sponsern + %1$s öffnen + + + Spotube unterstützen + Spotube unterstützen + Spotube ist kostenlos, frei und Open Source – keine Werbung, keine Abos, kein Tracking. Wenn es dir Freude macht, unterstütze doch das Projekt. + Du kannst diese Erinnerungen unter Einstellungen → Unterstützen deaktivieren. + Vielleicht später + Unterstützungs-Erinnerungen + Alle 7 Tage eine dezente Erinnerung anzeigen, das Projekt zu unterstützen. + + + Modulare Architektur + Bestimme, woher deine Musik kommt. + Spotube trennt Katalogsuche, Audio-Streaming und synchronisierte Songtexte. Konfiguriere unten deine Engines oder wechsle sie jederzeit. + Metadaten-Engine + Sorgt für Musikentdeckung, Suche, Titellisten und Playlists. + Audio-Engine + Lädt und dekodiert die abgespielten Audiostreams. + Songtext-Engine + Liefert zeitsynchrone Karaoke-Songtexte in Echtzeit. + Scrobble-Engine + Zeichnet deinen Hörverlauf bei Last.fm oder ListenBrainz auf. + Aktiv + Integriert + Bereit + Optional + Engine auswählen + Installierte Engines + Als Standard festlegen + NewPipe YouTube und LRCLib Lyrics sind integriert und sofort einsatzbereit. + Unter Einstellungen → Plugins kannst du jederzeit Community-Plugins verwalten oder hinzufügen. + + + Retro-moderne Architektur + Reines Hi-Fi, ohne Zwischenhändler + So steuert Spotube die dezentrale Wiedergabe direkt auf deinem Gerät. + DIREKT PEER-TO-PEER • 256 KBPS OPUS + Blaue Stunde + Spotube · Player-Demo + Ein bisschen Musik für die Momente dazwischen. + 1. Dezentrale Entdeckung + Suchanfragen gehen direkt an dein gewähltes Katalog-Plugin. Spotube erstellt nie ein Profil deines Geschmacks. + 2. Direkte Stream-Auflösung + Audio wird direkt von den Content-Servern auf dein Gerät gestreamt. Keine Proxy-Umwege, kein Datensammeln aus der Ferne. + 3. Lokales DSP & Wiedergabe + Lokal dekodiert und zwischengespeichert, mit Equalizer und optionalem Offline-Download. + 4. Synchronisierte Songtexte & Jam + Karaoke-Songtexte folgen der Wiedergabeposition – dazu LAN-Fernsteuerung und Gruppen-Jam. + Keine Zwischenserver. Die Wiedergabe läuft direkt auf deinem Gerät – privat aus Prinzip. + + + Gerät auswählen + %1$s: „%2$s“ + Abspielen + Zur Warteschlange hinzufügen + Als Nächstes abspielen + Dieses Gerät + Auf diesem Gerät + Remote-Gerät + Remote-Gerät (%1$s) + Auf dem verbundenen Gerät + + + Verbindung fehlgeschlagen + Verbindung unterbrochen + Senden fehlgeschlagen + Gerätesuche fehlgeschlagen: %1$s + Verbindung fehlgeschlagen: %1$s + + + Geräte + Aktualisieren + Trennen + Verbinden... + Suche nach Geräten im Netzwerk... + Keine Geräte gefunden + Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist. + Unbekanntes Gerät + Wird aufgelöst... + + + Fernsteuerung + Getrennt + Verbindungsfehler: %1$s + Unbekannter Titel + Unbekannter Künstler + Lautstärke + Aus Warteschlange entfernen + Weitere Optionen + + + Wo abspielen? + Du hast eine aktive Jam-Session. Wähle, wo diese Sammlung abgespielt werden soll. + Hier abspielen + Für Jam vorschlagen + + + Verbindung fehlgeschlagen + Verbindung unterbrochen + + + + Zur Jam-Warteschlange hinzugefügt + + %1$d Titel zur Jam-Warteschlange hinzugefügt + %1$d Titel zur Jam-Warteschlange hinzugefügt + + + + Fernsteuerungsanfrage + „%1$s“ möchte die Wiedergabe auf diesem Gerät steuern. + Ablehnen + Erlauben + Immer erlauben + + + Suchergebnisse + Alle + Titel + Nutzer + Keine Suchtypen verfügbar + Tippe, um zu suchen + Tippe, um nach Titeln zu suchen + Keine Ergebnisse gefunden + Alle anzeigen + Suche läuft + Titel werden geladen... + Alben werden geladen... + Künstler werden geladen... + Playlists werden geladen... + Nutzer werden geladen... + Keine Alben gefunden + Keine Künstler gefunden + Keine Playlists gefunden + Keine Nutzer gefunden + Wir wühlen in den Plattenkisten... + Wir durchsuchen den Kosmos... + Die Lautsprecher wärmen sich auf... + Wir entstauben das Vinyl... + Wir befragen die Musikgötter... + Wir richten die Antennen aus... + Wir blättern durch die Platten... + Suchfunktionen konnten nicht geladen werden + Suche fehlgeschlagen + Titelsuche fehlgeschlagen + Albumsuche fehlgeschlagen + Künstlersuche fehlgeschlagen + Playlist-Suche fehlgeschlagen + Nutzersuche fehlgeschlagen + + + Entdecken + + + Synchron + Nur Text + Keine synchronisierten Songtexte verfügbar + Keine Songtexte verfügbar + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d Kanäle + Kein Metadaten-Plugin ausgewählt + + + Zurück + Erneut versuchen + Dialog schließen + Wird geladen... + Unbekannt + Unbekannter Fehler + + + Nutzer + Playlist + + + Das tut uns wirklich leid + Gerade funktioniert etwas nicht richtig. Dein Erlebnis ist uns wichtig und wir tun unser Bestes, um das zu beheben. Versuche es bitte erneut oder sieh dir die Fehlerdetails unten an. + Fehlerdetails + In die Zwischenablage kopieren + Vollständiges Protokoll anzeigen + Vollständiges Fehlerprotokoll + + + Zufallswiedergabe + Zufällig abspielen + Folgen + Nicht mehr folgen + Playlist bearbeiten + Von %1$s + + + Titeloptionen + Zur Jam hinzufügen + Radio starten + Als Nächstes abspielen + Als Nächstes einreihen + Aus Warteschlange entfernen + Aus Favoriten entfernen + Als Favorit speichern + Zur Playlist hinzufügen + Herunterladen + Teilen + + + Original + Titel + Künstler + Album + Dauer + Sortieren + Keine Titel gefunden + Filtern + Sammelaktionen + Alle herunterladen + %1$d herunterladen + Alle zur Warteschlange hinzufügen + %1$d zur Warteschlange hinzufügen + Alle als Nächstes abspielen + %1$d als Nächstes abspielen + Alle zur Playlist hinzufügen + %1$d zur Playlist hinzufügen + Alle zur Jam hinzufügen + %1$d zur Jam hinzufügen + +%1$d weitere + + + Keine URL zum Laden + Im Browserverlauf zurück + Im Browserverlauf vorwärts + + + Sperrliste filtern... + Keine gesperrten Einträge + Keine gesperrten Einträge für „%1$s“ + Titel + Titel + Zur Sperrliste hinzufügen + Von der Sperrliste entfernen + + + Keine Titel-Metadaten + Stream-URL konnte nicht ermittelt werden + Stream-URL konnte nicht ermittelt werden. Spiele den Titel zuerst ab. + + + Gespeicherte Titel + Du + + + Gefolgt + Top-Titel + Top-Titel abspielen + Top-Titel zur Warteschlange hinzufügen + Ähnliche Künstler + Empfohlene Playlists + Künstlerübersicht konnte nicht geladen werden + Follower nicht verfügbar + %1$s Tsd. Follower + %1$s Mio. Follower + %1$s Mrd. Follower + + %1$d Follower + %1$d Follower + + + + Gefällt mir + Gefällt mir nicht mehr + Platzhalter-Titel + Platzhalter-Künstler + Unbekanntes Album + Spulen + Lautstärke + Zufallswiedergabe aktivieren + Zufallswiedergabe deaktivieren + Zurück + Weiter + Abspielen oder pausieren + Wiederholen aus + Titel wiederholen + Alle wiederholen + Stummschalten + Stummschaltung aufheben + Weitere Optionen + Player-Optionen + Player minimieren + Songtexte erweitern + Aktuelle Wiedergabe + Songtext + Alternative Quelle + Alternative Quelle + Herunterladen + Heruntergeladen + Wird heruntergeladen... + Bereits heruntergeladen + Download wird wiederholt... + %1$s wird heruntergeladen + Die Wiedergabe wird in %1$s pausiert. + Wiedergabe nach der ausgewählten Anzahl an Minuten pausieren. + Minuten + Zwischen 1 und 600 Minuten wählen + Timer abbrechen + Starten + Die Wiedergabe-URL wurde mit anderen Streaming-Einstellungen ermittelt. Der markierte Stream entspricht deiner aktuellen Einstellung; die aktive URL wird bei der nächsten Stream-Anfrage aktualisiert. + Zugeordnete Audioquelle + Zugeordnete Quelle wird geladen… + Beim Abspielen der lokal zwischengespeicherten Datei wird keine zugeordnete Remote-Quelle benötigt. + Keine zugeordnete Quelle verfügbar. + Für diesen Titel ist keine zugeordnete Audioquelle zwischengespeichert. + Stream-Details sind noch nicht zwischengespeichert. Spiele den Titel zuerst ab, um sie zu laden. + Titeldetails konnten nicht geladen werden. + Verfügbare Streams + Dieser Titel wird aus dem lokalen Cache abgespielt. Stream-Varianten werden für diese Wiedergabe nicht verwendet. + Streams werden geladen… + Für diese Quelle sind keine Streams verfügbar. + Aktuell ausgewählter Stream + Entspricht deinen Einstellungen für Streaming-Format & -Qualität + Aktive Wiedergabe-URL + Aus lokalem Cache abgespielt + Metadaten-Titel + Titel + ID + Album + Übereinstimmung + Externe URI + Album-ID + Albumtyp + Erscheinungsdatum des Albums + Anzahl der Titel im Album + Album-Genres + Dauer + Titelnummer + CD-Nummer + Explizit + Beliebtheit + ISRC + Vorschaubild-URLs + + + Seitenleiste ein-/ausblenden + + + Alternative Quellen + Aktuell wird kein Titel abgespielt + Suche nach alternativen Quellen... + Keine alternativen Quellen gefunden + Übereinstimmung: %1$d% + Aktive Quelle + + + Rauswerfen + Aus der Session entfernen + Rauswerfen und erneuten Beitritt verhindern + Vorschläge entfernen + Alle Titel entfernen, die diese Person zur Warteschlange hinzugefügt hat + Neu anordnen + Aus Warteschlange entfernen + %1$s • Hinzugefügt von %2$s + + + Unbekannt + Playlist wird geladen... + Titel hinzufügen + Titel zur Playlist hinzufügen + Titel suchen... + Suchen + Keine Titel gefunden + Suche nach Titeln zum Hinzufügen + Fertig + Hinzufügen + Hinzugefügt + + + Album wird geladen... + + + Zu deiner Playlist hinzufügen + Suchen + Deine Playlists durchsuchen... + Deine Playlists werden geladen... + Du hast noch keine eigenen Playlists.\nErstelle eine, um Titel hinzuzufügen. + Playlist erstellen + Playlist bearbeiten + Erstellen + Name + Name der Playlist + Beschreibung + Beschreibung (optional) + Öffentlich + Alle können diese Playlist sehen + Gemeinsam + Andere können Titel zu dieser Playlist hinzufügen + Coverbild + Playlist-Cover + Kein Bild + Bild ändern + Bild auswählen + + + Installieren + Ersetzen + plugin.json wurde in der ZIP-Datei nicht gefunden + manifest.zipline.json wurde in der ZIP-Datei nicht gefunden + Ungültiges plugin.json-Format: %1$s + Plugin konnte nicht gelesen werden: %1$s + Plugin konnte nicht installiert werden: %1$s + Eine neuere Version von %1$s ist verfügbar. Bestätige das Update vor der Installation. + %1$s ist bereits installiert. Bestätige das Ersetzen vor der Installation. + Integrierte Plugins sind bereits enthalten und können nicht erneut hinzugefügt werden. + Integrierte Plugins können nicht entfernt werden. + %1$s gibt eine ungültige apiVersion an (%2$s). Erwartet wird semantische Versionierung, kompatibel mit %3$s. + Die Spotube-Plugin-API-Version %1$s ist ungültig. + %1$s ist für Plugin-API %2$s gebaut, Spotube unterstützt jedoch die Breaking-API-Version %3$s. Installiere ein Plugin, das für dieselbe Breaking-API-Version gebaut wurde. + + + Plugin-API nicht kompatibel + Plugin installieren? + %1$s wird zu deinen installierten Plugins hinzugefügt. + Plugin aktualisieren? + Eine ältere Version ist installiert (%1$s). Auf %2$s aktualisieren? + Installiertes Plugin ersetzen? + %1$s verwendet eine ungültige semantische Version (%2$s) und kann nicht als Update verglichen werden. Installiertes Plugin trotzdem ersetzen? + Die installierte Version (%1$s) lässt sich nicht per semantischer Versionierung vergleichen. Durch %2$s ersetzen? + Version %1$s ist bereits installiert. Vorhandenes Plugin durch die neue Kopie ersetzen? + Die installierte Version %1$s ist neuer als die bereitgestellte Version %2$s. Trotzdem ersetzen? + Installiertes Plugin durch die neue Kopie ersetzen? + + + %1$s: + Version + API-Version + Autor + Lizenz + Berechtigungen + Funktionen + Repository + Kontakt + Fehler melden + %1$s unterstützen + + + Offiziell + Verifiziert + GitHub-Sterne + URL des GitHub-Repositorys + + + Keine Releases gefunden + Release auswählen + Wähle ein Release + Vorabversion + Versionshinweise + + + Startseite + Suche + Auf dem Gerät + Verbinden + Geräte + + + Fenster ausblenden + Fenster anzeigen + Nächster Titel + Vorheriger Titel + Zufallswiedergabe + Wiederholen + Wiederholen: AUS + Wiederholen: TITEL + Wiederholen: ALLE + Lautstärke: %1$d% + Lautstärke + + Lautstärke - + Stummschalten + Stummschaltung aufheben + Titel liken + Titel nicht mehr liken + Beenden + + + Minimieren + Maximieren + Wiederherstellen + Schließen + + + Wiedergabe + Steuerung der Medienwiedergabe + + + Entdecken + Aktuelle Wiedergabe + Empfohlen + Gespeicherte Titel + Top-Titel + diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml new file mode 100644 index 00000000..3a79f26d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -0,0 +1,932 @@ + + + + Cola + Vaciar cola + Filtrar cola... + La cola está vacía + + Busca canciones, artistas, álbumes... + Borrar todo el historial + Buscar... + + + Tu biblioteca + Biblioteca + Playlists + Álbumes + Artistas + Canciones locales + Descargas + Buscar playlists guardadas… + Buscar álbumes guardados… + Buscar artistas guardados… + Buscar carpetas o canciones… + Filtrar biblioteca + Cerrar filtro + Crear playlist + Actualizar canciones locales + Cambiar a vista de cuadrícula + Cambiar a vista de lista + No hay playlists guardadas + Ninguna playlist coincide con “%1$s” + No hay álbumes guardados + Ningún álbum coincide con “%1$s” + No hay artistas guardados + Ningún artista coincide con “%1$s” + + + Carpetas + Carpeta + %1$s • %2$s + + %1$d canción + %1$d canciones + %1$d canciones + + + %1$d carpeta + %1$d carpetas + %1$d carpetas + + Volver a carpetas + Reproducir carpeta + Se necesita acceso al audio + Permite el acceso al audio para explorar la música de tu dispositivo. + Permitir acceso + No se encontraron carpetas locales + No hay canciones en esta carpeta + No hay carpetas ni canciones que coincidan + No se pudo actualizar el contenido local + + + %1$d activas • %2$d completadas + Borrar completadas + Aún no hay descargas + Descargando %1$d% + En cola + Completada + Cancelada + Error + Error: %1$s + Cancelar descarga + Reintentar descarga + Quitar descarga + + + Artista + En lista negra + Canciones que te gustan + Tus canciones guardadas + Artista desconocido + Título de la canción + Nombre del artista + + Seleccionado + + Ajustes + + Plugins + Idioma y región + Apariencia + Reproducción + Descargas + Caché + Escritorio + Actualizaciones + Apoyo + Acerca de + + Cerrar + Guardar + Cancelar + + Idioma + Idioma de la interfaz de la app. Actual: %1$s + Predeterminado del sistema + País + País de referencia para el contenido y la disponibilidad. Actual: %1$s + %1$s (%2$s) + + Tema + Elige cómo se adapta Spotube a la apariencia del sistema. Actual: %1$s + Claro + Oscuro + Sistema + + Superficie + Material de fondo de la app. Actual: %1$s + Acuoso + Helado + Cromo + Acrílico + Cristal + + Color de acento + Paleta actual: %1$s + Color de acento + Elige una paleta de acento y previsualízala en los temas claro y oscuro. + Claro + Oscuro + Vista previa + Botón + Duende verde + Violeta eléctrico + Cian oceánico + Naranja atardecer + Jardín de rosas + Azul medianoche + Pizarra metálica + + Aspecto y estilo + Material de superficie y acento. Actual: %1$s · %2$s + Aspecto y estilo + Elige un material de superficie y luego un acento que combine con él. Cada opción es una combinación seleccionada. + Superficie + Acento + Secundario + + Paleta + Paleta de acento Millennium. Actual: %1$s + Paleta + Elige una paleta de acento Millennium y previsualízala en claro y oscuro. + Paleta + Paleta Millennium y acento. Actual: %1$s + Azul + Naranja + Lima + + Carpeta de descargas personalizada + Carpeta actual: %1$s + Déjalo vacío para usar la carpeta de música predeterminada. + C:\Music\Spotube + Carpetas de contenido local + Carpetas personalizadas que se analizan en el escritorio. Añadidas: %1$d + Aún no has añadido ninguna carpeta personalizada + Carpetas personalizadas para tu biblioteca de música local. + Gestionar + Añadir carpeta + Quitar carpeta + No se han añadido carpetas personalizadas. + Quitar %1$s de la lista de análisis + Las carpetas de descargas y de caché de música se incluyen automáticamente. + Tu carpeta de descargas se incluye automáticamente. + La carpeta de caché de música se incluye automáticamente. + Guardando… + No se pudieron guardar las carpetas. Tus cambios siguen aquí; inténtalo de nuevo. + + %1$d carpeta + %1$d carpetas + %1$d carpetas + + Formato de descarga + Códec y contenedor preferidos para las descargas. Actual: %1$s + Calidad de descarga + + Formato de streaming + Códec y contenedor preferidos para el streaming de música. Actual: %1$s + Calidad de streaming + Actual: %1$s + Activar caché de música + Guarda el audio reproducido en local para acelerar las siguientes reproducciones. + Carpeta de caché + Actual: %1$s + Actual: caché predeterminada de la app + Límite de tamaño de la caché + Sin límite + Límite actual: %1$d MB + Define el tamaño máximo de la caché en MB. Escribe 0 o déjalo vacío para no tener límite. + 500 + El tamaño debe ser 0 o un número positivo. + Activar reproducción infinita + Sigue reproduciendo canciones recomendadas cuando termine la cola. + Puerto del servidor proxy de reproducción + Permitir control remoto + Permite que otros dispositivos de la misma red controlen la reproducción al conectarse. + Nombre del dispositivo para control remoto + El nombre que ven otros dispositivos al detectar este. Actual: %1$s + Elige un nombre reconocible que verán otros dispositivos de tu red al detectar esta instancia de Spotube. + p. ej., Spotube del salón + nombre de host del dispositivo + Puerto que usa el servidor proxy de reproducción. Actual: %1$d + Elige un puerto entre 1 y 65535. + 14769 + Introduce un número entero válido. + El puerto debe estar entre 1 y 65535. + + Lista negra + Gestiona las canciones y artistas de la lista negra + + Minimizar a la bandeja + Mantén Spotube en ejecución en la bandeja del sistema al minimizar la ventana. + Rich Presence de Discord + Muestra lo que estás escuchando en Discord desde el escritorio. + + Buscar actualizaciones automáticamente + Busca nuevas versiones de Spotube en segundo plano. + + Gestionar plugins + Gestiona tus plugins + Plugin de %1$s predeterminado + Metadatos + Audio + Letras + Scrobbling + Ningún plugin seleccionado + Cambiar + Seleccionar + Seleccionado + Borrar selección + No hay plugins disponibles + Plugin %1$s + Borrar + + Configurar + Gestor de plugins + Introduce una URL + La URL debe empezar por http:// o https:// + No se pudo descargar el plugin + Aún no hay plugins instalados + Pega una URL de descarga o elige un archivo arriba. + + %1$d plugin instalado + %1$d de plugins instalados + %1$d plugins instalados + + Instalar un plugin + https://example.com/plugin.smplug + Descargar + Instalar desde archivo (.smplug) + Activo + v%1$s + Integrado + Quitar plugin + Iniciar sesión + Cerrar sesión + Descargar desde URL + Instalar desde archivo + Instalar + + • %1$s + Permisos solicitados + Este plugin no necesita permisos especiales. + Instalado frente a proporcionado + Instalado + Proporcionado + API %1$s → %2$s + Almacenamiento persistente + Leer y escribir datos en el disco + Solicitudes de red + Enviar y recibir datos por internet + WebView + Mostrar contenido web dentro de la app + Jam en grupo + Probar conexión + Probando… + Conectado en %1$d ms + Error: %1$s + Conexión + Diagnóstico + + + Jam en grupo + Crear + Unirse + Crea una sala y comparte su código con tus amigos. + Crear sala + Unirse a la sala + Conectando… + Código de la sala + ABC123 + Conectado + Reconectando… + Copiar código + ¡Copiado! + Compartir + Participantes (%1$d) + Anfitrión + Oyente + Esperando a los oyentes… + Quitar + Bloquear + Gestionar + Salir de la sala + No se pudo conectar + Cerrar + No se pudo crear la sesión de Jam + No se pudo unir a la sesión de Jam + Introduce un código de sala de seis caracteres con letras y números. + Únete a mi Jam de Spotube con el código: %1$s + Servidor de salas + URL del servidor + Introduce una URL que empiece por https:// + Comprueba el servidor de salas antes de crear una sala. + Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega el servidor de github.com/team-spotube/jam-server e introduce su URL. + Tú + Servidor: %1$s + Configura un servidor de salas en Ajustes para crear una sala o unirte a una. + Ajustes del servidor + %1$s (tú) + Tu nombre + Los demás en la jam te ven como %1$s + Todos en la jam lo verán. Déjalo vacío para usar %1$s. Un nombre nuevo se aplica la próxima vez que crees una sala o te unas a una. + Usa como máximo %1$d caracteres. + No hay ningún servidor de jam configurado + No se pudo conectar con el servidor de jam. Revisa tu red o la URL del servidor en Ajustes → Jam en grupo. + El servidor de jam usa una versión incompatible. + No hay ninguna sala con ese código. Revísalo e inténtalo de nuevo. + Esta sala está llena. + El anfitrión te ha bloqueado en esta sala. + El anfitrión te ha quitado de la sala. + Esta sala se abrió de nuevo en este dispositivo, así que se cerró esta conexión. + + Ir al álbum + Temporizador + Detalles de la canción + Reproducir + Pausar + No se está reproduciendo nada + Añadir a la cola + + Ignorar + Actualizar + Spotube %1$s está disponible + Descubre las novedades de esta versión en la página de descargas de Spotube. + %1$s %2$s está disponible + Versión instalada: %1$s + Descubre las novedades de esta actualización en la página del plugin. + + Omitir + Siguiente + Atrás + Empezar + Spotube + Música, sin ataduras. + Un reproductor de código abierto para todo un mundo de música.\nPara que lo explores. Para que lo hagas tuyo. + Mantén vivo este proyecto. + Spotube lo hacen personas, no un negocio de suscripciones. Si se convierte en parte de tu día a día, un pequeño apoyo nos ayuda a darle el cuidado que merece.\n\nGracias por hacerle un hueco al software independiente. + Empieza con un plugin de catálogo para encontrar canciones y playlists. Elige un plugin de audio para reproducirlas. Las letras y el historial de escucha son opcionales. Descubre un plugin abajo, revisa sus permisos, instálalo y selecciónalo aquí. + Descubrir plugins + Tus fuentes + Reintentar + Más plugins + No se encontraron plugins nuevos. También puedes instalar uno desde un enlace o un archivo .smplug arriba. + La búsqueda de plugins no está disponible ahora. Inténtalo de nuevo o instala uno desde un enlace o archivo. + Bienvenida + Apoyo + Plugins + Guía + + Cómo usar Spotube + Cuatro pasos rápidos y a escuchar. + Inicia sesión + Conecta tu cuenta o elige tu propio plugin de catálogo. + Busca y reproduce + Encuentra cualquier canción, álbum o artista y dale al play. + Escucha sin conexión + Descarga canciones para seguir escuchando sin conexión. + Sigue la letra + Abre la letra sincronizada desde el reproductor mientras suena una canción. + + + Tu música. Tus fuentes. Tus reglas. + Un reproductor de música gratuito y de código abierto creado por una comunidad que simplemente ama la música. + Código abierto + Sin anuncios + Sin telemetría + Basado en plugins + ¡Vamos! + + + Nuestros mecenas nos mantienen en marcha + Hecho con pasión. Mantenido gracias a ti. + Spotube es 100 % gratuito, libre y de código abierto: sin anuncios, sin suscripciones, sin rastreo. Cada función existe porque personas como tú decidieron ayudar a que el código siga fluyendo. + Patreon + Mecenas mensual + Apoya el desarrollo continuo y la infraestructura + Buy Me a Coffee + Un café puntual + Da energía a una sesión de programación nocturna con una pequeña propina + Open Collective + Fondo comunitario + Financiación transparente y gestionada por la comunidad + Gracias de todo corazón por creer en la música abierta. + Apoyar es totalmente opcional: Spotube seguirá siendo gratis de todos modos. + Patrocinar + Abrir %1$s + + + Apoyar a Spotube + Apoyar a Spotube + Spotube es gratuito, libre y de código abierto: sin anuncios, sin suscripciones, sin rastreo. Si te alegra el día, plantéate apoyar el proyecto. + Puedes desactivar estos recordatorios en Ajustes → Apoyo. + Quizás más tarde + Recordatorios de apoyo + Muestra un recordatorio amable para apoyar el proyecto cada 7 días. + + + Arquitectura modular + Elige de dónde viene tu música. + Spotube separa el catálogo, el streaming de audio y las letras sincronizadas. Configura tus motores abajo o cámbialos cuando quieras. + Motor de metadatos + Se encarga del descubrimiento de música, la búsqueda, las listas de canciones y las playlists. + Motor de audio + Obtiene y decodifica las secuencias de audio que se reproducen. + Motor de letras + Muestra letras de karaoke sincronizadas en tiempo real. + Motor de scrobbling + Registra tu historial de escucha en Last.fm o ListenBrainz. + Activo + Integrado + Listo + Opcional + Selecciona un motor + Motores instalados + Establecer como predeterminado + NewPipe YouTube y LRCLib Lyrics vienen integrados y listos para usar desde el primer momento. + Gestiona o añade nuevos plugins de la comunidad cuando quieras en Ajustes → Plugins. + + + Arquitectura retromoderna + Alta fidelidad pura, sin intermediarios + Así es como Spotube orquesta la reproducción descentralizada directamente en tu equipo. + P2P DIRECTO • OPUS A 256 KBPS + Hora azul + Spotube · demostración del reproductor + Un poco de música para los momentos intermedios. + 1. Descubrimiento descentralizado + Las búsquedas van directamente al plugin de catálogo que elijas. Spotube nunca analiza tus gustos. + 2. Resolución directa del stream + El audio llega directamente de los servidores de contenido a tu equipo. Sin saltos por proxies ni recopilación remota de datos. + 3. DSP y reproducción en local + Decodificado y almacenado en caché localmente, con ecualizador y descarga opcional para escuchar sin conexión. + 4. Letras sincronizadas y Jam + Las letras de karaoke siguen la reproducción al segundo, con control remoto por LAN y Jam en grupo. + Sin servidores intermediarios. La reproducción ocurre en tu dispositivo: privada por diseño. + + + Elige un dispositivo + %1$s: «%2$s» + Reproducir + Añadir a la cola + Reproducir a continuación + Este dispositivo + En este dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + En el dispositivo conectado + + + Error de conexión + Se perdió la conexión + Error al enviar + Error al buscar dispositivos: %1$s + No se pudo conectar: %1$s + + + Dispositivos + Actualizar + Desconectar + Conectando... + Buscando dispositivos en la red... + No se encontraron dispositivos + Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes. + Dispositivo desconocido + Resolviendo... + + + Control remoto + Desconectado + Error de conexión: %1$s + Canción desconocida + Artista desconocido + Volumen + Quitar de la cola + Más opciones + + + ¿Dónde reproducir? + Tienes una sesión de Jam activa. Elige dónde reproducir esta colección. + Reproducir aquí + Sugerir al Jam + + + Error de conexión + Se perdió la conexión + + + + Añadida a la cola del Jam + + %1$d añadida a la cola del Jam + %1$d añadidas a la cola del Jam + %1$d añadidas a la cola del Jam + + + + Solicitud de control remoto + \"%1$s\" quiere controlar la reproducción en este dispositivo. + Denegar + Permitir + Permitir siempre + + + Resultados de búsqueda + Todo + Canciones + Usuarios + No hay tipos de búsqueda disponibles + Empieza a escribir para buscar + Empieza a escribir para buscar canciones + No se encontraron resultados + Ver todo + Buscando + Cargando canciones... + Cargando álbumes... + Cargando artistas... + Cargando playlists... + Cargando usuarios... + No se encontraron álbumes + No se encontraron artistas + No se encontraron playlists + No se encontraron usuarios + Rebuscando entre los discos... + Buscando por el cosmos... + Calentando los altavoces... + Quitando el polvo a los vinilos... + Consultando a los dioses de la música... + Sintonizando las antenas... + Pasando discos... + No se pudieron cargar las opciones de búsqueda + No se pudo realizar la búsqueda + No se pudieron buscar canciones + No se pudieron buscar álbumes + No se pudieron buscar artistas + No se pudieron buscar playlists + No se pudieron buscar usuarios + + + Explorar + + + Sincronizada + Texto + No hay letra sincronizada disponible + No hay letra disponible + + + %1$s en %2$s + %1$d kbps + %1$s kHz • %2$d canales + No hay ningún plugin de metadatos seleccionado + + + Atrás + Reintentar + Cerrar diálogo + Cargando... + Desconocido + Error desconocido + + + Usuario + Playlist + + + Lo sentimos de verdad + Algo no funciona bien en este momento. Tu experiencia nos importa y estamos haciendo todo lo posible para solucionarlo. Inténtalo de nuevo o consulta los detalles del error a continuación. + Detalles del error + Copiar al portapapeles + Ver registro completo + Registro de errores completo + + + Aleatorio + Reproducción aleatoria + Seguir + Dejar de seguir + Editar playlist + De %1$s + + + Opciones de la canción + Añadir al Jam + Iniciar radio + Reproducir a continuación + Mover a siguiente + Quitar de la cola + Quitar de favoritos + Guardar en favoritos + Añadir a playlist + Descargar + Compartir + + + Original + Título + Artista + Álbum + Duración + Ordenar + No se encontraron canciones + Filtrar + Acciones en bloque + Descargar todo + Descargar %1$d + Añadir todo a la cola + Añadir %1$d a la cola + Reproducir todo a continuación + Reproducir %1$d a continuación + Añadir todo a una playlist + Añadir %1$d a una playlist + Añadir todo al Jam + Añadir %1$d al Jam + +%1$d más + + + No hay ninguna URL que cargar + Retroceder en el historial del navegador + Avanzar en el historial del navegador + + + Filtrar lista negra... + No hay elementos en la lista negra + Ningún elemento de la lista negra coincide con “%1$s” + Canciones + Canción + Añadir a la lista negra + Quitar de la lista negra + + + No hay metadatos de la canción + No se pudo obtener la URL del stream + No se pudo obtener la URL del stream. Prueba a reproducir la canción primero. + + + Canciones guardadas + Tú + + + Siguiendo + Canciones populares + Reproducir canciones populares + Añadir canciones populares a la cola + Artistas similares + Playlists destacadas + No se pudo cargar la información del artista + Seguidores no disponibles + %1$s mil seguidores + %1$s M de seguidores + %1$s mil M de seguidores + + %1$d seguidor + %1$d de seguidores + %1$d seguidores + + + + Me gusta + Ya no me gusta + Título de canción no disponible + Artistas no disponibles + Álbum desconocido + Avanzar + Volumen + Activar aleatorio + Desactivar aleatorio + Anterior + Siguiente + Reproducir o pausar + Repetición desactivada + Repetir una + Repetir todo + Silenciar + Activar sonido + Más opciones + Opciones del reproductor + Contraer reproductor + Ampliar letra + Reproduciendo ahora + Letra + Fuente alternativa + Fuente alternativa + Descargar + Descargada + Descargando... + Ya descargada + Reintentando la descarga... + Descargando %1$s + La reproducción se pausará en %1$s. + Pausa la reproducción tras el número de minutos seleccionado. + Minutos + Elige entre 1 y 600 minutos + Cancelar temporizador + Iniciar + La URL de reproducción se obtuvo con otros ajustes de streaming. El stream resaltado coincide con tu preferencia actual; la URL activa se actualizará en la próxima solicitud de stream. + Fuente de audio asociada + Cargando fuente asociada… + No se necesita una fuente remota asociada mientras se reproduce el archivo local en caché. + No hay ninguna fuente asociada disponible. + No hay ninguna fuente de audio asociada en caché para esta canción. + Los detalles del stream aún no están en caché. Reproduce primero la canción para cargarlos. + No se pudieron cargar los detalles de la canción. + Streams disponibles + Esta canción se está reproduciendo desde la caché local. Las variantes de stream no se usan en esta reproducción. + Cargando streams… + No hay streams disponibles para esta fuente. + Stream seleccionado actualmente + Coincide con tu preferencia de formato y calidad de streaming + URL de reproducción activa + Reproducido desde la caché local + Canción de metadatos + Título + ID + Álbum + Fiabilidad + URI externa + ID del álbum + Tipo de álbum + Fecha de lanzamiento del álbum + Número de canciones del álbum + Géneros del álbum + Duración + Número de pista + Número de disco + Explícito + Popularidad + ISRC + URL de miniaturas + + + Mostrar u ocultar barra lateral + + + Fuentes alternativas + No se está reproduciendo ninguna canción + Buscando fuentes alternativas... + No se encontraron fuentes alternativas + Fiabilidad: %1$d% + Fuente activa + + + Expulsar + Sacarlo de la sesión + Expulsarlo e impedir que vuelva a unirse + Quitar sugerencias + Quitar todas las canciones que añadió a la cola + Reordenar + Quitar de la cola + %1$s • Añadida por %2$s + + + Desconocido + Cargando playlist... + Añadir canciones + Añadir canciones a la playlist + Buscar canciones... + Buscar + No se encontraron canciones + Busca canciones para añadir + Listo + Añadir + Añadida + + + Cargando álbum... + + + Añadir a tu playlist + Buscar + Busca en tus playlists... + Cargando tus playlists... + Aún no tienes ninguna playlist.\nCrea una para añadir canciones. + Crear playlist + Editar playlist + Crear + Nombre + Nombre de la playlist + Descripción + Descripción (opcional) + Pública + Cualquiera puede ver esta playlist + Colaborativa + Otras personas pueden añadir canciones a esta playlist + Imagen de portada + Portada de la playlist + Sin imagen + Cambiar imagen + Elegir imagen + + + Instalar + Reemplazar + No se encontró plugin.json en el archivo zip + No se encontró manifest.zipline.json en el archivo zip + Formato de plugin.json no válido: %1$s + No se pudo leer el plugin: %1$s + No se pudo instalar el plugin: %1$s + Hay una versión más reciente de %1$s. Confirma la actualización antes de instalarla. + %1$s ya está instalado. Confirma que quieres reemplazarlo antes de instalarlo. + Los plugins integrados ya están incluidos y no se pueden volver a añadir. + Los plugins integrados no se pueden quitar. + %1$s declara una apiVersion no válida (%2$s). Se esperaba un versionado semántico compatible con %3$s. + La versión %1$s de la API de plugins de Spotube no es válida. + %1$s está diseñado para la API de plugins %2$s, pero Spotube es compatible con la API de plugins %3$s, que incluye cambios incompatibles. Instala un plugin creado para la misma versión principal de la API. + + + API de plugin no compatible + ¿Instalar plugin? + %1$s se añadirá a tus plugins instalados. + ¿Actualizar plugin? + Hay una versión anterior instalada (%1$s). ¿Actualizar a %2$s? + ¿Reemplazar el plugin instalado? + %1$s usa una versión semántica no válida (%2$s) y no se puede comparar como actualización. ¿Reemplazar el plugin instalado de todos modos? + La versión instalada (%1$s) no se puede comparar con el versionado semántico. ¿Reemplazarla por %2$s? + La versión %1$s ya está instalada. ¿Reemplazar el plugin existente por la copia proporcionada? + La versión instalada %1$s es más reciente que la versión proporcionada %2$s. ¿Reemplazarla de todos modos? + ¿Reemplazar el plugin instalado por la copia proporcionada? + + + %1$s: + Versión + Versión de la API + Autor + Licencia + Capacidades + Funciones + Repositorio + Contacto + Informar de errores + Apoyar a %1$s + + + Oficial + Verificado + Estrellas en GitHub + URL del repositorio de GitHub + + + No se encontraron versiones + Seleccionar versión + Selecciona una versión + Versión preliminar + Notas de la versión + + + Inicio + Buscar + En el dispositivo + Connect + Dispositivos + + + Ocultar ventana + Mostrar ventana + Siguiente canción + Canción anterior + Aleatorio + Repetir + Repetir: NO + Repetir: UNA + Repetir: TODO + Volumen: %1$d% + Volumen + + Volumen - + Silenciar + Activar sonido + Me gusta + Ya no me gusta + Salir + + + Minimizar + Maximizar + Restaurar + Cerrar + + + Reproducción + Controles de reproducción multimedia + + + Explorar + Reproduciendo ahora + Destacados + Canciones guardadas + Canciones populares + diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml new file mode 100644 index 00000000..7ad121f8 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -0,0 +1,926 @@ + + + + صف پخش + پاک کردن صف + فیلتر صف... + صف پخش خالی است + + جستجوی آهنگ‌ها، هنرمندان، آلبوم‌ها... + پاک کردن کل تاریخچه + جستجو... + + + کتابخانهٔ شما + کتابخانه + پلی‌لیست‌ها + آلبوم‌ها + هنرمندان + آهنگ‌های محلی + دانلودها + جستجو در پلی‌لیست‌های ذخیره‌شده… + جستجو در آلبوم‌های ذخیره‌شده… + جستجو در هنرمندان ذخیره‌شده… + جستجوی پوشه‌ها یا آهنگ‌ها… + فیلتر کتابخانه + بستن فیلتر + ساخت پلی‌لیست + به‌روزرسانی آهنگ‌های محلی + نمایش شبکه‌ای + نمایش فهرستی + هیچ پلی‌لیست ذخیره‌شده‌ای ندارید + هیچ پلی‌لیستی با «%1$s» مطابقت ندارد + هیچ آلبوم ذخیره‌شده‌ای ندارید + هیچ آلبومی با «%1$s» مطابقت ندارد + هیچ هنرمند ذخیره‌شده‌ای ندارید + هیچ هنرمندی با «%1$s» مطابقت ندارد + + + پوشه‌ها + پوشه + %1$s • %2$s + + %1$d آهنگ + %1$d آهنگ + + + %1$d پوشه + %1$d پوشه + + بازگشت به پوشه‌ها + پخش پوشه + دسترسی به صدا لازم است + برای مرور موسیقی‌های دستگاهتان، اجازهٔ دسترسی به فایل‌های صوتی را بدهید. + اعطای دسترسی + هیچ پوشهٔ محلی‌ای پیدا نشد + این پوشه آهنگی ندارد + هیچ پوشه یا آهنگ مطابقی پیدا نشد + به‌روزرسانی رسانه‌های محلی ناموفق بود + + + %1$d در حال انجام • %2$d تکمیل‌شده + پاک کردن تکمیل‌شده‌ها + هنوز دانلودی ندارید + در حال دانلود %1$d% + در صف + تکمیل شد + لغو شد + ناموفق + ناموفق: %1$s + لغو دانلود + تلاش دوباره برای دانلود + حذف دانلود + + + هنرمند + در فهرست سیاه + آهنگ‌های پسندیده + آهنگ‌های ذخیره‌شدهٔ شما + هنرمند ناشناس + عنوان آهنگ + نام هنرمند + + انتخاب‌شده + + تنظیمات + + افزونه‌ها + زبان و منطقه + ظاهر + پخش + دانلودها + حافظهٔ پنهان + دسکتاپ + به‌روزرسانی‌ها + حمایت + درباره + + بستن + ذخیره + لغو + + زبان + زبان رابط کاربری برنامه. فعلی: %1$s + پیش‌فرض سیستم + کشور + کشور منطقه برای محتوا و دسترس‌پذیری. فعلی: %1$s + %1$s (%2$s) + + پوسته + انتخاب کنید Spotube چگونه با ظاهر سیستم شما هماهنگ شود. فعلی: %1$s + روشن + تیره + سیستم + + سطح + جنس پس‌زمینهٔ برنامه. فعلی: %1$s + آبی‌گون + یخی + کروم + آکریلیک + شیشه + + رنگ تأکیدی + پالت فعلی: %1$s + رنگ تأکیدی + یک پالت رنگ تأکیدی انتخاب کنید و پیش‌نمایش آن را در پوستهٔ روشن و تیره ببینید. + روشن + تیره + پیش‌نمایش + دکمه + گابلین سبز + بنفش الکتریکی + فیروزه‌ای اقیانوسی + نارنجی غروب + باغ رز + آبی نیمه‌شب + خاکستری فلزی + + ظاهر و حس + جنس سطح و رنگ تأکیدی. فعلی: %1$s · %2$s + ظاهر و حس + یک جنس سطح انتخاب کنید، سپس رنگ تأکیدی‌ای که با آن جور باشد. هر گزینه ترکیبی دست‌چین‌شده است. + سطح + رنگ تأکیدی + ثانویه + + پالت + پالت رنگ تأکیدی Millennium. فعلی: %1$s + پالت + یک پالت رنگ تأکیدی Millennium انتخاب کنید و پیش‌نمایش آن را در حالت روشن و تیره ببینید. + پالت + پالت Millennium و رنگ تأکیدی. فعلی: %1$s + آبی + نارنجی + لیمویی + + پوشهٔ دانلود سفارشی + پوشهٔ فعلی: %1$s + برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید. + C:\Music\Spotube + پوشه‌های رسانهٔ محلی + پوشه‌های سفارشی برای اسکن در دسکتاپ. افزوده‌شده: %1$d + هنوز پوشهٔ سفارشی‌ای اضافه نشده است + پوشه‌های سفارشی برای کتابخانهٔ موسیقی محلی شما. + مدیریت + افزودن پوشه + حذف پوشه + هیچ پوشهٔ سفارشی‌ای اضافه نشده است. + حذف %1$s از فهرست اسکن + پوشه‌های دانلود و حافظهٔ پنهان موسیقی به‌طور خودکار اضافه می‌شوند. + پوشهٔ دانلود شما به‌طور خودکار اضافه می‌شود. + پوشهٔ حافظهٔ پنهان موسیقی به‌طور خودکار اضافه می‌شود. + در حال ذخیره… + ذخیرهٔ پوشه‌ها ممکن نشد. تغییرات شما هنوز اینجاست؛ دوباره تلاش کنید. + + %1$d پوشه + %1$d پوشه + + قالب دانلود موسیقی + کدک و قالب ترجیحی برای دانلودها. فعلی: %1$s + کیفیت دانلود موسیقی + + قالب پخش آنلاین موسیقی + کدک و قالب ترجیحی برای پخش آنلاین موسیقی. فعلی: %1$s + کیفیت پخش آنلاین موسیقی + فعلی: %1$s + فعال‌سازی حافظهٔ پنهان موسیقی + صدای پخش‌شده را به‌صورت محلی ذخیره کنید تا پخش دوباره سریع‌تر شود. + پوشهٔ حافظهٔ پنهان + فعلی: %1$s + فعلی: حافظهٔ پنهان پیش‌فرض برنامه + محدودیت حجم حافظهٔ پنهان + بدون محدودیت + محدودیت فعلی: %1$d MB + حداکثر حجم حافظهٔ پنهان را به MB تعیین کنید. برای نامحدود، 0 وارد کنید یا خالی بگذارید. + 500 + حجم باید 0 یا یک عدد مثبت باشد. + فعال‌سازی پخش بی‌پایان + وقتی صف پخش تمام شد، پخش با آهنگ‌های پیشنهادی ادامه پیدا کند. + درگاه سرور پراکسی پخش + اجازهٔ کنترل از راه دور + به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند. + نام دستگاه برای کنترل از راه دور + نامی که دستگاه‌های دیگر هنگام یافتن این دستگاه می‌بینند. فعلی: %1$s + یک نام دوستانه انتخاب کنید تا دستگاه‌های دیگر شبکه هنگام یافتن این نسخه از Spotube آن را ببینند. + مثلاً Spotube اتاق نشیمن + نام میزبان دستگاه + درگاه مورد استفادهٔ سرور پراکسی پخش. فعلی: %1$d + درگاهی بین 1 و 65535 انتخاب کنید. + 14769 + یک عدد صحیح معتبر وارد کنید. + درگاه باید بین 1 و 65535 باشد. + + فهرست سیاه + مدیریت آهنگ‌ها و هنرمندان فهرست سیاه + + کوچک‌سازی به سینی سیستم + وقتی پنجره کوچک می‌شود، Spotube در سینی سیستم در حال اجرا بماند. + وضعیت Discord + وضعیت پخش فعلی خود را در Discord روی دسکتاپ نمایش دهید. + + بررسی خودکار به‌روزرسانی‌ها + نسخه‌های جدید Spotube را در پس‌زمینه بررسی کنید. + + مدیریت افزونه‌ها + افزونه‌های خود را مدیریت کنید + افزونهٔ پیش‌فرض %1$s + فراداده + صدا + متن آهنگ + اسکراب + هیچ افزونه‌ای انتخاب نشده است + تغییر + انتخاب + انتخاب‌شده + پاک کردن انتخاب + هیچ افزونه‌ای در دسترس نیست + افزونهٔ %1$s + پاک کردن + + پیکربندی + مدیریت افزونه‌ها + لطفاً یک URL وارد کنید + URL باید با http:// یا https:// شروع شود + دانلود افزونه ناموفق بود + هنوز افزونه‌ای نصب نشده است + یک URL دانلود وارد کنید یا از بالا فایلی انتخاب کنید. + + %1$d افزونه نصب‌شده + %1$d افزونه نصب‌شده + + نصب افزونه + https://example.com/plugin.smplug + دانلود + نصب از فایل (.smplug) + فعال + v%1$s + داخلی + حذف افزونه + ورود + خروج + دانلود از URL + نصب از فایل + نصب + + • %1$s + مجوزهای درخواستی + این افزونه به مجوز خاصی نیاز ندارد. + نصب‌شده در برابر ارائه‌شده + نصب‌شده + ارائه‌شده + API %1$s → %2$s + ذخیره‌سازی دائمی + خواندن و نوشتن داده روی دیسک + درخواست‌های شبکه + ارسال و دریافت داده از طریق اینترنت + WebView + نمایش محتوای وب درون برنامه + جم گروهی + آزمایش اتصال + در حال آزمایش… + در %1$d میلی‌ثانیه متصل شد + ناموفق: %1$s + اتصال + عیب‌یابی + + + جم گروهی + ساختن + پیوستن + یک اتاق بسازید و کد آن را با دوستانتان به اشتراک بگذارید. + ساخت اتاق + پیوستن به اتاق + در حال اتصال… + کد اتاق + ABC123 + متصل + در حال اتصال دوباره… + کپی کد + کپی شد! + اشتراک‌گذاری + شرکت‌کنندگان (%1$d) + میزبان + شنونده + در انتظار شنوندگان… + حذف + مسدود کردن + مدیریت + ترک اتاق + اتصال برقرار نشد + بستن + ساخت جلسهٔ جم ناموفق بود + پیوستن به جلسهٔ جم ناموفق بود + یک کد اتاق شش‌نویسه‌ای شامل حروف و اعداد وارد کنید. + با این کد به جم Spotube من بپیوندید: %1$s + سرور اتاق‌ها + نشانی سرور + نشانی‌ای وارد کنید که با https:// شروع شود + پیش از شروع اتاق، سرور اتاق‌ها را بررسی کنید. + جم از سرور رایگان اتاق‌های Spotube استفاده می‌کند. برای میزبانی شخصی، سرور را از github.com/team-spotube/jam-server راه‌اندازی کنید و نشانی آن را وارد کنید. + شما + سرور: %1$s + برای میزبانی یا پیوستن به اتاق، در تنظیمات یک سرور اتاق تعیین کنید. + تنظیمات سرور + %1$s (شما) + نام شما + دیگران در جم شما را با نام %1$s می‌بینند + به همه در جم نشان داده می‌شود. برای استفاده از %1$s خالی بگذارید. نام جدید از دفعهٔ بعدی که اتاقی می‌سازید یا به آن می‌پیوندید اعمال می‌شود. + حداکثر %1$d نویسه وارد کنید. + هیچ سرور جمی تنظیم نشده است + دسترسی به سرور جم ممکن نیست. شبکه یا نشانی سرور را در تنظیمات ← جم گروهی بررسی کنید. + سرور جم نسخهٔ ناسازگاری اجرا می‌کند. + اتاقی با این کد وجود ندارد. آن را بررسی کنید و دوباره امتحان کنید. + این اتاق پر است. + میزبان شما را از این اتاق مسدود کرد. + میزبان شما را از اتاق بیرون کرد. + این اتاق دوباره روی همین دستگاه باز شد، بنابراین این اتصال بسته شد. + + رفتن به آلبوم + زمان‌سنج خواب + جزئیات آهنگ + پخش + توقف + چیزی در حال پخش نیست + افزودن به صف + + نادیده گرفتن + به‌روزرسانی + Spotube %1$s در دسترس است + تازه‌های این نسخه را در صفحهٔ دانلود Spotube ببینید. + %1$s %2$s در دسترس است + نسخهٔ نصب‌شدهٔ فعلی: %1$s + تازه‌های این به‌روزرسانی را در صفحهٔ افزونه ببینید. + + رد شدن + بعدی + بازگشت + شروع کنید + Spotube + موسیقی، بی‌هیچ قید و بندی. + پخش‌کننده‌ای متن‌باز برای دنیایی از موسیقی.\nبرای کشف کردن. برای از آنِ خود کردن. + این پروژه را زنده نگه دارید. + Spotube را آدم‌ها ساخته‌اند، نه یک کسب‌وکار اشتراکی. اگر بخشی از زندگی روزمره‌تان شده، کمی حمایت به ما کمک می‌کند آن‌طور که شایسته است از آن مراقبت کنیم.\n\nسپاس که برای نرم‌افزار مستقل جا باز می‌کنید. + با یک افزونهٔ کاتالوگ شروع کنید تا آهنگ‌ها و پلی‌لیست‌ها را پیدا کنید. یک افزونهٔ صوتی برای پخش آن‌ها انتخاب کنید. متن آهنگ و تاریخچهٔ شنیدن اختیاری‌اند. افزونه‌ای را در پایین پیدا کنید، مجوزهایش را بررسی کنید، نصبش کنید و سپس آن را اینجا انتخاب کنید. + کشف افزونه‌ها + منابع شما + تلاش دوباره + افزونه‌های بیشتر + افزونهٔ جدیدی پیدا نشد. می‌توانید از طریق پیوند یا فایل .smplug در بالا هم افزونه نصب کنید. + کشف افزونه‌ها در حال حاضر در دسترس نیست. دوباره تلاش کنید یا از پیوند یا فایل نصب کنید. + خوش آمدید + حمایت + افزونه‌ها + راهنما + + نحوهٔ استفاده از Spotube + چهار گام سریع و مشغول شنیدن هستید. + ورود + حساب خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید. + جستجو و پخش + هر آهنگ، آلبوم یا هنرمندی را پیدا کنید و پخش را بزنید. + گوش دادن آفلاین + آهنگ‌ها را دانلود کنید تا بدون اینترنت هم پخش شوند. + همراهی با متن آهنگ + هنگام پخش آهنگ، متن همگام‌شده را از پخش‌کننده باز کنید. + + + موسیقی شما. منابع شما. قوانین شما. + پخش‌کننده‌ای رایگان و متن‌باز، ساختهٔ جامعه‌ای که فقط عاشق موسیقی است. + متن‌باز + بدون تبلیغات + بدون ردیابی + مبتنی بر افزونه + برویم + + + حامیان ما را سرپا نگه می‌دارند + ساخته‌شده با عشق. زنده به لطف شما. + Spotube صددرصد رایگان، آزاد و متن‌باز است — بدون تبلیغات، بدون اشتراک، بدون ردیابی. هر قابلیتی وجود دارد چون کسانی مثل شما تصمیم گرفتند به ادامهٔ توسعه کمک کنند. + Patreon + حامی ماهانه + از توسعه و زیرساخت مستمر حمایت کنید + Buy Me a Coffee + یک قهوهٔ یک‌باره + با یک انعام کوچک، به یک جلسهٔ کدنویسی شبانه انرژی بدهید + Open Collective + صندوق جامعه + پشتیبانی مالی شفاف و تحت مدیریت جامعه + از صمیم قلب سپاسگزاریم که به موسیقی آزاد باور دارید. + حمایت کاملاً اختیاری است — Spotube در هر صورت رایگان می‌ماند. + حمایت + باز کردن %1$s + + + حمایت از Spotube + حمایت از Spotube + Spotube رایگان، آزاد و متن‌باز است — بدون تبلیغات، بدون اشتراک، بدون ردیابی. اگر برایتان شادی می‌آورد، حمایت از پروژه را در نظر بگیرید. + می‌توانید این یادآوری‌ها را در تنظیمات → حمایت خاموش کنید. + شاید بعداً + یادآوری‌های حمایت + هر ۷ روز یک یادآوری ملایم برای حمایت از پروژه نمایش داده شود. + + + معماری ماژولار + انتخاب کنید موسیقی‌تان از کجا بیاید. + Spotube کشف کاتالوگ، پخش صدا و متن همگام‌شده را از هم جدا می‌کند. موتورهای خود را در پایین پیکربندی کنید یا هر زمان عوضشان کنید. + موتور فراداده + کشف موسیقی، جستجو، فهرست آهنگ‌ها و پلی‌لیست‌ها را ممکن می‌کند. + موتور صدا + جریان‌های صوتی در حال پخش را دریافت و رمزگشایی می‌کند. + موتور متن آهنگ + متن کارائوکهٔ همگام با زمان را به‌صورت زنده نمایش می‌دهد. + موتور اسکراب + تاریخچهٔ شنیدن را در Last.fm یا ListenBrainz ثبت می‌کند. + فعال + داخلی + آماده + اختیاری + یک موتور انتخاب کنید + موتورهای نصب‌شده + تنظیم به‌عنوان پیش‌فرض + NewPipe YouTube و LRCLib Lyrics داخلی هستند و از همان ابتدا آمادهٔ پخش‌اند. + هر زمان در تنظیمات → افزونه‌ها، افزونه‌های جامعه را مدیریت یا اضافه کنید. + + + معماری رترو-مدرن + Hi-Fi خالص، بدون واسطه + این‌گونه Spotube پخش غیرمتمرکز را مستقیماً روی دستگاه شما هماهنگ می‌کند. + نظیربه‌نظیر مستقیم • 256 KBPS OPUS + ساعت آبی + Spotube · نمایش پخش‌کننده + کمی موسیقی برای لحظه‌های میان‌راه. + ۱. کشف غیرمتمرکز + جستجوها مستقیماً به افزونهٔ کاتالوگ انتخابی شما می‌روند. Spotube هرگز از سلیقهٔ شما نمایه نمی‌سازد. + ۲. دریافت مستقیم جریان + صدا مستقیماً از سرورهای محتوا به دستگاه شما می‌رسد. بدون پراکسی میانی یا جمع‌آوری داده از راه دور. + ۳. پردازش صدا و پخش محلی + رمزگشایی و ذخیرهٔ محلی، همراه با اکولایزر و دانلود آفلاین اختیاری. + ۴. متن همگام و جم + متن کارائوکه با زمان پخش همگام می‌شود، همراه با کنترل از راه دور در LAN و جم گروهی. + بدون سرور واسطه. پخش درست روی دستگاه شما انجام می‌شود — خصوصی از پایه. + + + یک دستگاه انتخاب کنید + %1$s: «%2$s» + پخش + افزودن به صف + پخش بعدی + همین دستگاه + روی همین دستگاه + دستگاه راه دور + دستگاه راه دور (%1$s) + روی دستگاه متصل + + + اتصال ناموفق بود + اتصال قطع شد + ارسال ناموفق بود + یافتن دستگاه‌ها ناموفق بود: %1$s + اتصال ناموفق بود: %1$s + + + دستگاه‌ها + به‌روزرسانی + قطع اتصال + در حال اتصال... + در حال جستجوی دستگاه‌ها در شبکه... + هیچ دستگاهی پیدا نشد + مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است. + دستگاه ناشناس + در حال شناسایی... + + + کنترل از راه دور + قطع شد + خطای اتصال: %1$s + آهنگ ناشناس + هنرمند ناشناس + صدا + حذف از صف + گزینه‌های بیشتر + + + کجا پخش شود؟ + یک جلسهٔ جم فعال دارید. انتخاب کنید این مجموعه کجا پخش شود. + پخش در اینجا + پیشنهاد به جم + + + اتصال ناموفق بود + اتصال قطع شد + + + + به صف جم اضافه شد + + %1$d آهنگ به صف جم اضافه شد + %1$d آهنگ به صف جم اضافه شد + + + + درخواست کنترل از راه دور + \"%1$s\" می‌خواهد پخش را روی این دستگاه کنترل کند. + رد کردن + اجازه دادن + همیشه اجازه بده + + + نتایج جستجو + همه + آهنگ‌ها + کاربران + هیچ نوع جستجویی در دسترس نیست + برای جستجو شروع به تایپ کنید + برای جستجوی آهنگ‌ها شروع به تایپ کنید + نتیجه‌ای پیدا نشد + مشاهدهٔ همه + در حال جستجو + در حال بارگیری آهنگ‌ها... + در حال بارگیری آلبوم‌ها... + در حال بارگیری هنرمندان... + در حال بارگیری پلی‌لیست‌ها... + در حال بارگیری کاربران... + هیچ آلبومی پیدا نشد + هیچ هنرمندی پیدا نشد + هیچ پلی‌لیستی پیدا نشد + هیچ کاربری پیدا نشد + در حال گشتن میان صفحه‌ها... + در حال جستجوی کیهان... + در حال گرم کردن بلندگوها... + در حال گردگیری صفحه‌های گرامافون... + در حال مشورت با خدایان موسیقی... + در حال تنظیم آنتن‌ها... + در حال ورق زدن آلبوم‌ها... + بارگیری قابلیت‌های جستجو ناموفق بود + جستجو ناموفق بود + جستجوی آهنگ‌ها ناموفق بود + جستجوی آلبوم‌ها ناموفق بود + جستجوی هنرمندان ناموفق بود + جستجوی پلی‌لیست‌ها ناموفق بود + جستجوی کاربران ناموفق بود + + + مرور + + + همگام + ساده + متن همگام‌شده‌ای در دسترس نیست + متن ساده‌ای در دسترس نیست + + + %1$s در %2$s + %1$d kbps + %1$s kHz • %2$d کانال + هیچ افزونهٔ فراداده‌ای انتخاب نشده است + + + بازگشت + تلاش دوباره + بستن پنجره + در حال بارگیری... + ناشناس + خطای ناشناخته + + + کاربر + پلی‌لیست + + + واقعاً متأسفیم + در حال حاضر چیزی درست کار نمی‌کند. تجربهٔ شما برای ما مهم است و تمام تلاشمان را برای رفع آن می‌کنیم. لطفاً دوباره تلاش کنید یا جزئیات خطا را در پایین بررسی کنید. + جزئیات خطا + کپی در کلیپ‌بورد + مشاهدهٔ گزارش کامل + گزارش کامل خطا + + + پخش تصادفی + پخش تصادفی + دنبال کردن + لغو دنبال کردن + ویرایش پلی‌لیست + از %1$s + + + گزینه‌های آهنگ + افزودن به جم + شروع رادیو + پخش بعدی + انتقال به بعدی + حذف از صف + حذف از علاقه‌مندی‌ها + افزودن به علاقه‌مندی‌ها + افزودن به پلی‌لیست + دانلود + اشتراک‌گذاری + + + اصلی + عنوان + هنرمند + آلبوم + مدت + مرتب‌سازی + هیچ آهنگی پیدا نشد + فیلتر + کارهای گروهی + دانلود همه + دانلود %1$d + افزودن همه به صف + افزودن %1$d به صف + پخش همه در ادامه + پخش %1$d در ادامه + افزودن همه به پلی‌لیست + افزودن %1$d به پلی‌لیست + افزودن همه به جم + افزودن %1$d به جم + +%1$d دیگر + + + URL برای بارگیری وجود ندارد + بازگشت در تاریخچهٔ مرورگر + رفتن به جلو در تاریخچهٔ مرورگر + + + فیلتر فهرست سیاه... + هیچ موردی در فهرست سیاه نیست + هیچ مورد فهرست سیاهی با «%1$s» مطابقت ندارد + آهنگ‌ها + آهنگ + افزودن به فهرست سیاه + حذف از فهرست سیاه + + + فرادادهٔ آهنگ موجود نیست + URL جریان پیدا نشد + URL جریان پیدا نشد. ابتدا آهنگ را پخش کنید. + + + آهنگ‌های ذخیره‌شده + شما + + + دنبال می‌کنید + آهنگ‌های برتر + پخش آهنگ‌های برتر + افزودن آهنگ‌های برتر به صف + هنرمندان مرتبط + پلی‌لیست‌های ویژه + بارگیری نمای کلی هنرمند ناموفق بود + تعداد دنبال‌کنندگان در دسترس نیست + %1$sK دنبال‌کننده + %1$sM دنبال‌کننده + %1$sB دنبال‌کننده + + %1$d دنبال‌کننده + %1$d دنبال‌کننده + + + + پسندیدن + لغو پسند + عنوان آهنگ خالی + هنرمندان خالی + آلبوم ناشناس + جابه‌جایی + صدا + فعال کردن پخش تصادفی + غیرفعال کردن پخش تصادفی + قبلی + بعدی + پخش یا توقف + تکرار خاموش + تکرار یک آهنگ + تکرار همه + بی‌صدا + باصدا + گزینه‌های بیشتر + گزینه‌های پخش‌کننده + جمع کردن پخش‌کننده + باز کردن متن آهنگ + در حال پخش + متن آهنگ + منبع جایگزین + منبع جایگزین + دانلود + دانلود شد + در حال دانلود... + قبلاً دانلود شده است + تلاش دوباره برای دانلود... + در حال دانلود %1$s + پخش تا %1$s دیگر متوقف می‌شود. + پخش پس از تعداد دقیقهٔ انتخاب‌شده متوقف شود. + دقیقه + عددی بین 1 تا 600 دقیقه انتخاب کنید + لغو زمان‌سنج + شروع + URL پخش با تنظیمات پخش آنلاین متفاوتی به دست آمده است. جریان برجسته‌شده با ترجیح فعلی شما مطابقت دارد؛ URL فعال در درخواست بعدی جریان به‌روز می‌شود. + منبع صوتی منطبق + در حال بارگیری منبع منطبق… + هنگام پخش فایل ذخیره‌شدهٔ محلی، به منبع راه دور منطبق نیازی نیست. + هیچ منبع منطبقی در دسترس نیست. + هیچ منبع صوتی منطبقی برای این آهنگ در حافظهٔ پنهان نیست. + جزئیات جریان هنوز در حافظهٔ پنهان نیست. برای بارگیری آن‌ها ابتدا آهنگ را پخش کنید. + بارگیری جزئیات آهنگ ناموفق بود. + جریان‌های در دسترس + این آهنگ از حافظهٔ پنهان محلی پخش می‌شود. نسخه‌های جریان برای این پخش استفاده نمی‌شوند. + در حال بارگیری جریان‌ها… + هیچ جریانی برای این منبع در دسترس نیست. + جریان انتخاب‌شدهٔ فعلی + مطابق با قالب و کیفیت پخش آنلاین ترجیحی شما + URL پخش فعال + پخش از حافظهٔ پنهان محلی + آهنگ فراداده + عنوان + شناسه + آلبوم + اطمینان + URI خارجی + شناسهٔ آلبوم + نوع آلبوم + تاریخ انتشار آلبوم + تعداد آهنگ‌های آلبوم + ژانرهای آلبوم + مدت + شمارهٔ آهنگ + شمارهٔ دیسک + صریح + محبوبیت + ISRC + URLهای تصویر بندانگشتی + + + نمایش/پنهان کردن نوار کناری + + + منابع جایگزین + هیچ آهنگی در حال پخش نیست + در حال جستجوی منابع جایگزین... + هیچ منبع جایگزینی پیدا نشد + اطمینان: %1$d% + منبع فعال + + + بیرون کردن + او را از جلسه حذف کنید + بیرون کردن و جلوگیری از پیوستن دوباره + حذف پیشنهادها + حذف همهٔ آهنگ‌هایی که او به صف اضافه کرده است + تغییر ترتیب + حذف از صف + %1$s • افزوده‌شده توسط %2$s + + + ناشناس + در حال بارگیری پلی‌لیست... + افزودن آهنگ + افزودن آهنگ به پلی‌لیست + جستجوی آهنگ‌ها... + جستجو + هیچ آهنگی پیدا نشد + آهنگ‌هایی را برای افزودن جستجو کنید + تمام + افزودن + اضافه شد + + + در حال بارگیری آلبوم... + + + افزودن به پلی‌لیست شما + جستجو + جستجو در پلی‌لیست‌های شما... + در حال بارگیری پلی‌لیست‌های شما... + هنوز پلی‌لیستی ندارید.\nبرای افزودن آهنگ، یکی بسازید. + ساخت پلی‌لیست + ویرایش پلی‌لیست + ساختن + نام + نام پلی‌لیست + توضیحات + توضیحات (اختیاری) + عمومی + همه می‌توانند این پلی‌لیست را ببینند + مشارکتی + دیگران می‌توانند به این پلی‌لیست آهنگ اضافه کنند + تصویر کاور + کاور پلی‌لیست + بدون تصویر + تغییر تصویر + انتخاب تصویر + + + نصب + جایگزینی + plugin.json در فایل zip پیدا نشد + manifest.zipline.json در فایل zip پیدا نشد + قالب plugin.json نامعتبر است: %1$s + خواندن افزونه ناموفق بود: %1$s + نصب افزونه ناموفق بود: %1$s + نسخهٔ جدیدتری از %1$s در دسترس است. پیش از نصب، به‌روزرسانی را تأیید کنید. + %1$s از قبل نصب شده است. پیش از نصب، جایگزینی آن را تأیید کنید. + افزونه‌های داخلی از قبل وجود دارند و نمی‌توان دوباره اضافه‌شان کرد. + افزونه‌های داخلی را نمی‌توان حذف کرد. + %1$s یک apiVersion نامعتبر (%2$s) اعلام کرده است. نسخه‌بندی معنایی سازگار با %3$s انتظار می‌رود. + نسخهٔ API افزونهٔ Spotube (%1$s) نامعتبر است. + %1$s برای API افزونهٔ %2$s ساخته شده، اما Spotube از نسخهٔ اصلی API افزونهٔ %3$s پشتیبانی می‌کند. افزونه‌ای نصب کنید که برای همان نسخهٔ اصلی API ساخته شده باشد. + + + API افزونه سازگار نیست + افزونه نصب شود؟ + %1$s به افزونه‌های نصب‌شدهٔ شما اضافه می‌شود. + افزونه به‌روز شود؟ + نسخهٔ قدیمی‌تری نصب است (%1$s). به %2$s به‌روز شود؟ + افزونهٔ نصب‌شده جایگزین شود؟ + %1$s از یک نسخهٔ معنایی نامعتبر (%2$s) استفاده می‌کند و نمی‌توان آن را به‌عنوان به‌روزرسانی مقایسه کرد. با این حال افزونهٔ نصب‌شده جایگزین شود؟ + نسخهٔ نصب‌شده (%1$s) را نمی‌توان با نسخه‌بندی معنایی مقایسه کرد. با %2$s جایگزین شود؟ + نسخهٔ %1$s از قبل نصب است. افزونهٔ موجود با نسخهٔ ارائه‌شده جایگزین شود؟ + نسخهٔ نصب‌شدهٔ %1$s از نسخهٔ ارائه‌شدهٔ %2$s جدیدتر است. با این حال جایگزین شود؟ + افزونهٔ نصب‌شده با نسخهٔ ارائه‌شده جایگزین شود؟ + + + %1$s: + نسخه + نسخهٔ API + سازنده + مجوز + قابلیت‌ها + توانایی‌ها + مخزن + تماس + گزارش اشکال + حمایت از %1$s + + + رسمی + تأییدشده + ستاره‌های GitHub + URL مخزن GitHub + + + هیچ نسخه‌ای پیدا نشد + انتخاب نسخه + یک نسخه انتخاب کنید + پیش‌انتشار + یادداشت‌های انتشار + + + خانه + جستجو + روی دستگاه + اتصال + دستگاه‌ها + + + پنهان کردن پنجره + نمایش پنجره + آهنگ بعدی + آهنگ قبلی + پخش تصادفی + تکرار + تکرار: خاموش + تکرار: یک آهنگ + تکرار: همه + صدا: %1$d% + صدا + + صدا - + بی‌صدا + باصدا + پسندیدن آهنگ + لغو پسند آهنگ + خروج + + + کوچک کردن + بزرگ کردن + بازگرداندن + بستن + + + پخش + کنترل‌های پخش رسانه + + + مرور + در حال پخش + ویژه + آهنگ‌های ذخیره‌شده + آهنگ‌های برتر + diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml new file mode 100644 index 00000000..a96a3b83 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -0,0 +1,932 @@ + + + + File d\'attente + Vider la file d\'attente + Filtrer la file d\'attente... + La file d\'attente est vide + + Rechercher des titres, artistes, albums... + Effacer tout l\'historique + Rechercher... + + + Bibliothèque + Bibliothèque + Playlists + Albums + Artistes + Titres locaux + Téléchargements + Rechercher dans les playlists enregistrées… + Rechercher dans les albums enregistrés… + Rechercher dans les artistes enregistrés… + Rechercher des dossiers ou des titres… + Filtrer la bibliothèque + Fermer le filtre + Créer une playlist + Actualiser les titres locaux + Afficher en grille + Afficher en liste + Aucune playlist enregistrée + Aucune playlist ne correspond à « %1$s » + Aucun album enregistré + Aucun album ne correspond à « %1$s » + Aucun artiste enregistré + Aucun artiste ne correspond à « %1$s » + + + Dossiers + Dossier + %1$s • %2$s + + %1$d titre + %1$d titres + %1$d titres + + + %1$d dossier + %1$d dossiers + %1$d dossiers + + Retour aux dossiers + Lire le dossier + Accès à l\'audio requis + Autorisez l\'accès à l\'audio pour parcourir la musique de votre appareil. + Autoriser l\'accès + Aucun dossier local trouvé + Aucun titre dans ce dossier + Aucun dossier ou titre correspondant + Échec de l\'actualisation des médias locaux + + + %1$d en cours • %2$d terminé(s) + Effacer les terminés + Aucun téléchargement pour l\'instant + Téléchargement %1$d% + En attente + Terminé + Annulé + Échec + Échec : %1$s + Annuler le téléchargement + Relancer le téléchargement + Supprimer le téléchargement + + + Artiste + Sur liste noire + Titres likés + Vos titres enregistrés + Artiste inconnu + Titre du morceau + Nom de l\'artiste + + Sélectionné + + Paramètres + + Plugins + Langue et région + Apparence + Lecture + Téléchargements + Cache + Bureau + Mises à jour + Soutien + À propos + + Fermer + Enregistrer + Annuler + + Langue + Langue de l\'interface. Actuelle : %1$s + Langue du système + Pays + Pays utilisé pour le contenu et la disponibilité. Actuel : %1$s + %1$s (%2$s) + + Thème + Choisissez comment Spotube s\'adapte à l\'apparence du système. Actuel : %1$s + Clair + Sombre + Système + + Surface + Matériau d\'arrière-plan de l\'application. Actuel : %1$s + Aqueux + Givré + Chrome + Acrylique + Verre + + Couleur d\'accentuation + Palette actuelle : %1$s + Couleur d\'accentuation + Choisissez une palette d\'accentuation et prévisualisez-la en thème clair et sombre. + Clair + Sombre + Aperçu + Bouton + Bouffon vert + Violet électrique + Cyan océan + Orange couchant + Roseraie + Bleu nuit + Ardoise métallique + + Style et rendu + Matériau de surface et accentuation. Actuel : %1$s · %2$s + Style et rendu + Choisissez un matériau de surface, puis une couleur d\'accentuation assortie. Chaque option est une combinaison soigneusement choisie. + Surface + Accentuation + Secondaire + + Palette + Palette d\'accentuation Millennium. Actuelle : %1$s + Palette + Choisissez une palette d\'accentuation Millennium et prévisualisez-la en clair et en sombre. + Palette + Palette Millennium et accentuation. Actuelle : %1$s + Bleu + Orange + Citron vert + + Dossier de téléchargement personnalisé + Dossier actuel : %1$s + Laissez vide pour utiliser le dossier Musique par défaut. + C:\Music\Spotube + Dossiers de médias locaux + Dossiers personnalisés à analyser sur ordinateur. Ajoutés : %1$d + Aucun dossier personnalisé ajouté + Dossiers personnalisés pour votre bibliothèque musicale locale. + Gérer + Ajouter un dossier + Supprimer le dossier + Aucun dossier personnalisé ajouté. + Retirer %1$s de la liste d\'analyse + Les dossiers de téléchargement et de cache musical sont inclus automatiquement. + Votre dossier de téléchargement est inclus automatiquement. + Le dossier de cache musical est inclus automatiquement. + Enregistrement… + Impossible d\'enregistrer les dossiers. Vos modifications sont conservées ; réessayez. + + %1$d dossier + %1$d dossiers + %1$d dossiers + + Format de téléchargement + Codec et conteneur préférés pour les téléchargements. Actuel : %1$s + Qualité de téléchargement + + Format de streaming + Codec et conteneur préférés pour le streaming. Actuel : %1$s + Qualité de streaming + Actuel : %1$s + Activer le cache musical + Stocker l\'audio diffusé en local pour accélérer les écoutes suivantes. + Dossier du cache + Actuel : %1$s + Actuel : cache par défaut de l\'application + Taille maximale du cache + Aucune limite + Limite actuelle : %1$d MB + Définissez la taille maximale du cache en MB. Saisissez 0 ou laissez vide pour aucune limite. + 500 + La taille doit être 0 ou un nombre positif. + Activer la lecture continue + Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente. + Port du serveur proxy de lecture + Autoriser le contrôle à distance + Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent. + Nom de l\'appareil pour le contrôle à distance + Le nom que voient les autres appareils lorsqu\'ils détectent celui-ci. Actuel : %1$s + Choisissez un nom convivial à afficher aux autres appareils de votre réseau lorsqu\'ils détectent cette instance de Spotube. + p. ex. Spotube du salon + nom d\'hôte de l\'appareil + Port utilisé par le serveur proxy de lecture. Actuel : %1$d + Choisissez un port entre 1 et 65535. + 14769 + Saisissez un nombre entier valide. + Le port doit être compris entre 1 et 65535. + + Liste noire + Gérer les titres et artistes sur liste noire + + Réduire dans la zone de notification + Garder Spotube actif dans la zone de notification lorsque la fenêtre est réduite. + Statut enrichi Discord + Afficher votre lecture en cours dans Discord sur ordinateur. + + Rechercher automatiquement les mises à jour + Rechercher les nouvelles versions de Spotube en arrière-plan. + + Gérer les plugins + Gérez vos plugins + Plugin %1$s par défaut + Métadonnées + Audio + Paroles + Scrobbling + Aucun plugin sélectionné + Modifier + Sélectionner + Sélectionné + Effacer la sélection + Aucun plugin disponible + Plugin %1$s + Effacer + + Configurer + Gestionnaire de plugins + Veuillez saisir une URL + L\'URL doit commencer par http:// ou https:// + Échec du téléchargement du plugin + Aucun plugin installé pour l\'instant + Collez une URL de téléchargement ou choisissez un fichier ci-dessus. + + %1$d plugin installé + %1$d de plugins installés + %1$d plugins installés + + Installer un plugin + https://example.com/plugin.smplug + Télécharger + Installer depuis un fichier (.smplug) + Actif + v%1$s + Intégré + Supprimer le plugin + Se connecter + Se déconnecter + Télécharger depuis une URL + Installer depuis un fichier + Installer + + • %1$s + Autorisations demandées + Ce plugin ne nécessite aucune autorisation particulière. + Installé ou fourni + Installé + Fourni + API %1$s → %2$s + Stockage persistant + Lire et écrire des données sur le disque + Requêtes réseau + Envoyer et recevoir des données sur Internet + WebView + Afficher du contenu web dans l\'application + Jam de groupe + Tester la connexion + Test en cours… + Connecté en %1$d ms + Échec : %1$s + Connexion + Diagnostic + + + Jam de groupe + Créer + Rejoindre + Créez une salle, puis partagez son code avec vos amis. + Créer une salle + Rejoindre la salle + Connexion… + Code de la salle + ABC123 + Connecté + Reconnexion… + Copier le code + Copié ! + Partager + Participants (%1$d) + Hôte + Auditeur + En attente d\'auditeurs… + Retirer + Bannir + Gérer + Quitter la salle + Connexion impossible + Ignorer + Échec de la création de la session Jam + Impossible de rejoindre la session Jam + Saisissez un code de salle de six caractères (lettres et chiffres). + Rejoignez ma Jam Spotube avec le code : %1$s + Serveur de salles + URL du serveur + Saisissez une URL commençant par https:// + Vérifiez le serveur de salles avant de lancer une salle. + Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez le serveur depuis github.com/team-spotube/jam-server et saisissez son URL. + Vous + Serveur : %1$s + Définissez un serveur de salles dans les Paramètres pour créer ou rejoindre une salle. + Paramètres du serveur + %1$s (vous) + Votre nom + Les autres participants vous voient comme %1$s + Visible par tous les participants de la jam. Laissez vide pour utiliser %1$s. Un nouveau nom s’applique la prochaine fois que vous créez ou rejoignez une salle. + Utilisez au maximum %1$d caractères. + Aucun serveur de jam configuré + Impossible de joindre le serveur de jam. Vérifiez votre réseau ou l’URL du serveur dans Paramètres → Jam de groupe. + Le serveur de jam utilise une version incompatible. + Aucune salle avec ce code. Vérifiez-le et réessayez. + Cette salle est pleine. + L’hôte vous a banni de cette salle. + L’hôte vous a retiré de la salle. + Cette salle a été rouverte sur cet appareil, cette connexion a donc été fermée. + + Accéder à l\'album + Minuteur de veille + Détails du titre + Lecture + Pause + Aucune lecture en cours + Ajouter à la file d\'attente + + Ignorer + Mettre à jour + Spotube %1$s est disponible + Découvrez les nouveautés de cette version sur la page de téléchargement de Spotube. + %1$s %2$s est disponible + Version installée : %1$s + Découvrez les nouveautés de cette mise à jour sur la page du plugin. + + Passer + Suivant + Retour + Commencer + Spotube + La musique, sans contraintes. + Un lecteur open source pour tout un monde de musique.\nÀ vous de l\'explorer. À vous de vous l\'approprier. + Faites vivre ce projet. + Spotube est créé par des personnes, pas par une entreprise d\'abonnements. S\'il fait partie de votre quotidien, un petit soutien nous aide à lui accorder l\'attention qu\'il mérite.\n\nMerci de faire une place aux logiciels indépendants. + Commencez par un plugin de catalogue pour trouver des titres et des playlists. Choisissez un plugin audio pour les écouter. Les paroles et l\'historique d\'écoute sont facultatifs. Découvrez un plugin ci-dessous, vérifiez ses autorisations, installez-le, puis sélectionnez-le ici. + Découvrir des plugins + Vos sources + Réessayer + Plus de plugins + Aucun nouveau plugin trouvé. Vous pouvez aussi en installer un depuis un lien ou un fichier .smplug ci-dessus. + La découverte de plugins est indisponible pour le moment. Réessayez, ou installez-en un depuis un lien ou un fichier. + Bienvenue + Soutien + Plugins + Guide + + Comment utiliser Spotube + Quatre étapes rapides et c\'est parti. + Se connecter + Connectez votre compte ou choisissez votre propre plugin de catalogue. + Rechercher et écouter + Trouvez n\'importe quel titre, album ou artiste, puis lancez la lecture. + Écouter hors connexion + Téléchargez des titres pour continuer à écouter sans connexion. + Suivre les paroles + Ouvrez les paroles synchronisées depuis le lecteur pendant la lecture d\'un titre. + + + Votre musique. Vos sources. Vos règles. + Un lecteur de musique gratuit et open source, créé par une communauté qui aime tout simplement la musique. + Open source + Sans publicité + Sans télémétrie + Basé sur des plugins + C\'est parti + + + Nos soutiens nous font avancer + Créé avec passion. Maintenu en vie grâce à vous. + Spotube est 100 % gratuit, libre et open source — sans publicité, sans abonnement, sans pistage. Chaque fonctionnalité existe parce que des personnes comme vous ont choisi d\'aider à faire avancer le code. + Patreon + Soutien mensuel + Soutenez le développement et l\'infrastructure dans la durée + Buy Me a Coffee + Un café ponctuel + Alimentez une session de code nocturne avec un petit pourboire + Open Collective + Fonds communautaire + Un financement transparent, géré par la communauté + Merci du fond du cœur de croire en la musique libre. + Le soutien est entièrement facultatif — Spotube reste gratuit dans tous les cas. + Soutenir + Ouvrir %1$s + + + Soutenir Spotube + Soutenir Spotube + Spotube est gratuit, libre et open source — sans publicité, sans abonnement, sans pistage. S\'il vous apporte de la joie, pensez à soutenir le projet. + Vous pouvez désactiver ces rappels dans Paramètres → Soutien. + Plus tard + Rappels de soutien + Afficher un petit rappel pour soutenir le projet tous les 7 jours. + + + Architecture modulaire + Choisissez d\'où vient votre musique. + Spotube sépare la découverte du catalogue, le streaming audio et les paroles synchronisées. Configurez vos moteurs ci-dessous ou changez-les à tout moment. + Moteur de métadonnées + Gère la découverte musicale, la recherche, les listes de titres et les playlists. + Moteur audio + Récupère et décode les flux audio lus. + Moteur de paroles + Diffuse des paroles karaoké synchronisées en temps réel. + Moteur de scrobbling + Enregistre l\'historique d\'écoute sur Last.fm ou ListenBrainz. + Actif + Intégré + Prêt + Facultatif + Sélectionnez un moteur + Moteurs installés + Définir par défaut + NewPipe YouTube et LRCLib Lyrics sont intégrés et prêts à l\'emploi. + Gérez ou ajoutez des plugins de la communauté à tout moment dans Paramètres → Plugins. + + + Architecture rétro-moderne + Hi-Fi pure, zéro intermédiaire + Voici comment Spotube orchestre une lecture décentralisée directement sur votre machine. + PAIR-À-PAIR DIRECT • OPUS 256 KBPS + L\'heure bleue + Spotube · démonstration du lecteur + Un peu de musique pour les moments entre deux. + 1. Découverte décentralisée + Les requêtes vont directement au plugin de catalogue choisi. Spotube ne profile jamais vos goûts. + 2. Résolution directe des flux + L\'audio est diffusé directement des serveurs de contenu vers votre machine. Aucun proxy ni collecte de données à distance. + 3. DSP et lecture en local + Décodage et mise en cache en local, avec égaliseur et téléchargement hors connexion facultatif. + 4. Paroles synchronisées et Jam + Les paroles karaoké suivent la position de lecture, avec contrôle à distance en LAN et Jam de groupe. + Aucun serveur intermédiaire. La lecture se fait directement sur votre appareil — confidentielle par conception. + + + Choisir un appareil + %1$s : « %2$s » + Lire + Ajouter à la file d\'attente + Lire ensuite + Cet appareil + Sur cet appareil + Appareil distant + Appareil distant (%1$s) + Sur l\'appareil connecté + + + Échec de la connexion + Connexion perdue + Échec de l\'envoi + Échec de la détection : %1$s + Échec de la connexion : %1$s + + + Appareils + Actualiser + Se déconnecter + Connexion... + Recherche d\'appareils sur le réseau... + Aucun appareil trouvé + Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil. + Appareil inconnu + Résolution... + + + Contrôle à distance + Déconnecté + Erreur de connexion : %1$s + Titre inconnu + Artiste inconnu + Volume + Retirer de la file d\'attente + Plus d\'options + + + Où écouter ? + Une session Jam est en cours. Choisissez où lire cette collection. + Lire ici + Proposer à la Jam + + + Échec de la connexion + Connexion perdue + + + + Ajouté à la file d\'attente de la Jam + + %1$d titre ajouté à la file d\'attente de la Jam + %1$d titres ajoutés à la file d\'attente de la Jam + %1$d titres ajoutés à la file d\'attente de la Jam + + + + Demande de contrôle à distance + \"%1$s\" souhaite contrôler la lecture sur cet appareil. + Refuser + Autoriser + Toujours autoriser + + + Résultats de recherche + Tout + Titres + Utilisateurs + Aucun type de recherche disponible + Commencez à taper pour rechercher + Commencez à taper pour rechercher des titres + Aucun résultat + Tout afficher + Recherche en cours + Chargement des titres... + Chargement des albums... + Chargement des artistes... + Chargement des playlists... + Chargement des utilisateurs... + Aucun album trouvé + Aucun artiste trouvé + Aucune playlist trouvée + Aucun utilisateur trouvé + On fouille dans les bacs... + On explore le cosmos... + On fait chauffer les enceintes... + On dépoussière les vinyles... + On consulte les dieux de la musique... + On règle les antennes... + On feuillette les disques... + Impossible de charger les fonctions de recherche + Échec de la recherche + Échec de la recherche de titres + Échec de la recherche d\'albums + Échec de la recherche d\'artistes + Échec de la recherche de playlists + Échec de la recherche d\'utilisateurs + + + Parcourir + + + Synchronisées + Texte brut + Aucunes paroles synchronisées disponibles + Aucunes paroles disponibles + + + %1$s dans %2$s + %1$d kbps + %1$s kHz • %2$d canaux + Aucun plugin de métadonnées sélectionné + + + Retour + Réessayer + Fermer la boîte de dialogue + Chargement... + Inconnu + Erreur inconnue + + + Utilisateur + Playlist + + + Nous sommes vraiment désolés + Quelque chose ne fonctionne pas pour le moment. Votre expérience compte pour nous et nous faisons de notre mieux pour corriger le problème. Veuillez réessayer ou consulter les détails de l\'erreur ci-dessous. + Détails de l\'erreur + Copier dans le presse-papiers + Voir le journal complet + Journal d\'erreurs complet + + + Lecture aléatoire + Lecture aléatoire + Suivre + Ne plus suivre + Modifier la playlist + Par %1$s + + + Options du titre + Ajouter à la Jam + Lancer la radio + Lire ensuite + Placer en suivant + Retirer de la file d\'attente + Retirer des favoris + Ajouter aux favoris + Ajouter à une playlist + Télécharger + Partager + + + Ordre d\'origine + Titre + Artiste + Album + Durée + Trier + Aucun titre trouvé + Filtrer + Actions groupées + Tout télécharger + Télécharger %1$d + Tout ajouter à la file d\'attente + Ajouter %1$d à la file d\'attente + Tout lire ensuite + Lire %1$d ensuite + Tout ajouter à une playlist + Ajouter %1$d à une playlist + Tout ajouter à la Jam + Ajouter %1$d à la Jam + +%1$d autres + + + Aucune URL à charger + Page précédente + Page suivante + + + Filtrer la liste noire... + Aucun élément sur liste noire + Aucun élément sur liste noire ne correspond à « %1$s » + Titres + Titre + Ajouter à la liste noire + Retirer de la liste noire + + + Aucune métadonnée pour ce titre + Impossible de résoudre l\'URL du flux + Impossible de résoudre l\'URL du flux. Essayez d\'abord de lire le titre. + + + Titres enregistrés + Vous + + + Abonné + Titres populaires + Lire les titres populaires + Ajouter les titres populaires à la file d\'attente + Artistes similaires + Playlists à la une + Impossible de charger la présentation de l\'artiste + Abonnés indisponibles + %1$s k abonnés + %1$s M d\'abonnés + %1$s Md d\'abonnés + + %1$d abonné + %1$d d\'abonnés + %1$d abonnés + + + + J\'aime + Je n\'aime plus + Titre du morceau indisponible + Artistes indisponibles + Album inconnu + Position de lecture + Volume + Activer la lecture aléatoire + Désactiver la lecture aléatoire + Précédent + Suivant + Lecture ou pause + Répétition désactivée + Répéter le titre + Tout répéter + Couper le son + Réactiver le son + Plus d\'options + Options du lecteur + Réduire le lecteur + Agrandir les paroles + En cours de lecture + Paroles + Source alternative + Source alternative + Télécharger + Téléchargé + Téléchargement... + Déjà téléchargé + Nouvelle tentative de téléchargement... + Téléchargement de %1$s + La lecture sera mise en pause dans %1$s. + Mettre la lecture en pause après le nombre de minutes choisi. + Minutes + Entre 1 et 600 minutes + Annuler le minuteur + Démarrer + L\'URL de lecture a été résolue avec d\'autres paramètres de streaming. Le flux mis en évidence correspond à votre préférence actuelle ; l\'URL active sera actualisée à la prochaine demande de flux. + Source audio associée + Chargement de la source associée… + Aucune source distante associée n\'est nécessaire pendant la lecture du fichier en cache local. + Aucune source associée disponible. + Aucune source audio associée n\'est en cache pour ce titre. + Les détails du flux ne sont pas encore en cache. Lisez d\'abord le titre pour les charger. + Impossible de charger les détails du titre. + Flux disponibles + Ce titre est lu depuis le cache local. Les variantes de flux ne sont pas utilisées pour cette lecture. + Chargement des flux… + Aucun flux disponible pour cette source. + Flux actuellement sélectionné + Correspond à vos préférences de format et de qualité de streaming + URL de lecture active + Lu depuis le cache local + Titre (métadonnées) + Titre + ID + Album + Fiabilité + URI externe + ID de l\'album + Type d\'album + Date de sortie de l\'album + Nombre de titres de l\'album + Genres de l\'album + Durée + Numéro de piste + Numéro de disque + Explicite + Popularité + ISRC + URL des miniatures + + + Afficher/masquer la barre latérale + + + Sources alternatives + Aucun titre en cours de lecture + Recherche de sources alternatives... + Aucune source alternative trouvée + Fiabilité : %1$d% + Source active + + + Exclure + Le retirer de la session + L\'exclure et l\'empêcher de revenir + Retirer ses suggestions + Retirer tous les titres qu\'il a ajoutés à la file d\'attente + Réorganiser + Retirer de la file d\'attente + %1$s • Ajouté par %2$s + + + Inconnu + Chargement de la playlist... + Ajouter des titres + Ajouter des titres à la playlist + Rechercher des titres... + Rechercher + Aucun titre trouvé + Recherchez des titres à ajouter + OK + Ajouter + Ajouté + + + Chargement de l\'album... + + + Ajouter à une de vos playlists + Rechercher + Rechercher dans vos playlists... + Chargement de vos playlists... + Vous n\'avez encore aucune playlist.\nCréez-en une pour y ajouter des titres. + Créer une playlist + Modifier la playlist + Créer + Nom + Nom de la playlist + Description + Description (facultatif) + Publique + Tout le monde peut voir cette playlist + Collaborative + D\'autres personnes peuvent ajouter des titres à cette playlist + Image de couverture + Couverture de la playlist + Aucune image + Changer l\'image + Choisir une image + + + Installer + Remplacer + plugin.json introuvable dans le fichier zip + manifest.zipline.json introuvable dans le fichier zip + Format de plugin.json non valide : %1$s + Impossible de lire le plugin : %1$s + Impossible d\'installer le plugin : %1$s + Une version plus récente de %1$s est disponible. Confirmez la mise à jour avant l\'installation. + %1$s est déjà installé. Confirmez le remplacement avant l\'installation. + Les plugins intégrés sont déjà inclus et ne peuvent pas être ajoutés à nouveau. + Les plugins intégrés ne peuvent pas être supprimés. + %1$s déclare une apiVersion non valide (%2$s). Une version sémantique compatible avec %3$s est attendue. + La version %1$s de l\'API de plugins de Spotube n\'est pas valide. + %1$s cible l\'API de plugins %2$s, mais Spotube prend en charge la version majeure %3$s de l\'API de plugins. Installez un plugin conçu pour la même version majeure de l\'API. + + + API de plugin incompatible + Installer le plugin ? + %1$s sera ajouté à vos plugins installés. + Mettre à jour le plugin ? + Une version plus ancienne est installée (%1$s). Mettre à jour vers la version %2$s ? + Remplacer le plugin installé ? + %1$s utilise une version sémantique non valide (%2$s) et ne peut pas être comparé comme une mise à jour. Remplacer quand même le plugin installé ? + La version installée (%1$s) ne peut pas être comparée selon la gestion sémantique de version. La remplacer par %2$s ? + La version %1$s est déjà installée. Remplacer le plugin existant par la copie fournie ? + La version installée %1$s est plus récente que la version fournie %2$s. La remplacer quand même ? + Remplacer le plugin installé par la copie fournie ? + + + %1$s : + Version + Version de l\'API + Auteur + Licence + Capacités + Fonctions + Dépôt + Contact + Signaler un bug + Soutenir %1$s + + + Officiel + Vérifié + Étoiles GitHub + URL du dépôt GitHub + + + Aucune version trouvée + Choisir une version + Sélectionnez une version + Préversion + Notes de version + + + Accueil + Rechercher + Sur l\'appareil + Connect + Appareils + + + Masquer la fenêtre + Afficher la fenêtre + Titre suivant + Titre précédent + Lecture aléatoire + Répétition + Répétition : DÉSACTIVÉE + Répétition : TITRE + Répétition : TOUT + Volume : %1$d% + Volume + + Volume - + Couper le son + Réactiver le son + Aimer le titre + Ne plus aimer le titre + Quitter + + + Réduire + Agrandir + Restaurer + Fermer + + + Lecture + Commandes de lecture multimédia + + + Parcourir + En cours de lecture + À la une + Titres enregistrés + Titres populaires + diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml new file mode 100644 index 00000000..5de37959 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -0,0 +1,925 @@ + + + कतार + कतार साफ़ करें + कतार फ़िल्टर करें... + कतार में कुछ नहीं है + + गाने, कलाकार, एल्बम खोजें... + पूरा इतिहास साफ़ करें + खोजें... + + + आपकी लाइब्रेरी + लाइब्रेरी + प्लेलिस्ट + एल्बम + कलाकार + लोकल ट्रैक + डाउनलोड + सेव की गई प्लेलिस्ट खोजें… + सेव किए गए एल्बम खोजें… + सेव किए गए कलाकार खोजें… + फ़ोल्डर या ट्रैक खोजें… + लाइब्रेरी फ़िल्टर करें + फ़िल्टर बंद करें + प्लेलिस्ट बनाएं + लोकल ट्रैक रीफ़्रेश करें + ग्रिड व्यू पर जाएं + लिस्ट व्यू पर जाएं + कोई सेव की गई प्लेलिस्ट नहीं + “%1$s” से मेल खाती कोई प्लेलिस्ट नहीं + कोई सेव किया गया एल्बम नहीं + “%1$s” से मेल खाता कोई एल्बम नहीं + कोई सेव किया गया कलाकार नहीं + “%1$s” से मेल खाता कोई कलाकार नहीं + + + फ़ोल्डर + फ़ोल्डर + %1$s • %2$s + + %1$d ट्रैक + %1$d ट्रैक + + + %1$d फ़ोल्डर + %1$d फ़ोल्डर + + फ़ोल्डर पर वापस जाएं + फ़ोल्डर चलाएं + ऑडियो एक्सेस ज़रूरी है + अपने डिवाइस का संगीत देखने के लिए ऑडियो एक्सेस की अनुमति दें। + एक्सेस दें + कोई लोकल फ़ोल्डर नहीं मिला + इस फ़ोल्डर में कोई ट्रैक नहीं है + कोई मेल खाता फ़ोल्डर या ट्रैक नहीं + लोकल मीडिया रीफ़्रेश नहीं हो सका + + + %1$d जारी • %2$d पूरे + पूरे हुए हटाएं + अभी कोई डाउनलोड नहीं + डाउनलोड हो रहा है %1$d% + कतार में + पूरा हुआ + रद्द किया गया + विफल + विफल: %1$s + डाउनलोड रद्द करें + फिर से डाउनलोड करें + डाउनलोड हटाएं + + + कलाकार + ब्लैकलिस्ट में + पसंद किए गए ट्रैक + आपके सेव किए गए गाने + अज्ञात कलाकार + ट्रैक का नाम + कलाकार का नाम + + चुना गया + + सेटिंग + + प्लगइन + भाषा और क्षेत्र + दिखावट + प्लेबैक + डाउनलोड + कैशिंग + डेस्कटॉप + अपडेट + सहयोग + जानकारी + + बंद करें + सेव करें + रद्द करें + + भाषा + ऐप इंटरफ़ेस की भाषा। मौजूदा: %1$s + सिस्टम डिफ़ॉल्ट + देश + कंटेंट और उपलब्धता के लिए क्षेत्रीय देश। मौजूदा: %1$s + %1$s (%2$s) + + थीम + चुनें कि Spotube आपके सिस्टम की दिखावट से कैसे मेल खाए। मौजूदा: %1$s + लाइट + डार्क + सिस्टम + + सरफ़ेस + ऐप का बैकग्राउंड मटीरियल। मौजूदा: %1$s + जलीय + बर्फ़ीला + क्रोम + एक्रिलिक + ग्लास + + एक्सेंट रंग + मौजूदा पैलेट: %1$s + एक्सेंट रंग + एक्सेंट पैलेट चुनें और उसे लाइट और डार्क दोनों थीम में देखें। + लाइट + डार्क + प्रीव्यू + बटन + ग्रीन गॉब्लिन + इलेक्ट्रिक वायलेट + ओशनिक सियान + सनसेट ऑरेंज + रोज़ गार्डन + मिडनाइट ब्लू + मेटैलिक स्लेट + + लुक और फ़ील + सरफ़ेस मटीरियल और एक्सेंट। मौजूदा: %1$s · %2$s + लुक और फ़ील + एक सरफ़ेस मटीरियल चुनें, फिर उसके साथ जंचने वाला एक्सेंट। हर विकल्प एक चुना हुआ कॉम्बिनेशन है। + सरफ़ेस + एक्सेंट + सेकेंडरी + + पैलेट + Millennium एक्सेंट पैलेट। मौजूदा: %1$s + पैलेट + Millennium एक्सेंट पैलेट चुनें और उसे लाइट और डार्क में देखें। + पैलेट + Millennium पैलेट और एक्सेंट। मौजूदा: %1$s + नीला + नारंगी + लाइम + + डाउनलोड फ़ोल्डर बदलें + मौजूदा फ़ोल्डर: %1$s + डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें। + C:\Music\Spotube + लोकल मीडिया फ़ोल्डर + डेस्कटॉप पर स्कैन करने के लिए कस्टम फ़ोल्डर। जोड़े गए: %1$d + अभी तक कोई कस्टम फ़ोल्डर नहीं जोड़ा गया + आपकी लोकल म्यूज़िक लाइब्रेरी के लिए कस्टम फ़ोल्डर। + मैनेज करें + फ़ोल्डर जोड़ें + फ़ोल्डर हटाएं + कोई कस्टम फ़ोल्डर नहीं जोड़ा गया। + %1$s को स्कैन सूची से हटाएं + डाउनलोड और म्यूज़िक कैश फ़ोल्डर अपने-आप शामिल होते हैं। + आपका डाउनलोड फ़ोल्डर अपने-आप शामिल होता है। + म्यूज़िक कैश फ़ोल्डर अपने-आप शामिल होता है। + सेव हो रहा है… + फ़ोल्डर सेव नहीं हो सके। आपके बदलाव अभी भी यहीं हैं; फिर से कोशिश करें। + + %1$d फ़ोल्डर + %1$d फ़ोल्डर + + डाउनलोड म्यूज़िक फ़ॉर्मैट + डाउनलोड के लिए पसंदीदा कोडेक और कंटेनर। मौजूदा: %1$s + डाउनलोड म्यूज़िक क्वालिटी + + स्ट्रीमिंग म्यूज़िक फ़ॉर्मैट + म्यूज़िक स्ट्रीमिंग के लिए पसंदीदा कोडेक और कंटेनर। मौजूदा: %1$s + स्ट्रीमिंग म्यूज़िक क्वालिटी + मौजूदा: %1$s + म्यूज़िक कैशिंग चालू करें + दोबारा तेज़ी से चलाने के लिए स्ट्रीम किया गया ऑडियो डिवाइस पर सेव करें। + कैश फ़ोल्डर + मौजूदा: %1$s + मौजूदा: डिफ़ॉल्ट ऐप्लिकेशन कैश + कैश साइज़ सीमा + कोई सीमा नहीं + मौजूदा सीमा: %1$d MB + MB में अधिकतम कैश साइज़ सेट करें। कोई सीमा न रखने के लिए 0 डालें या खाली छोड़ें। + 500 + साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए। + लगातार प्लेबैक चालू करें + कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें। + प्लेबैक प्रॉक्सी सर्वर पोर्ट + रिमोट कंट्रोल की अनुमति दें + एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें। + रिमोट कंट्रोल डिवाइस का नाम + यह नाम दूसरे डिवाइस को इस डिवाइस को खोजते समय दिखता है। मौजूदा: %1$s + एक आसान नाम चुनें, जो आपके नेटवर्क के दूसरे डिवाइस को यह Spotube खोजते समय दिखे। + जैसे लिविंग रूम Spotube + डिवाइस होस्टनेम + प्लेबैक प्रॉक्सी सर्वर का पोर्ट। मौजूदा: %1$d + 1 से 65535 के बीच कोई पोर्ट चुनें। + 14769 + एक मान्य पूर्ण संख्या डालें। + पोर्ट 1 से 65535 के बीच होना चाहिए। + + ब्लैकलिस्ट + ब्लैकलिस्ट किए गए ट्रैक और कलाकार मैनेज करें + + ट्रे में छोटा करें + विंडो छोटी करने पर Spotube को सिस्टम ट्रे में चलता रहने दें। + Discord रिच प्रेज़ेंस + डेस्कटॉप पर Discord में आपका मौजूदा प्लेबैक स्टेटस दिखाएं। + + अपडेट अपने-आप जांचें + बैकग्राउंड में Spotube के नए रिलीज़ देखें। + + प्लगइन मैनेज करें + अपने प्लगइन मैनेज करें + डिफ़ॉल्ट %1$s प्लगइन + मेटाडेटा + ऑडियो + लिरिक्स + स्क्रॉबल + कोई प्लगइन नहीं चुना गया + बदलें + चुनें + चुना गया + चुनाव हटाएं + कोई प्लगइन उपलब्ध नहीं + %1$s प्लगइन + हटाएं + + कॉन्फ़िगर करें + प्लगइन मैनेजर + कृपया URL डालें + URL http:// या https:// से शुरू होना चाहिए + प्लगइन डाउनलोड नहीं हो सका + अभी तक कोई प्लगइन इंस्टॉल नहीं है + डाउनलोड URL पेस्ट करें या ऊपर से कोई फ़ाइल चुनें। + + %1$d प्लगइन इंस्टॉल है + %1$d प्लगइन इंस्टॉल हैं + + प्लगइन इंस्टॉल करें + https://example.com/plugin.smplug + डाउनलोड करें + फ़ाइल से इंस्टॉल करें (.smplug) + सक्रिय + v%1$s + बिल्ट-इन + प्लगइन हटाएं + लॉग इन करें + लॉग आउट करें + URL से डाउनलोड करें + फ़ाइल से इंस्टॉल करें + इंस्टॉल करें + + • %1$s + मांगी गई अनुमतियां + इस प्लगइन को किसी खास अनुमति की ज़रूरत नहीं है। + इंस्टॉल बनाम दिया गया + इंस्टॉल किया गया + दिया गया + API %1$s → %2$s + स्थायी स्टोरेज + डिस्क पर डेटा पढ़ें और लिखें + नेटवर्क अनुरोध + इंटरनेट पर डेटा भेजें और पाएं + WebView + ऐप के अंदर वेब कंटेंट दिखाएं + ग्रुप जैम + कनेक्शन टेस्ट करें + टेस्ट हो रहा है… + %1$d ms में कनेक्ट हुआ + विफल: %1$s + कनेक्शन + डायग्नोस्टिक्स + + + ग्रुप जैम + बनाएं + जुड़ें + एक रूम बनाएं, फिर उसका कोड अपने दोस्तों के साथ शेयर करें। + रूम बनाएं + रूम से जुड़ें + कनेक्ट हो रहा है… + रूम कोड + ABC123 + कनेक्ट है + फिर से कनेक्ट हो रहा है… + कोड कॉपी करें + कॉपी हो गया! + शेयर करें + सदस्य (%1$d) + होस्ट + श्रोता + श्रोताओं का इंतज़ार है… + हटाएं + बैन करें + मैनेज करें + रूम छोड़ें + कनेक्ट नहीं हो सका + ख़ारिज करें + जैम सेशन नहीं बन सका + जैम सेशन से नहीं जुड़ सके + अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें। + मेरे Spotube जैम से जुड़ें, कोड: %1$s + रूम सर्वर + सर्वर URL + https:// से शुरू होने वाला URL डालें + रूम शुरू करने से पहले रूम सर्वर जांच लें। + जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए github.com/team-spotube/jam-server से सर्वर डिप्लॉय करें और उसका URL डालें। + आप + सर्वर: %1$s + रूम होस्ट करने या उसमें शामिल होने के लिए सेटिंग में रूम सर्वर सेट करें। + सर्वर सेटिंग + %1$s (आप) + आपका नाम + जैम में बाकी लोग आपको %1$s के रूप में देखते हैं + जैम में सभी को दिखता है। %1$s इस्तेमाल करने के लिए खाली छोड़ें। नया नाम अगली बार रूम बनाने या उसमें शामिल होने पर लागू होगा। + ज़्यादा से ज़्यादा %1$d अक्षर इस्तेमाल करें। + कोई जैम सर्वर सेट नहीं है + जैम सर्वर तक नहीं पहुंच पाए। अपना नेटवर्क या सेटिंग → ग्रुप जैम में सर्वर URL जांचें। + जैम सर्वर का वर्शन संगत नहीं है। + इस कोड वाला कोई रूम नहीं है। कोड जांचकर फिर से कोशिश करें। + यह रूम भर गया है। + होस्ट ने आपको इस रूम से बैन कर दिया है। + होस्ट ने आपको रूम से हटा दिया है। + यह रूम इस डिवाइस पर फिर से खोला गया, इसलिए यह कनेक्शन बंद कर दिया गया। + + एल्बम पर जाएं + स्लीप टाइमर + ट्रैक की जानकारी + चलाएं + रोकें + कुछ नहीं चल रहा + कतार में जोड़ें + + अनदेखा करें + अपडेट करें + Spotube %1$s उपलब्ध है + इस रिलीज़ में क्या नया है, यह Spotube डाउनलोड पेज पर देखें। + %1$s %2$s उपलब्ध है + अभी इंस्टॉल है: %1$s + इस अपडेट में क्या नया है, यह प्लगइन के पेज पर देखें। + + छोड़ें + आगे + पीछे + शुरू करें + Spotube + संगीत, बिना किसी बंधन के। + संगीत की दुनिया के लिए एक ओपन-सोर्स प्लेयर।\nखोजिए अपने तरीके से। बनाइए इसे अपना। + इस प्रोजेक्ट को चलते रहने दें। + Spotube को लोग बनाते हैं, कोई सब्सक्रिप्शन बिज़नेस नहीं। अगर यह आपकी रोज़ की ज़िंदगी का हिस्सा बन जाए, तो थोड़ा सा सहयोग हमें इसकी वह देखभाल करने में मदद करता है जिसका यह हकदार है।\n\nस्वतंत्र सॉफ़्टवेयर को जगह देने के लिए धन्यवाद। + गाने और प्लेलिस्ट ढूंढने के लिए किसी कैटलॉग प्लगइन से शुरुआत करें। उन्हें चलाने के लिए एक ऑडियो प्लगइन चुनें। लिरिक्स और सुनने का इतिहास वैकल्पिक हैं। नीचे कोई प्लगइन खोजें, उसकी अनुमतियां देखें, इंस्टॉल करें, फिर उसे यहां चुनें। + प्लगइन खोजें + आपके सोर्स + फिर से कोशिश करें + और प्लगइन + कोई नया प्लगइन नहीं मिला। आप ऊपर दिए लिंक या .smplug फ़ाइल से भी इंस्टॉल कर सकते हैं। + प्लगइन खोज अभी उपलब्ध नहीं है। फिर से कोशिश करें, या लिंक या फ़ाइल से इंस्टॉल करें। + स्वागत + सहयोग + प्लगइन + गाइड + + Spotube कैसे इस्तेमाल करें + चार आसान स्टेप और आप सुनना शुरू कर सकते हैं। + साइन इन करें + अपना अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें। + खोजें और चलाएं + कोई भी गाना, एल्बम या कलाकार खोजें, फिर प्ले दबाएं। + ऑफ़लाइन सुनें + बिना कनेक्शन के सुनते रहने के लिए ट्रैक डाउनलोड करें। + लिरिक्स के साथ गाएं + ट्रैक चलते समय प्लेयर से सिंक्ड लिरिक्स खोलें। + + + आपका संगीत। आपके सोर्स। आपके नियम। + एक मुफ़्त, ओपन-सोर्स म्यूज़िक प्लेयर, जिसे संगीत से प्यार करने वाली कम्युनिटी ने बनाया है। + ओपन सोर्स + कोई विज्ञापन नहीं + कोई टेलीमेट्री नहीं + प्लगइन से चलने वाला + चलिए शुरू करें + + + सहयोगियों से हम चलते हैं + जुनून से बना। आपसे ज़िंदा। + Spotube 100% मुफ़्त, स्वतंत्र और ओपन सोर्स है — न विज्ञापन, न सब्सक्रिप्शन, न ट्रैकिंग। हर फ़ीचर इसलिए है क्योंकि आप जैसे लोगों ने कोड को आगे बढ़ाने में मदद करना चुना। + Patreon + मासिक स्पॉन्सर + लगातार डेवलपमेंट और इंफ्रास्ट्रक्चर में सहयोग करें + Buy Me a Coffee + एक बार की कॉफ़ी + एक छोटी सी टिप से देर रात की कोडिंग को ऊर्जा दें + Open Collective + कम्युनिटी फ़ंड + पारदर्शी, कम्युनिटी द्वारा संचालित आर्थिक सहयोग + ओपन म्यूज़िक पर भरोसा करने के लिए दिल से धन्यवाद। + सहयोग करना पूरी तरह वैकल्पिक है — Spotube हर हाल में मुफ़्त रहेगा। + स्पॉन्सर करें + %1$s खोलें + + + Spotube को सहयोग दें + Spotube को सहयोग दें + Spotube मुफ़्त, स्वतंत्र और ओपन सोर्स है — न विज्ञापन, न सब्सक्रिप्शन, न ट्रैकिंग। अगर यह आपको खुशी देता है, तो प्रोजेक्ट को सहयोग देने के बारे में सोचें। + आप ये रिमाइंडर सेटिंग → सहयोग में बंद कर सकते हैं। + बाद में + सहयोग रिमाइंडर + हर 7 दिन में प्रोजेक्ट को सहयोग देने का एक छोटा सा रिमाइंडर दिखाएं। + + + मॉड्यूलर आर्किटेक्चर + चुनें कि आपका संगीत कहां से आए। + Spotube कैटलॉग खोज, ऑडियो स्ट्रीमिंग और सिंक्ड लिरिक्स को अलग रखता है। नीचे अपने इंजन सेट करें या उन्हें कभी भी बदलें। + मेटाडेटा इंजन + म्यूज़िक खोज, सर्च, ट्रैक लिस्टिंग और प्लेलिस्ट को चलाता है। + ऑडियो इंजन + चलने वाली ऑडियो स्ट्रीम लाता और डिकोड करता है। + लिरिक्स इंजन + समय के साथ सिंक कराओके लिरिक्स रियल टाइम में दिखाता है। + स्क्रॉबल इंजन + सुनने का इतिहास Last.fm या ListenBrainz पर दर्ज करता है। + सक्रिय + बिल्ट-इन + तैयार + वैकल्पिक + एक इंजन चुनें + इंस्टॉल किए गए इंजन + डिफ़ॉल्ट बनाएं + NewPipe YouTube और LRCLib लिरिक्स बिल्ट-इन हैं और शुरू से ही स्ट्रीम करने के लिए तैयार हैं। + सेटिंग → प्लगइन में कभी भी कम्युनिटी प्लगइन मैनेज करें या नए जोड़ें। + + + रेट्रो-मॉडर्न आर्किटेक्चर + शुद्ध Hi-Fi, कोई बिचौलिया नहीं + देखिए Spotube कैसे आपकी मशीन पर सीधे डीसेंट्रलाइज़्ड प्लेबैक चलाता है। + सीधा पीयर-टू-पीयर • 256 KBPS OPUS + ब्लू आवर + Spotube · प्लेयर डेमो + बीच के पलों के लिए थोड़ा सा संगीत। + 1. डीसेंट्रलाइज़्ड खोज + सर्च सीधे आपके चुने हुए कैटलॉग प्लगइन तक जाती है। Spotube कभी आपकी पसंद की प्रोफ़ाइल नहीं बनाता। + 2. सीधा स्ट्रीम रिज़ॉल्यूशन + ऑडियो सीधे कंटेंट सर्वर से आपकी मशीन तक स्ट्रीम होता है। न कोई प्रॉक्सी, न रिमोट डेटा संग्रह। + 3. लोकल DSP और प्लेबैक + इक्वलाइज़र कंट्रोल और वैकल्पिक ऑफ़लाइन डाउनलोड के साथ लोकल रूप से डिकोड और कैश होता है। + 4. सिंक्ड लिरिक्स और जैम + कराओके लिरिक्स प्लेबैक के समय के साथ चलते हैं, साथ में LAN रिमोट कंट्रोल और ग्रुप जैम। + कोई बिचौलिया सर्वर नहीं। प्लेबैक सीधे आपके डिवाइस पर होता है — शुरू से ही निजी। + + + डिवाइस चुनें + %1$s: “%2$s” + चलाएं + कतार में जोड़ें + अगला चलाएं + यह डिवाइस + इस डिवाइस पर + रिमोट डिवाइस + रिमोट डिवाइस (%1$s) + कनेक्ट किए गए डिवाइस पर + + + कनेक्शन विफल + कनेक्शन टूट गया + भेजना विफल + खोज विफल: %1$s + कनेक्ट नहीं हो सका: %1$s + + + डिवाइस + रीफ़्रेश करें + डिस्कनेक्ट करें + कनेक्ट हो रहा है... + नेटवर्क पर डिवाइस खोजे जा रहे हैं... + कोई डिवाइस नहीं मिला + पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है। + अज्ञात डिवाइस + पता लगाया जा रहा है... + + + रिमोट कंट्रोल + डिस्कनेक्ट हो गया + कनेक्शन में गड़बड़ी: %1$s + अज्ञात ट्रैक + अज्ञात कलाकार + वॉल्यूम + कतार से हटाएं + और विकल्प + + + कहां चलाएं? + आपका एक जैम सेशन चालू है। चुनें कि यह कलेक्शन कहां चलाना है। + यहां चलाएं + जैम में सुझाएं + + + कनेक्शन विफल + कनेक्शन टूट गया + + + + जैम कतार में जोड़ा गया + + %1$d ट्रैक जैम कतार में जोड़ा गया + %1$d ट्रैक जैम कतार में जोड़े गए + + + + रिमोट कंट्रोल अनुरोध + \"%1$s\" इस डिवाइस पर प्लेबैक कंट्रोल करना चाहता है। + मना करें + अनुमति दें + हमेशा अनुमति दें + + + खोज के नतीजे + सभी + ट्रैक + यूज़र + कोई खोज प्रकार उपलब्ध नहीं + खोजने के लिए टाइप करें + ट्रैक खोजने के लिए टाइप करें + कोई नतीजा नहीं मिला + सभी देखें + खोज रहे हैं + ट्रैक लोड हो रहे हैं... + एल्बम लोड हो रहे हैं... + कलाकार लोड हो रहे हैं... + प्लेलिस्ट लोड हो रही हैं... + यूज़र लोड हो रहे हैं... + कोई एल्बम नहीं मिला + कोई कलाकार नहीं मिला + कोई प्लेलिस्ट नहीं मिली + कोई यूज़र नहीं मिला + रिकॉर्ड के ढेर खंगाले जा रहे हैं... + ब्रह्मांड में खोज रहे हैं... + स्पीकर गर्म हो रहे हैं... + विनाइल की धूल झाड़ रहे हैं... + संगीत के देवताओं से सलाह ले रहे हैं... + एंटीना ट्यून कर रहे हैं... + रिकॉर्ड पलट रहे हैं... + खोज की सुविधाएं लोड नहीं हो सकीं + खोज नहीं हो सकी + ट्रैक नहीं खोजे जा सके + एल्बम नहीं खोजे जा सके + कलाकार नहीं खोजे जा सके + प्लेलिस्ट नहीं खोजी जा सकीं + यूज़र नहीं खोजे जा सके + + + ब्राउज़ करें + + + सिंक्ड + सादा + सिंक्ड लिरिक्स उपलब्ध नहीं हैं + सादे लिरिक्स उपलब्ध नहीं हैं + + + %2$s में %1$s + %1$d kbps + %1$s kHz • %2$d ch + कोई मेटाडेटा प्लगइन नहीं चुना गया + + + वापस + फिर से कोशिश करें + डायलॉग बंद करें + लोड हो रहा है... + अज्ञात + अज्ञात गड़बड़ी + + + यूज़र + प्लेलिस्ट + + + हमें सच में खेद है + अभी कुछ ठीक से काम नहीं कर रहा है। आपका अनुभव हमारे लिए मायने रखता है, और हम इसे ठीक करने की पूरी कोशिश कर रहे हैं। कृपया फिर से कोशिश करें या नीचे गड़बड़ी की जानकारी देखें। + गड़बड़ी की जानकारी + क्लिपबोर्ड पर कॉपी करें + पूरा लॉग देखें + पूरा एरर लॉग + + + शफ़ल + शफ़ल करके चलाएं + फ़ॉलो करें + अनफ़ॉलो करें + प्लेलिस्ट एडिट करें + %1$s द्वारा + + + ट्रैक विकल्प + जैम में जोड़ें + रेडियो शुरू करें + अगला चलाएं + अगले पर ले जाएं + कतार से हटाएं + पसंदीदा से हटाएं + पसंदीदा में सेव करें + प्लेलिस्ट में जोड़ें + डाउनलोड करें + शेयर करें + + + मूल क्रम + नाम + कलाकार + एल्बम + अवधि + क्रम से लगाएं + कोई ट्रैक नहीं मिला + फ़िल्टर + एक साथ कार्रवाई + सभी डाउनलोड करें + %1$d डाउनलोड करें + सभी को कतार में जोड़ें + %1$d को कतार में जोड़ें + सभी को अगला चलाएं + %1$d को अगला चलाएं + सभी को प्लेलिस्ट में जोड़ें + %1$d को प्लेलिस्ट में जोड़ें + सभी को जैम में जोड़ें + %1$d को जैम में जोड़ें + +%1$d और + + + लोड करने के लिए कोई URL नहीं + ब्राउज़र इतिहास में पीछे जाएं + ब्राउज़र इतिहास में आगे जाएं + + + ब्लैकलिस्ट फ़िल्टर करें... + ब्लैकलिस्ट में कुछ नहीं है + \'%1$s\' से मेल खाता कोई ब्लैकलिस्ट आइटम नहीं + ट्रैक + ट्रैक + ब्लैकलिस्ट में जोड़ें + ब्लैकलिस्ट से हटाएं + + + ट्रैक का मेटाडेटा नहीं है + स्ट्रीम URL नहीं मिल सका + स्ट्रीम URL नहीं मिल सका। पहले ट्रैक चलाकर देखें। + + + सेव किए गए ट्रैक + आप + + + फ़ॉलो कर रहे हैं + टॉप ट्रैक + टॉप ट्रैक चलाएं + टॉप ट्रैक कतार में जोड़ें + मिलते-जुलते कलाकार + फ़ीचर्ड प्लेलिस्ट + कलाकार की जानकारी लोड नहीं हो सकी + फ़ॉलोअर उपलब्ध नहीं + %1$sK फ़ॉलोअर + %1$sM फ़ॉलोअर + %1$sB फ़ॉलोअर + + %1$d फ़ॉलोअर + %1$d फ़ॉलोअर + + + + पसंद करें + पसंद से हटाएं + No-op ट्रैक का नाम + No-op कलाकार + अज्ञात एल्बम + आगे-पीछे करें + वॉल्यूम + शफ़ल चालू करें + शफ़ल बंद करें + पिछला + अगला + चलाएं या रोकें + रिपीट बंद + एक को रिपीट करें + सभी को रिपीट करें + म्यूट करें + अनम्यूट करें + और विकल्प + प्लेयर विकल्प + प्लेयर छोटा करें + लिरिक्स बड़ा करें + अभी चल रहा है + लिरिक्स + वैकल्पिक सोर्स + वैकल्पिक सोर्स + डाउनलोड करें + डाउनलोड हो गया + डाउनलोड हो रहा है... + पहले से डाउनलोड है + डाउनलोड फिर से शुरू हो रहा है... + %1$s डाउनलोड हो रहा है + प्लेबैक %1$s में रुक जाएगा। + चुने गए मिनटों के बाद प्लेबैक रोकें। + मिनट + 1 से 600 मिनट के बीच चुनें + टाइमर रद्द करें + शुरू करें + प्लेबैक URL अलग स्ट्रीमिंग सेटिंग के साथ तय किया गया था। हाइलाइट की गई स्ट्रीम आपकी मौजूदा पसंद से मेल खाती है; सक्रिय URL अगली स्ट्रीम रिक्वेस्ट पर रीफ़्रेश होगा। + मेल खाता ऑडियो सोर्स + मेल खाता सोर्स लोड हो रहा है… + लोकल कैश फ़ाइल चलाते समय मेल खाते रिमोट सोर्स की ज़रूरत नहीं है। + कोई मेल खाता सोर्स उपलब्ध नहीं है। + इस ट्रैक के लिए कोई मेल खाता ऑडियो सोर्स कैश नहीं है। + स्ट्रीम की जानकारी अभी कैश नहीं है। उसे लोड करने के लिए पहले ट्रैक चलाएं। + ट्रैक की जानकारी लोड नहीं हो सकी। + उपलब्ध स्ट्रीम + यह ट्रैक लोकल कैश से चल रहा है। इस प्लेबैक के लिए स्ट्रीम वेरिएंट इस्तेमाल नहीं होते। + स्ट्रीम लोड हो रही हैं… + इस सोर्स के लिए कोई स्ट्रीम उपलब्ध नहीं है। + अभी चुनी गई स्ट्रीम + आपकी स्ट्रीमिंग फ़ॉर्मैट और क्वालिटी पसंद से मेल खाती है + सक्रिय प्लेबैक URL + लोकल कैश से चल रहा है + मेटाडेटा ट्रैक + नाम + ID + एल्बम + विश्वसनीयता + बाहरी URI + एल्बम ID + एल्बम प्रकार + एल्बम रिलीज़ की तारीख + एल्बम में ट्रैक की संख्या + एल्बम की शैलियां + अवधि + ट्रैक नंबर + डिस्क नंबर + एक्सप्लिसिट + लोकप्रियता + ISRC + थंबनेल URL + + + साइडबार दिखाएं/छिपाएं + + + वैकल्पिक सोर्स + अभी कोई ट्रैक नहीं चल रहा + वैकल्पिक सोर्स खोजे जा रहे हैं... + कोई वैकल्पिक सोर्स नहीं मिला + विश्वसनीयता: %1$d% + सक्रिय सोर्स + + + निकालें + उन्हें सेशन से हटाएं + निकालें और दोबारा जुड़ने से रोकें + सुझाव हटाएं + उनके कतार में जोड़े गए सभी ट्रैक हटाएं + क्रम बदलें + कतार से हटाएं + %1$s • %2$s ने जोड़ा + + + अज्ञात + प्लेलिस्ट लोड हो रही है... + ट्रैक जोड़ें + प्लेलिस्ट में ट्रैक जोड़ें + ट्रैक खोजें... + खोजें + कोई ट्रैक नहीं मिला + जोड़ने के लिए ट्रैक खोजें + हो गया + जोड़ें + जोड़ा गया + + + एल्बम लोड हो रहा है... + + + अपनी प्लेलिस्ट में जोड़ें + खोजें + अपनी प्लेलिस्ट खोजें... + आपकी प्लेलिस्ट लोड हो रही हैं... + अभी तक आपकी कोई प्लेलिस्ट नहीं है।\nट्रैक जोड़ने के लिए एक प्लेलिस्ट बनाएं। + प्लेलिस्ट बनाएं + प्लेलिस्ट एडिट करें + बनाएं + नाम + प्लेलिस्ट का नाम + विवरण + विवरण (वैकल्पिक) + सार्वजनिक + कोई भी यह प्लेलिस्ट देख सकता है + साझा + दूसरे लोग इस प्लेलिस्ट में ट्रैक जोड़ सकते हैं + कवर इमेज + प्लेलिस्ट कवर + कोई इमेज नहीं + इमेज बदलें + इमेज चुनें + + + इंस्टॉल करें + बदलें + zip फ़ाइल में plugin.json नहीं मिला + zip फ़ाइल में manifest.zipline.json नहीं मिला + plugin.json का फ़ॉर्मैट अमान्य है: %1$s + प्लगइन पढ़ा नहीं जा सका: %1$s + प्लगइन इंस्टॉल नहीं हो सका: %1$s + %1$s का नया वर्शन उपलब्ध है। इंस्टॉल करने से पहले अपडेट की पुष्टि करें। + %1$s पहले से इंस्टॉल है। इंस्टॉल करने से पहले उसे बदलने की पुष्टि करें। + बिल्ट-इन प्लगइन पहले से शामिल हैं और दोबारा नहीं जोड़े जा सकते। + बिल्ट-इन प्लगइन हटाए नहीं जा सकते। + %1$s ने अमान्य apiVersion (%2$s) बताया है। %3$s के साथ संगत सिमैंटिक वर्शनिंग अपेक्षित है। + Spotube प्लगइन API वर्शन %1$s अमान्य है। + %1$s प्लगइन API %2$s के लिए बना है, लेकिन Spotube ब्रेकिंग प्लगइन API %3$s सपोर्ट करता है। उसी ब्रेकिंग API वर्शन के लिए बना प्लगइन इंस्टॉल करें। + + + प्लगइन API संगत नहीं है + प्लगइन इंस्टॉल करें? + %1$s आपके इंस्टॉल किए गए प्लगइन में जोड़ा जाएगा। + प्लगइन अपडेट करें? + एक पुराना वर्शन (%1$s) इंस्टॉल है। %2$s पर अपडेट करें? + इंस्टॉल किया गया प्लगइन बदलें? + %1$s अमान्य सिमैंटिक वर्शन (%2$s) इस्तेमाल करता है और अपडेट के रूप में इसकी तुलना नहीं हो सकती। फिर भी इंस्टॉल किया गया प्लगइन बदलें? + इंस्टॉल किए गए वर्शन (%1$s) की सिमैंटिक वर्शनिंग से तुलना नहीं हो सकती। इसे %2$s से बदलें? + वर्शन %1$s पहले से इंस्टॉल है। मौजूदा प्लगइन को दी गई कॉपी से बदलें? + इंस्टॉल किया गया वर्शन %1$s, दिए गए वर्शन %2$s से नया है। फिर भी बदलें? + इंस्टॉल किए गए प्लगइन को दी गई कॉपी से बदलें? + + + %1$s: + वर्शन + API वर्शन + लेखक + लाइसेंस + क्षमताएं + सुविधाएं + रिपॉज़िटरी + संपर्क + बग रिपोर्ट करें + %1$s को सहयोग दें + + + आधिकारिक + सत्यापित + GitHub स्टार + GitHub रिपॉज़िटरी URL + + + कोई रिलीज़ नहीं मिली + रिलीज़ चुनें + एक रिलीज़ चुनें + प्री-रिलीज़ + रिलीज़ नोट्स + + + होम + खोजें + डिवाइस पर + कनेक्ट + डिवाइस + + + विंडो छिपाएं + विंडो दिखाएं + अगला ट्रैक + पिछला ट्रैक + शफ़ल + रिपीट + रिपीट: बंद + रिपीट: एक + रिपीट: सभी + वॉल्यूम: %1$d% + वॉल्यूम + + वॉल्यूम - + म्यूट करें + अनम्यूट करें + ट्रैक पसंद करें + ट्रैक को पसंद से हटाएं + बाहर निकलें + + + छोटा करें + बड़ा करें + पहले जैसा करें + बंद करें + + + प्लेबैक + मीडिया प्लेबैक कंट्रोल + + + ब्राउज़ करें + अभी चल रहा है + फ़ीचर्ड + सेव किए गए ट्रैक + टॉप ट्रैक + diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml new file mode 100644 index 00000000..0cd52e2e --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -0,0 +1,920 @@ + + + + Antrean + Kosongkan Antrean + Filter antrean... + Antrean kosong + + Cari lagu, artis, album... + Hapus semua riwayat + Cari... + + + Koleksi Kamu + Koleksi + Playlist + Album + Artis + Lagu Lokal + Unduhan + Cari playlist tersimpan… + Cari album tersimpan… + Cari artis tersimpan… + Cari folder atau lagu… + Filter koleksi + Tutup filter + Buat playlist + Muat ulang lagu lokal + Beralih ke tampilan petak + Beralih ke tampilan daftar + Belum ada playlist tersimpan + Tidak ada playlist yang cocok dengan “%1$s” + Belum ada album tersimpan + Tidak ada album yang cocok dengan “%1$s” + Belum ada artis tersimpan + Tidak ada artis yang cocok dengan “%1$s” + + + Folder + Folder + %1$s • %2$s + + %1$d lagu + + + %1$d folder + + Kembali ke folder + Putar folder + Akses audio diperlukan + Izinkan akses audio untuk menjelajahi musik di perangkatmu. + Beri akses + Tidak ada folder lokal + Tidak ada lagu di folder ini + Tidak ada folder atau lagu yang cocok + Gagal memuat ulang media lokal + + + %1$d aktif • %2$d selesai + Hapus yang selesai + Belum ada unduhan + Mengunduh %1$d% + Dalam antrean + Selesai + Dibatalkan + Gagal + Gagal: %1$s + Batalkan unduhan + Coba unduh lagi + Hapus unduhan + + + Artis + Diblokir + Lagu yang Disukai + Lagu tersimpanmu + Artis tidak dikenal + Judul lagu + Nama artis + + Dipilih + + Pengaturan + + Plugin + Bahasa dan Wilayah + Tampilan + Pemutaran + Unduhan + Cache + Desktop + Pembaruan + Dukungan + Tentang + + Tutup + Simpan + Batal + + Bahasa + Bahasa antarmuka aplikasi. Saat ini: %1$s + Bawaan sistem + Negara + Negara untuk konten dan ketersediaan. Saat ini: %1$s + %1$s (%2$s) + + Tema + Pilih cara Spotube menyesuaikan dengan tampilan sistem. Saat ini: %1$s + Terang + Gelap + Sistem + + Permukaan + Material latar belakang aplikasi. Saat ini: %1$s + Air + Es + Krom + Akrilik + Kaca + + Warna aksen + Palet saat ini: %1$s + Warna aksen + Pilih palet aksen dan pratinjau dalam tema terang maupun gelap. + Terang + Gelap + Pratinjau + Tombol + Green Goblin + Ungu Elektrik + Sian Samudra + Jingga Senja + Taman Mawar + Biru Tengah Malam + Batu Metalik + + Tampilan & nuansa + Material permukaan dan aksen. Saat ini: %1$s · %2$s + Tampilan & nuansa + Pilih material permukaan, lalu aksen yang serasi. Setiap opsi adalah kombinasi pilihan. + Permukaan + Aksen + Sekunder + + Palet + Palet aksen Millennium. Saat ini: %1$s + Palet + Pilih palet aksen Millennium dan pratinjau dalam mode terang dan gelap. + Palet + Palet dan aksen Millennium. Saat ini: %1$s + Biru + Jingga + Hijau Limau + + Ganti folder unduhan + Folder saat ini: %1$s + Biarkan kosong untuk memakai folder musik bawaan. + C:\Music\Spotube + Folder media lokal + Folder kustom untuk dipindai di desktop. Ditambahkan: %1$d + Belum ada folder kustom + Folder kustom untuk koleksi musik lokalmu. + Kelola + Tambah folder + Hapus folder + Belum ada folder kustom yang ditambahkan. + Hapus %1$s dari daftar pindai + Folder unduhan dan cache musik disertakan secara otomatis. + Folder unduhanmu disertakan secara otomatis. + Folder cache musik disertakan secara otomatis. + Menyimpan… + Gagal menyimpan folder. Perubahanmu masih ada; coba lagi. + + %1$d folder + + Format unduhan musik + Codec dan kontainer pilihan untuk unduhan. Saat ini: %1$s + Kualitas unduhan musik + + Format streaming musik + Codec dan kontainer pilihan untuk streaming musik. Saat ini: %1$s + Kualitas streaming musik + Saat ini: %1$s + Aktifkan cache musik + Simpan audio yang di-streaming secara lokal agar pemutaran ulang lebih cepat. + Folder cache + Saat ini: %1$s + Saat ini: cache aplikasi bawaan + Batas ukuran cache + Tanpa batas + Batas saat ini: %1$d MB + Atur ukuran cache maksimum dalam MB. Masukkan 0 atau kosongkan untuk tanpa batas. + 500 + Ukuran harus 0 atau bilangan positif. + Aktifkan pemutaran tanpa henti + Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis. + Port server proxy pemutaran + Izinkan kontrol jarak jauh + Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung. + Nama perangkat kontrol jarak jauh + Nama yang dilihat perangkat lain saat menemukan perangkat ini. Saat ini: %1$s + Pilih nama yang mudah dikenali untuk ditampilkan ke perangkat lain di jaringanmu saat mereka menemukan Spotube ini. + mis. Spotube Ruang Tamu + nama host perangkat + Port yang dipakai server proxy pemutaran. Saat ini: %1$d + Pilih port antara 1 dan 65535. + 14769 + Masukkan bilangan bulat yang valid. + Port harus antara 1 dan 65535. + + Daftar blokir + Kelola lagu dan artis yang diblokir + + Minimalkan ke tray + Biarkan Spotube tetap berjalan di system tray saat jendela diminimalkan. + Discord Rich Presence + Tampilkan status pemutaran saat ini di Discord pada desktop. + + Periksa pembaruan otomatis + Cari rilis Spotube baru di latar belakang. + + Kelola Plugin + Kelola plugin kamu + Plugin %1$s Bawaan + Metadata + Audio + Lirik + Scrobble + Belum ada plugin dipilih + Ubah + Pilih + Dipilih + Hapus pilihan + Tidak ada plugin tersedia + Plugin %1$s + Hapus + + Konfigurasi + Pengelola Plugin + Masukkan URL + URL harus diawali http:// atau https:// + Gagal mengunduh plugin + Belum ada plugin terpasang + Tempel URL unduhan atau pilih file di atas. + + %1$d plugin terpasang + + Pasang Plugin + https://example.com/plugin.smplug + Unduh + Pasang dari file (.smplug) + Aktif + v%1$s + Bawaan + Hapus plugin + Masuk + Keluar + Unduh dari URL + Pasang dari file + Pasang + + • %1$s + Izin yang diminta + Plugin ini tidak memerlukan izin khusus. + Terpasang vs diberikan + Terpasang + Diberikan + API %1$s → %2$s + Penyimpanan Persisten + Membaca dan menulis data ke disk + Permintaan Jaringan + Mengirim dan menerima data melalui internet + WebView + Menampilkan konten web di dalam aplikasi + Jam Grup + Uji koneksi + Menguji… + Terhubung dalam %1$d ms + Gagal: %1$s + Koneksi + Diagnostik + + + Jam Grup + Buat + Gabung + Buat ruang, lalu bagikan kodenya ke teman-temanmu. + Buat ruang + Gabung ke ruang + Menghubungkan… + Kode ruang + ABC123 + Terhubung + Menghubungkan ulang… + Salin kode + Disalin! + Bagikan + Peserta (%1$d) + Host + Pendengar + Menunggu pendengar… + Keluarkan + Blokir + Kelola + Tinggalkan ruang + Gagal terhubung + Tutup + Gagal membuat sesi Jam + Gagal bergabung ke sesi Jam + Masukkan kode ruang enam karakter berisi huruf dan angka. + Gabung ke Jam Spotube-ku dengan kode: %1$s + Server ruang + URL server + Masukkan URL yang diawali https:// + Periksa server ruang sebelum memulai ruang. + Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy server dari github.com/team-spotube/jam-server lalu masukkan URL-nya. + Kamu + Server: %1$s + Atur server ruang di Pengaturan untuk membuat atau bergabung ke ruang. + Pengaturan server + %1$s (kamu) + Namamu + Orang lain di jam melihatmu sebagai %1$s + Ditampilkan ke semua orang di jam. Biarkan kosong untuk memakai %1$s. Nama baru berlaku saat kamu berikutnya membuat atau bergabung ke ruang. + Gunakan paling banyak %1$d karakter. + Belum ada server jam yang diatur + Tidak bisa menjangkau server jam. Periksa jaringan atau URL server di Pengaturan → Jam Grup. + Server jam menjalankan versi yang tidak kompatibel. + Tidak ada ruang dengan kode itu. Periksa lalu coba lagi. + Ruang ini sudah penuh. + Host memblokirmu dari ruang ini. + Host mengeluarkanmu dari ruang. + Ruang ini dibuka lagi di perangkat ini, jadi koneksi ini ditutup. + + Buka album + Timer tidur + Detail lagu + Putar + Jeda + Tidak ada yang diputar + Tambahkan ke antrean + + Abaikan + Perbarui + Spotube %1$s telah tersedia + Lihat yang baru di rilis ini pada halaman unduhan Spotube. + %1$s %2$s telah tersedia + Terpasang saat ini: %1$s + Lihat yang baru di pembaruan ini pada halaman plugin. + + Lewati + Berikutnya + Kembali + Mulai + Spotube + Musik, tanpa ikatan. + Pemutar open source untuk dunia musik.\nBebas kamu jelajahi. Bebas kamu jadikan milikmu. + Bantu proyek ini tetap hidup. + Spotube dibuat oleh orang-orang, bukan bisnis langganan. Jika Spotube menjadi bagian dari keseharianmu, sedikit dukungan membantu kami merawatnya sebagaimana mestinya.\n\nTerima kasih telah memberi ruang bagi perangkat lunak independen. + Mulailah dengan plugin katalog untuk menemukan lagu dan playlist. Pilih plugin audio untuk memutarnya. Lirik dan riwayat dengar bersifat opsional. Temukan plugin di bawah, tinjau izinnya, pasang, lalu pilih di sini. + Temukan plugin + Sumbermu + Coba lagi + Plugin lainnya + Tidak ada plugin baru. Kamu juga bisa memasang plugin dari tautan atau file .smplug di atas. + Penemuan plugin sedang tidak tersedia. Coba lagi, atau pasang dari tautan atau file. + Selamat datang + Dukungan + Plugin + Panduan + + Cara menggunakan Spotube + Empat langkah singkat dan kamu siap mendengarkan. + Masuk + Hubungkan akunmu, atau pilih plugin katalogmu sendiri. + Cari dan putar + Temukan lagu, album, atau artis apa pun, lalu tekan putar. + Dengarkan offline + Unduh lagu agar tetap bisa diputar tanpa koneksi. + Ikuti liriknya + Buka lirik tersinkron dari pemutar saat lagu diputar. + + + Musikmu. Sumbermu. Aturanmu. + Pemutar musik gratis dan open source yang dibuat oleh komunitas pencinta musik. + Open source + Tanpa iklan + Tanpa telemetri + Berbasis plugin + Ayo Mulai + + + Pendukung membuat kami terus berjalan + Dibuat dengan semangat. Dihidupkan olehmu. + Spotube 100% gratis, bebas, dan open source — tanpa iklan, tanpa langganan, tanpa pelacakan. Setiap fitur ada karena orang sepertimu memilih untuk membantu kode terus berkembang. + Patreon + Sponsor Bulanan + Dukung pengembangan dan infrastruktur secara rutin + Buy Me a Coffee + Kopi Sekali Saja + Beri semangat sesi ngoding larut malam dengan tip singkat + Open Collective + Dana Komunitas + Dukungan finansial yang transparan dan dikelola komunitas + Terima kasih dari lubuk hati kami karena telah percaya pada musik terbuka. + Dukungan sepenuhnya opsional — Spotube tetap gratis apa pun pilihanmu. + Sponsori + Buka %1$s + + + Dukung Spotube + Dukung Spotube + Spotube gratis, bebas, dan open source — tanpa iklan, tanpa langganan, tanpa pelacakan. Jika Spotube membuatmu senang, pertimbangkan untuk mendukung proyek ini. + Kamu bisa mematikan pengingat ini di Pengaturan → Dukungan. + Nanti saja + Pengingat dukungan + Tampilkan pengingat ringan untuk mendukung proyek setiap 7 hari. + + + Arsitektur Modular + Pilih dari mana musikmu berasal. + Spotube memisahkan penemuan katalog, streaming audio, dan lirik tersinkron. Atur mesinmu di bawah atau ganti kapan saja. + Mesin Metadata + Mendukung penemuan musik, pencarian, daftar lagu, dan playlist. + Mesin Audio + Mengambil dan mendekode stream audio yang diputar. + Mesin Lirik + Menampilkan lirik karaoke tersinkron secara real-time. + Mesin Scrobble + Mencatat riwayat dengar ke Last.fm atau ListenBrainz. + Aktif + Bawaan + Siap + Opsional + Pilih mesin + Mesin Terpasang + Jadikan bawaan + NewPipe YouTube dan LRCLib Lyrics sudah bawaan dan siap diputar langsung. + Kelola atau tambahkan plugin komunitas kapan saja di Pengaturan → Plugin. + + + Arsitektur Retro-Modern + Hi-Fi murni, tanpa perantara + Begini cara Spotube mengatur pemutaran terdesentralisasi langsung di perangkatmu. + PEER-TO-PEER LANGSUNG • 256 KBPS OPUS + Blue hour + Spotube · demonstrasi pemutar + Sedikit musik untuk momen-momen di antaranya. + 1. Penemuan Terdesentralisasi + Pencarian langsung dikirim ke plugin katalog pilihanmu. Spotube tidak pernah memprofilkan selera musikmu. + 2. Resolusi Stream Langsung + Audio di-streaming langsung dari server konten ke perangkatmu. Tanpa lompatan proxy atau pengumpulan data jarak jauh. + 3. DSP & Pemutaran Lokal + Didekode dan di-cache secara lokal dengan kontrol equalizer dan unduhan offline opsional. + 4. Lirik Tersinkron & Jam + Lirik karaoke mengikuti waktu pemutaran, dengan kontrol jarak jauh via LAN dan Jam Grup. + Tanpa server perantara. Pemutaran terjadi langsung di perangkatmu — privat sejak awal. + + + Pilih perangkat + %1$s: “%2$s” + Putar + Tambahkan ke antrean + Putar berikutnya + Perangkat Ini + Di perangkat ini + Perangkat Jarak Jauh + Perangkat Jarak Jauh (%1$s) + Di perangkat yang terhubung + + + Koneksi gagal + Koneksi terputus + Gagal mengirim + Pencarian gagal: %1$s + Gagal terhubung: %1$s + + + Perangkat + Muat ulang + Putuskan + Menghubungkan... + Mencari perangkat di jaringan... + Tidak ada perangkat ditemukan + Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan. + Perangkat Tidak Dikenal + Menyelesaikan... + + + Kontrol Jarak Jauh + Terputus + Kesalahan koneksi: %1$s + Lagu Tidak Dikenal + Artis Tidak Dikenal + Volume + Hapus dari antrean + Opsi lainnya + + + Putar di Mana? + Kamu sedang dalam sesi Jam. Pilih tempat untuk memutar koleksi ini. + Putar di sini + Sarankan ke Jam + + + Koneksi gagal + Koneksi terputus + + + + Ditambahkan ke antrean Jam + + %1$d ditambahkan ke antrean Jam + + + + Permintaan Kontrol Jarak Jauh + \"%1$s\" ingin mengontrol pemutaran di perangkat ini. + Tolak + Izinkan + Selalu Izinkan + + + Hasil pencarian + Semua + Lagu + Pengguna + Tidak ada jenis pencarian tersedia + Mulai mengetik untuk mencari + Mulai mengetik untuk mencari lagu + Tidak ada hasil + Lihat semua + Mencari + Memuat lagu... + Memuat album... + Memuat artis... + Memuat playlist... + Memuat pengguna... + Tidak ada album ditemukan + Tidak ada artis ditemukan + Tidak ada playlist ditemukan + Tidak ada pengguna ditemukan + Membongkar tumpukan piringan... + Menjelajahi alam semesta... + Memanaskan speaker... + Membersihkan debu piringan hitam... + Bertanya pada dewa musik... + Menyetel antena... + Membolak-balik koleksi piringan... + Gagal memuat kemampuan pencarian + Gagal mencari + Gagal mencari lagu + Gagal mencari album + Gagal mencari artis + Gagal mencari playlist + Gagal mencari pengguna + + + Jelajahi + + + Tersinkron + Biasa + Lirik tersinkron tidak tersedia + Lirik biasa tidak tersedia + + + %1$s dalam %2$s + %1$d kbps + %1$s kHz • %2$d ch + Belum ada plugin metadata dipilih + + + Kembali + Coba lagi + Tutup dialog + Memuat... + Tidak diketahui + Kesalahan tidak diketahui + + + Pengguna + Playlist + + + Kami Mohon Maaf + Ada yang tidak berfungsi saat ini. Pengalamanmu penting bagi kami, dan kami berusaha sebaik mungkin untuk memperbaikinya. Coba lagi atau periksa detail kesalahan di bawah. + Detail Kesalahan + Salin ke papan klip + Lihat log lengkap + Log Kesalahan Lengkap + + + Acak + Putar acak + Ikuti + Berhenti mengikuti + Edit playlist + Oleh %1$s + + + Opsi lagu + Tambahkan ke Jam + Mulai radio + Putar berikutnya + Pindahkan ke berikutnya + Hapus dari antrean + Hapus dari favorit + Simpan sebagai favorit + Tambahkan ke playlist + Unduh + Bagikan + + + Asli + Judul + Artis + Album + Durasi + Urutkan + Tidak ada lagu ditemukan + Filter + Tindakan massal + Unduh Semua + Unduh %1$d + Tambahkan Semua ke Antrean + Tambahkan %1$d ke Antrean + Putar Semua Berikutnya + Putar %1$d Berikutnya + Tambahkan Semua ke Playlist + Tambahkan %1$d ke Playlist + Tambahkan Semua ke Jam + Tambahkan %1$d ke Jam + +%1$d lainnya + + + Tidak ada URL untuk dimuat + Kembali di riwayat browser + Maju di riwayat browser + + + Filter daftar blokir... + Tidak ada item yang diblokir + Tidak ada item diblokir yang cocok dengan \'%1$s\' + Lagu + Lagu + Tambahkan ke daftar blokir + Hapus dari daftar blokir + + + Tidak ada metadata lagu + Tidak dapat menemukan URL stream + Tidak dapat menemukan URL stream. Coba putar lagunya terlebih dahulu. + + + Lagu Tersimpan + Kamu + + + Mengikuti + Lagu Teratas + Putar lagu teratas + Tambahkan lagu teratas ke antrean + Artis Terkait + Playlist Unggulan + Gagal memuat ringkasan artis + Jumlah pengikut tidak tersedia + %1$s rb pengikut + %1$s jt pengikut + %1$s M pengikut + + %1$d pengikut + + + + Suka + Batal suka + Judul lagu kosong + Artis kosong + Album Tidak Dikenal + Cari posisi + Volume + Aktifkan acak + Nonaktifkan acak + Sebelumnya + Berikutnya + Putar atau jeda + Mode ulang nonaktif + Ulang satu lagu + Ulang semua + Bisukan + Bunyikan + Opsi lainnya + Opsi pemutar + Ciutkan pemutar + Perluas lirik + Sedang Diputar + Lirik + Sumber Alternatif + Sumber alternatif + Unduh + Diunduh + Mengunduh... + Sudah diunduh + Mencoba mengunduh lagi... + Mengunduh %1$s + Pemutaran akan dijeda dalam %1$s. + Jeda pemutaran setelah jumlah menit yang dipilih. + Menit + Pilih antara 1 dan 600 menit + Batalkan timer + Mulai + URL pemutaran ditentukan dengan pengaturan streaming yang berbeda. Stream yang disorot sesuai dengan preferensimu saat ini; URL aktif akan diperbarui pada permintaan stream berikutnya. + Sumber audio yang cocok + Memuat sumber yang cocok… + Sumber jarak jauh yang cocok tidak diperlukan saat memutar file cache lokal. + Tidak ada sumber yang cocok. + Tidak ada sumber audio cocok yang di-cache untuk lagu ini. + Detail stream belum di-cache. Putar lagunya terlebih dahulu untuk memuatnya. + Gagal memuat detail lagu. + Stream yang tersedia + Lagu ini diputar dari cache lokal. Varian stream tidak digunakan untuk pemutaran ini. + Memuat stream… + Tidak ada stream tersedia untuk sumber ini. + Stream yang sedang dipilih + Sesuai dengan preferensi format & kualitas streaming-mu + URL pemutaran aktif + Diputar dari cache lokal + Metadata lagu + Judul + ID + Album + Tingkat kecocokan + URI Eksternal + ID Album + Jenis album + Tanggal rilis album + Jumlah lagu album + Genre album + Durasi + Nomor lagu + Nomor disk + Eksplisit + Popularitas + ISRC + URL thumbnail + + + Tampilkan/Sembunyikan Sidebar + + + Sumber Alternatif + Tidak ada lagu yang sedang diputar + Mencari sumber alternatif... + Tidak ada sumber alternatif ditemukan + Kecocokan: %1$d% + Sumber aktif + + + Keluarkan + Keluarkan mereka dari sesi + Keluarkan dan cegah mereka bergabung kembali + Hapus saran + Hapus semua lagu yang mereka tambahkan ke antrean + Ubah urutan + Hapus dari antrean + %1$s • Ditambahkan oleh %2$s + + + Tidak diketahui + Memuat playlist... + Tambah Lagu + Tambahkan Lagu ke Playlist + Cari lagu... + Cari + Tidak ada lagu ditemukan + Cari lagu untuk ditambahkan + Selesai + Tambah + Ditambahkan + + + Memuat album... + + + Tambahkan ke Playlist Kamu + Cari + Cari playlist kamu... + Memuat playlist kamu... + Kamu belum punya playlist.\nBuat satu untuk menambahkan lagu. + Buat Playlist + Edit Playlist + Buat + Nama + Nama playlist + Deskripsi + Deskripsi (opsional) + Publik + Siapa saja dapat melihat playlist ini + Kolaboratif + Orang lain dapat menambahkan lagu ke playlist ini + Gambar Sampul + Sampul playlist + Tidak ada gambar + Ganti Gambar + Pilih Gambar + + + Pasang + Ganti + plugin.json tidak ditemukan di file zip + manifest.zipline.json tidak ditemukan di file zip + Format plugin.json tidak valid: %1$s + Gagal membaca plugin: %1$s + Gagal memasang plugin: %1$s + Versi lebih baru dari %1$s tersedia. Konfirmasi pembaruan sebelum memasang. + %1$s sudah terpasang. Konfirmasi penggantian sebelum memasang. + Plugin bawaan sudah disertakan dan tidak dapat ditambahkan lagi. + Plugin bawaan tidak dapat dihapus. + %1$s mendeklarasikan apiVersion yang tidak valid (%2$s). Diharapkan semantic versioning yang kompatibel dengan %3$s. + Versi API plugin Spotube %1$s tidak valid. + %1$s menargetkan API plugin %2$s, tetapi Spotube mendukung API plugin breaking %3$s. Pasang plugin yang dibuat untuk versi API breaking yang sama. + + + API plugin tidak kompatibel + Pasang plugin? + %1$s akan ditambahkan ke plugin terpasang. + Perbarui plugin? + Versi lama terpasang (%1$s). Perbarui ke %2$s? + Ganti plugin terpasang? + %1$s menggunakan versi semantik yang tidak valid (%2$s) dan tidak dapat dibandingkan sebagai pembaruan. Tetap ganti plugin terpasang? + Versi terpasang (%1$s) tidak dapat dibandingkan menggunakan semantic versioning. Ganti dengan %2$s? + Versi %1$s sudah terpasang. Ganti plugin yang ada dengan salinan yang diberikan? + Versi terpasang %1$s lebih baru daripada versi yang diberikan %2$s. Tetap ganti? + Ganti plugin terpasang dengan salinan yang diberikan? + + + %1$s: + Versi + Versi API + Pembuat + Lisensi + Kapabilitas + Kemampuan + Repositori + Kontak + Laporkan Bug + Dukung %1$s + + + Resmi + Terverifikasi + Bintang GitHub + URL repositori GitHub + + + Tidak ada rilis ditemukan + Pilih Rilis + Pilih rilis + Pra-rilis + Catatan Rilis + + + Beranda + Cari + Di Perangkat + Connect + Perangkat + + + Sembunyikan Jendela + Tampilkan Jendela + Lagu Berikutnya + Lagu Sebelumnya + Acak + Ulang + Ulang: MATI + Ulang: SATU + Ulang: SEMUA + Volume: %1$d% + Volume + + Volume - + Bisukan + Bunyikan + Sukai Lagu + Batal Sukai Lagu + Keluar + + + Minimalkan + Maksimalkan + Pulihkan + Tutup + + + Pemutaran + Kontrol pemutaran media + + + Jelajahi + Sedang Diputar + Unggulan + Lagu Tersimpan + Lagu Teratas + diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml new file mode 100644 index 00000000..5ddcc190 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -0,0 +1,932 @@ + + + + Coda + Svuota coda + Filtra coda... + La coda è vuota + + Cerca brani, artisti, album... + Cancella tutta la cronologia + Cerca... + + + La tua libreria + Libreria + Playlist + Album + Artisti + Brani locali + Download + Cerca nelle playlist salvate… + Cerca negli album salvati… + Cerca negli artisti salvati… + Cerca cartelle o brani… + Filtra libreria + Chiudi filtro + Crea playlist + Aggiorna brani locali + Passa alla vista a griglia + Passa alla vista a elenco + Nessuna playlist salvata + Nessuna playlist corrisponde a “%1$s” + Nessun album salvato + Nessun album corrisponde a “%1$s” + Nessun artista salvato + Nessun artista corrisponde a “%1$s” + + + Cartelle + Cartella + %1$s • %2$s + + %1$d brano + %1$d brani + %1$d brani + + + %1$d cartella + %1$d cartelle + %1$d cartelle + + Torna alle cartelle + Riproduci cartella + Accesso all\'audio necessario + Consenti l\'accesso all\'audio per sfogliare la musica sul tuo dispositivo. + Consenti accesso + Nessuna cartella locale trovata + Nessun brano in questa cartella + Nessuna cartella o brano corrispondente + Impossibile aggiornare i contenuti locali + + + %1$d attivi • %2$d completati + Rimuovi completati + Ancora nessun download + Download in corso %1$d% + In coda + Completato + Annullato + Non riuscito + Non riuscito: %1$s + Annulla download + Riprova download + Rimuovi download + + + Artista + In blacklist + Brani che ti piacciono + I tuoi brani salvati + Artista sconosciuto + Titolo del brano + Nome dell\'artista + + Selezionato + + Impostazioni + + Plugin + Lingua e area geografica + Aspetto + Riproduzione + Download + Cache + Desktop + Aggiornamenti + Supporto + Informazioni + + Chiudi + Salva + Annulla + + Lingua + Lingua dell\'interfaccia dell\'app. Attuale: %1$s + Predefinita di sistema + Paese + Paese per contenuti e disponibilità. Attuale: %1$s + %1$s (%2$s) + + Tema + Scegli come Spotube si adatta all\'aspetto del sistema. Attuale: %1$s + Chiaro + Scuro + Sistema + + Superficie + Materiale di sfondo dell\'app. Attuale: %1$s + Acqua + Ghiaccio + Cromo + Acrilico + Vetro + + Colore di accento + Palette attuale: %1$s + Colore di accento + Scegli una palette di accento e visualizzane l\'anteprima nei temi chiaro e scuro. + Chiaro + Scuro + Anteprima + Pulsante + Verde Goblin + Viola elettrico + Ciano oceano + Arancio tramonto + Giardino di rose + Blu notte + Ardesia metallica + + Aspetto e stile + Materiale della superficie e accento. Attuale: %1$s · %2$s + Aspetto e stile + Scegli un materiale per la superficie, poi un accento abbinato. Ogni opzione è una combinazione curata. + Superficie + Accento + Secondario + + Palette + Palette di accento Millennium. Attuale: %1$s + Palette + Scegli una palette di accento Millennium e visualizzane l\'anteprima in chiaro e scuro. + Palette + Palette e accento Millennium. Attuale: %1$s + Blu + Arancione + Lime + + Cartella di download personalizzata + Cartella attuale: %1$s + Lascia vuoto per usare la cartella Musica predefinita. + C:\Music\Spotube + Cartelle multimediali locali + Cartelle personalizzate da analizzare sul desktop. Aggiunte: %1$d + Nessuna cartella personalizzata aggiunta + Cartelle personalizzate per la tua libreria musicale locale. + Gestisci + Aggiungi cartella + Rimuovi cartella + Nessuna cartella personalizzata aggiunta. + Rimuovi %1$s dall\'elenco di analisi + Le cartelle di download e della cache musicale sono incluse automaticamente. + La tua cartella di download è inclusa automaticamente. + La cartella della cache musicale è inclusa automaticamente. + Salvataggio… + Impossibile salvare le cartelle. Le modifiche sono ancora qui; riprova. + + %1$d cartella + %1$d cartelle + %1$d cartelle + + Formato musica scaricata + Codec e contenitore preferiti per i download. Attuale: %1$s + Qualità musica scaricata + + Formato musica in streaming + Codec e contenitore preferiti per lo streaming musicale. Attuale: %1$s + Qualità musica in streaming + Attuale: %1$s + Attiva cache musicale + Salva l\'audio in streaming in locale per velocizzare gli ascolti successivi. + Cartella cache + Attuale: %1$s + Attuale: cache predefinita dell\'app + Limite dimensione cache + Nessun limite + Limite attuale: %1$d MB + Imposta la dimensione massima della cache in MB. Inserisci 0 o lascia vuoto per nessun limite. + 500 + La dimensione deve essere 0 o un numero positivo. + Attiva riproduzione continua + Continua la riproduzione con brani consigliati quando la coda finisce. + Porta del server proxy di riproduzione + Consenti controllo remoto + Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono. + Nome del dispositivo per il controllo remoto + Il nome che gli altri dispositivi vedono quando trovano questo. Attuale: %1$s + Scegli un nome riconoscibile da mostrare agli altri dispositivi della tua rete quando trovano questa istanza di Spotube. + es. Spotube del soggiorno + nome host del dispositivo + Porta usata dal server proxy di riproduzione. Attuale: %1$d + Scegli una porta tra 1 e 65535. + 14769 + Inserisci un numero intero valido. + La porta deve essere compresa tra 1 e 65535. + + Blacklist + Gestisci brani e artisti in blacklist + + Riduci nell\'area di notifica + Mantieni Spotube in esecuzione nell\'area di notifica quando la finestra è ridotta a icona. + Rich Presence di Discord + Mostra lo stato di riproduzione attuale su Discord dal desktop. + + Controlla automaticamente gli aggiornamenti + Cerca nuove versioni di Spotube in background. + + Gestisci plugin + Gestisci i tuoi plugin + Plugin %1$s predefinito + Metadati + Audio + Testi + Scrobbling + Nessun plugin selezionato + Cambia + Seleziona + Selezionato + Cancella selezione + Nessun plugin disponibile + Plugin %1$s + Cancella + + Configura + Gestione plugin + Inserisci un URL + L\'URL deve iniziare con http:// o https:// + Impossibile scaricare il plugin + Nessun plugin installato + Incolla un URL di download o scegli un file qui sopra. + + %1$d plugin installato + %1$d di plugin installati + %1$d plugin installati + + Installa un plugin + https://example.com/plugin.smplug + Scarica + Installa da file (.smplug) + Attivo + v%1$s + Integrato + Rimuovi plugin + Accedi + Esci + Scarica da URL + Installa da file + Installa + + • %1$s + Autorizzazioni richieste + Questo plugin non richiede autorizzazioni speciali. + Installato e fornito + Installato + Fornito + API %1$s → %2$s + Archiviazione permanente + Leggere e scrivere dati su disco + Richieste di rete + Inviare e ricevere dati tramite Internet + WebView + Mostrare contenuti web all\'interno dell\'app + Jam di gruppo + Verifica connessione + Verifica in corso… + Connesso in %1$d ms + Non riuscito: %1$s + Connessione + Diagnostica + + + Jam di gruppo + Crea + Partecipa + Crea una stanza, poi condividi il codice con i tuoi amici. + Crea stanza + Entra nella stanza + Connessione… + Codice stanza + ABC123 + Connesso + Riconnessione… + Copia codice + Copiato! + Condividi + Partecipanti (%1$d) + Host + Ascoltatore + In attesa di ascoltatori… + Rimuovi + Banna + Gestisci + Esci dalla stanza + Impossibile connettersi + Ignora + Impossibile creare la sessione Jam + Impossibile partecipare alla sessione Jam + Inserisci un codice stanza di sei caratteri composto da lettere e cifre. + Unisciti alla mia Jam su Spotube con il codice: %1$s + Server delle stanze + URL del server + Inserisci un URL che inizi con https:// + Controlla il server delle stanze prima di avviare una stanza. + Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci il server da github.com/team-spotube/jam-server e inseriscine l’URL. + Tu + Server: %1$s + Imposta un server delle stanze nelle Impostazioni per ospitare una stanza o entrarci. + Impostazioni del server + %1$s (tu) + Il tuo nome + Gli altri nella jam ti vedono come %1$s + Visibile a tutti nella jam. Lascia vuoto per usare %1$s. Un nuovo nome vale dalla prossima volta che avvii una stanza o ci entri. + Usa al massimo %1$d caratteri. + Nessun server jam configurato + Impossibile raggiungere il server jam. Controlla la rete o l’URL del server in Impostazioni → Jam di gruppo. + Il server jam usa una versione non compatibile. + Nessuna stanza con questo codice. Controllalo e riprova. + Questa stanza è piena. + L’host ti ha bannato da questa stanza. + L’host ti ha rimosso dalla stanza. + Questa stanza è stata riaperta su questo dispositivo, quindi questa connessione è stata chiusa. + + Vai all\'album + Timer di spegnimento + Dettagli brano + Riproduci + Pausa + Nessuna riproduzione in corso + Aggiungi alla coda + + Ignora + Aggiorna + Spotube %1$s è disponibile + Scopri le novità di questa versione nella pagina dei download di Spotube. + %1$s %2$s è disponibile + Attualmente installata: %1$s + Scopri le novità di questo aggiornamento nella pagina del plugin. + + Salta + Avanti + Indietro + Inizia + Spotube + Musica, senza vincoli. + Un player open source per un mondo di musica.\nTutto da esplorare. Tutto da rendere tuo. + Aiutaci a mantenere vivo questo progetto. + Spotube è creato da persone, non da un\'azienda in abbonamento. Se diventa parte della tua quotidianità, un piccolo contributo ci aiuta a dargli la cura che merita.\n\nGrazie per fare spazio al software indipendente. + Inizia con un plugin di catalogo per trovare brani e playlist. Scegli un plugin audio per riprodurli. Testi e cronologia di ascolto sono facoltativi. Scopri un plugin qui sotto, controlla le sue autorizzazioni, installalo e poi selezionalo qui. + Scopri plugin + Le tue fonti + Riprova + Altri plugin + Nessun nuovo plugin trovato. Puoi anche installarne uno da un link o da un file .smplug qui sopra. + La ricerca dei plugin non è disponibile al momento. Riprova oppure installa da un link o da un file. + Benvenuto + Supporto + Plugin + Guida + + Come usare Spotube + Quattro rapidi passaggi e sei pronto ad ascoltare. + Accedi + Collega il tuo account o scegli il tuo plugin di catalogo. + Cerca e riproduci + Trova qualsiasi brano, album o artista, poi premi play. + Ascolta offline + Scarica i brani per continuare ad ascoltarli senza connessione. + Segui il testo + Apri i testi sincronizzati dal player mentre ascolti un brano. + + + La tua musica. Le tue fonti. Le tue regole. + Un player musicale gratuito e open source, creato da una community che ama semplicemente la musica. + Open source + Nessuna pubblicità + Nessuna telemetria + Basato su plugin + Iniziamo + + + I sostenitori ci fanno andare avanti + Creato con passione. Tenuto in vita da te. + Spotube è gratuito al 100%, libero e open source: niente pubblicità, niente abbonamenti, nessun tracciamento. Ogni funzione esiste perché persone come te hanno scelto di contribuire allo sviluppo. + Patreon + Sostenitore mensile + Sostieni lo sviluppo continuo e l\'infrastruttura + Buy Me a Coffee + Un caffè una tantum + Alimenta una sessione di programmazione notturna con una piccola mancia + Open Collective + Fondo della community + Sostegno finanziario trasparente e gestito dalla community + Grazie di cuore per credere nella musica libera. + Il sostegno è del tutto facoltativo: Spotube resta comunque gratuito. + Sostieni + Apri %1$s + + + Sostieni Spotube + Sostieni Spotube + Spotube è gratuito, libero e open source: niente pubblicità, niente abbonamenti, nessun tracciamento. Se ti rende felice, valuta di sostenere il progetto. + Puoi disattivare questi promemoria in Impostazioni → Supporto. + Forse più tardi + Promemoria di sostegno + Mostra un discreto promemoria per sostenere il progetto ogni 7 giorni. + + + Architettura modulare + Scegli da dove arriva la tua musica. + Spotube separa la ricerca nel catalogo, lo streaming audio e i testi sincronizzati. Configura i tuoi motori qui sotto o cambiali in qualsiasi momento. + Motore metadati + Gestisce scoperta musicale, ricerca, elenchi di brani e playlist. + Motore audio + Recupera e decodifica i flussi audio da riprodurre. + Motore testi + Trasmette testi karaoke sincronizzati in tempo reale. + Motore scrobbling + Registra la cronologia di ascolto su Last.fm o ListenBrainz. + Attivo + Integrato + Pronto + Facoltativo + Seleziona un motore + Motori installati + Imposta come predefinito + NewPipe YouTube e LRCLib Lyrics sono integrati e pronti per lo streaming fin da subito. + Gestisci o aggiungi nuovi plugin della community in qualsiasi momento in Impostazioni → Plugin. + + + Architettura retro-moderna + Puro Hi-Fi, zero intermediari + Ecco come Spotube gestisce la riproduzione decentralizzata direttamente sul tuo dispositivo. + PEER-TO-PEER DIRETTO • OPUS 256 KBPS + Ora blu + Spotube · dimostrazione del player + Un po\' di musica per i momenti di mezzo. + 1. Ricerca decentralizzata + Le ricerche vanno direttamente al plugin di catalogo scelto. Spotube non profila mai i tuoi gusti. + 2. Risoluzione diretta degli stream + L\'audio arriva direttamente dai server dei contenuti al tuo dispositivo. Nessun proxy intermedio né raccolta remota di dati. + 3. DSP e riproduzione in locale + Decodificato e salvato in cache in locale, con equalizzatore e download offline facoltativo. + 4. Testi sincronizzati e Jam + I testi karaoke seguono il tempo di riproduzione, con controllo remoto via LAN e Jam di gruppo. + Nessun server intermediario. La riproduzione avviene direttamente sul tuo dispositivo: privacy by design. + + + Scegli un dispositivo + %1$s: «%2$s» + Riproduci + Aggiungi alla coda + Riproduci dopo + Questo dispositivo + Su questo dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + Sul dispositivo connesso + + + Connessione non riuscita + Connessione persa + Invio non riuscito + Ricerca non riuscita: %1$s + Impossibile connettersi: %1$s + + + Dispositivi + Aggiorna + Disconnetti + Connessione... + Ricerca di dispositivi sulla rete... + Nessun dispositivo trovato + Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni. + Dispositivo sconosciuto + Risoluzione... + + + Controllo remoto + Disconnesso + Errore di connessione: %1$s + Brano sconosciuto + Artista sconosciuto + Volume + Rimuovi dalla coda + Altre opzioni + + + Dove riprodurre? + Hai una sessione Jam attiva. Scegli dove riprodurre questa raccolta. + Riproduci qui + Suggerisci alla Jam + + + Connessione non riuscita + Connessione persa + + + + Aggiunto alla coda della Jam + + %1$d brano aggiunto alla coda della Jam + %1$d brani aggiunti alla coda della Jam + %1$d brani aggiunti alla coda della Jam + + + + Richiesta di controllo remoto + \"%1$s\" vuole controllare la riproduzione su questo dispositivo. + Rifiuta + Consenti + Consenti sempre + + + Risultati della ricerca + Tutto + Brani + Utenti + Nessun tipo di ricerca disponibile + Inizia a digitare per cercare + Inizia a digitare per cercare brani + Nessun risultato trovato + Mostra tutto + Ricerca in corso + Caricamento brani... + Caricamento album... + Caricamento artisti... + Caricamento playlist... + Caricamento utenti... + Nessun album trovato + Nessun artista trovato + Nessuna playlist trovata + Nessun utente trovato + Frugando tra le casse di dischi... + Esplorando il cosmo... + Scaldando le casse... + Spolverando i vinili... + Consultando gli dei della musica... + Sintonizzando le antenne... + Sfogliando i dischi... + Impossibile caricare le funzionalità di ricerca + Ricerca non riuscita + Impossibile cercare i brani + Impossibile cercare gli album + Impossibile cercare gli artisti + Impossibile cercare le playlist + Impossibile cercare gli utenti + + + Sfoglia + + + Sincronizzati + Semplici + Nessun testo sincronizzato disponibile + Nessun testo semplice disponibile + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d can. + Nessun plugin di metadati selezionato + + + Indietro + Riprova + Chiudi finestra di dialogo + Caricamento... + Sconosciuto + Errore sconosciuto + + + Utente + Playlist + + + Ci dispiace davvero + Qualcosa non sta funzionando in questo momento. La tua esperienza è importante per noi e stiamo facendo del nostro meglio per risolvere il problema. Riprova o controlla i dettagli dell\'errore qui sotto. + Dettagli dell\'errore + Copia negli appunti + Visualizza log completo + Log completo dell\'errore + + + Riproduzione casuale + Riproduci in ordine casuale + Segui + Non seguire più + Modifica playlist + Di %1$s + + + Opzioni brano + Aggiungi alla Jam + Avvia radio + Riproduci dopo + Sposta come successivo + Rimuovi dalla coda + Rimuovi dai preferiti + Salva nei preferiti + Aggiungi alla playlist + Scarica + Condividi + + + Originale + Titolo + Artista + Album + Durata + Ordina + Nessun brano trovato + Filtra + Azioni multiple + Scarica tutto + Scarica %1$d + Aggiungi tutto alla coda + Aggiungi %1$d alla coda + Riproduci tutto dopo + Riproduci %1$d dopo + Aggiungi tutto alla playlist + Aggiungi %1$d alla playlist + Aggiungi tutto alla Jam + Aggiungi %1$d alla Jam + +%1$d altri + + + Nessun URL da caricare + Torna indietro nella cronologia del browser + Vai avanti nella cronologia del browser + + + Filtra blacklist... + Nessun elemento in blacklist + Nessun elemento in blacklist corrisponde a \'%1$s\' + Brani + Brano + Aggiungi alla blacklist + Rimuovi dalla blacklist + + + Nessun metadato del brano + Impossibile risolvere l\'URL dello stream + Impossibile risolvere l\'URL dello stream. Prova prima a riprodurre il brano. + + + Brani salvati + Tu + + + Segui già + Brani più popolari + Riproduci i brani più popolari + Aggiungi i brani più popolari alla coda + Artisti simili + Playlist in evidenza + Impossibile caricare la panoramica dell\'artista + Follower non disponibili + %1$s K follower + %1$s Mln follower + %1$s Mld follower + + %1$d follower + %1$d follower + %1$d follower + + + + Mi piace + Non mi piace più + Titolo brano segnaposto + Artisti segnaposto + Album sconosciuto + Posizione + Volume + Attiva riproduzione casuale + Disattiva riproduzione casuale + Precedente + Successivo + Riproduci o metti in pausa + Ripetizione disattivata + Ripeti brano + Ripeti tutto + Disattiva audio + Riattiva audio + Altre opzioni + Opzioni del player + Comprimi player + Espandi testo + In riproduzione + Testo + Fonte alternativa + Fonte alternativa + Scarica + Scaricato + Download in corso... + Già scaricato + Nuovo tentativo di download... + Download di %1$s + La riproduzione si interromperà tra %1$s. + Metti in pausa la riproduzione dopo il numero di minuti selezionato. + Minuti + Scegli tra 1 e 600 minuti + Annulla timer + Avvia + L\'URL di riproduzione è stato risolto con impostazioni di streaming diverse. Lo stream evidenziato corrisponde alle tue preferenze attuali; l\'URL attivo verrà aggiornato alla prossima richiesta di stream. + Fonte audio abbinata + Caricamento della fonte abbinata… + Una fonte remota abbinata non è necessaria durante la riproduzione del file locale in cache. + Nessuna fonte abbinata disponibile. + Nessuna fonte audio abbinata in cache per questo brano. + I dettagli dello stream non sono ancora in cache. Riproduci prima il brano per caricarli. + Impossibile caricare i dettagli del brano. + Stream disponibili + Questo brano viene riprodotto dalla cache locale. Le varianti di stream non vengono usate per questa riproduzione. + Caricamento degli stream… + Nessuno stream disponibile per questa fonte. + Stream attualmente selezionato + Corrisponde alle tue preferenze di formato e qualità dello streaming + URL di riproduzione attivo + Riprodotto dalla cache locale + Brano dei metadati + Titolo + ID + Album + Affidabilità + URI esterno + ID album + Tipo di album + Data di uscita dell\'album + Numero di brani dell\'album + Generi dell\'album + Durata + Numero del brano + Numero del disco + Esplicito + Popolarità + ISRC + URL delle miniature + + + Mostra/nascondi barra laterale + + + Fonti alternative + Nessun brano in riproduzione + Ricerca di fonti alternative... + Nessuna fonte alternativa trovata + Affidabilità: %1$d% + Fonte attiva + + + Espelli + Rimuovilo dalla sessione + Espellilo e impediscigli di rientrare + Rimuovi suggerimenti + Rimuovi tutti i brani che ha aggiunto alla coda + Riordina + Rimuovi dalla coda + %1$s • Aggiunto da %2$s + + + Sconosciuto + Caricamento playlist... + Aggiungi brani + Aggiungi brani alla playlist + Cerca brani... + Cerca + Nessun brano trovato + Cerca brani da aggiungere + Fine + Aggiungi + Aggiunto + + + Caricamento album... + + + Aggiungi a una tua playlist + Cerca + Cerca nelle tue playlist... + Caricamento delle tue playlist... + Non hai ancora nessuna playlist.\nCreane una per aggiungere brani. + Crea playlist + Modifica playlist + Crea + Nome + Nome della playlist + Descrizione + Descrizione (facoltativa) + Pubblica + Chiunque può vedere questa playlist + Collaborativa + Altre persone possono aggiungere brani a questa playlist + Immagine di copertina + Copertina della playlist + Nessuna immagine + Cambia immagine + Scegli immagine + + + Installa + Sostituisci + plugin.json non trovato nel file zip + manifest.zipline.json non trovato nel file zip + Formato di plugin.json non valido: %1$s + Impossibile leggere il plugin: %1$s + Impossibile installare il plugin: %1$s + È disponibile una versione più recente di %1$s. Conferma l\'aggiornamento prima di installarla. + %1$s è già installato. Conferma la sostituzione prima di installarlo. + I plugin integrati sono già inclusi e non possono essere aggiunti di nuovo. + I plugin integrati non possono essere rimossi. + %1$s dichiara un apiVersion non valido (%2$s). È previsto un versionamento semantico compatibile con %3$s. + La versione API dei plugin di Spotube %1$s non è valida. + %1$s è destinato all\'API plugin %2$s, ma Spotube supporta l\'API plugin %3$s con modifiche incompatibili. Installa un plugin creato per la stessa versione principale dell\'API. + + + API del plugin non compatibile + Installare il plugin? + %1$s verrà aggiunto ai plugin installati. + Aggiornare il plugin? + È installata una versione precedente (%1$s). Aggiornare a %2$s? + Sostituire il plugin installato? + %1$s usa una versione semantica non valida (%2$s) e non può essere confrontato come aggiornamento. Sostituire comunque il plugin installato? + La versione installata (%1$s) non può essere confrontata tramite versionamento semantico. Sostituirla con %2$s? + La versione %1$s è già installata. Sostituire il plugin esistente con la copia fornita? + La versione installata %1$s è più recente della versione fornita %2$s. Sostituirla comunque? + Sostituire il plugin installato con la copia fornita? + + + %1$s: + Versione + Versione API + Autore + Licenza + Funzionalità + Abilità + Repository + Contatto + Segnala bug + Sostieni %1$s + + + Ufficiale + Verificato + Stelle su GitHub + URL del repository GitHub + + + Nessuna versione trovata + Seleziona versione + Seleziona una versione + Pre-release + Note di rilascio + + + Home + Cerca + Sul dispositivo + Connect + Dispositivi + + + Nascondi finestra + Mostra finestra + Brano successivo + Brano precedente + Riproduzione casuale + Ripetizione + Ripetizione: NO + Ripetizione: UNO + Ripetizione: TUTTI + Volume: %1$d% + Volume + + Volume - + Disattiva audio + Riattiva audio + Metti Mi piace al brano + Togli Mi piace al brano + Esci + + + Riduci a icona + Ingrandisci + Ripristina + Chiudi + + + Riproduzione + Controlli di riproduzione multimediale + + + Sfoglia + In riproduzione + In evidenza + Brani salvati + Brani più popolari + diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml new file mode 100644 index 00000000..1464d695 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -0,0 +1,920 @@ + + + + 再生キュー + キューをクリア + キューを絞り込み... + キューに曲がありません + + 曲、アーティスト、アルバムを検索... + 履歴をすべて消去 + 検索... + + + ライブラリ + ライブラリ + プレイリスト + アルバム + アーティスト + ローカルの曲 + ダウンロード + 保存したプレイリストを検索… + 保存したアルバムを検索… + 保存したアーティストを検索… + フォルダや曲を検索… + ライブラリを絞り込み + 絞り込みを閉じる + プレイリストを作成 + ローカルの曲を更新 + グリッド表示に切り替え + リスト表示に切り替え + 保存したプレイリストはありません + 「%1$s」に一致するプレイリストはありません + 保存したアルバムはありません + 「%1$s」に一致するアルバムはありません + 保存したアーティストはありません + 「%1$s」に一致するアーティストはありません + + + フォルダ + フォルダ + %1$s • %2$s + + %1$d曲 + + + %1$d個のフォルダ + + フォルダ一覧に戻る + フォルダを再生 + オーディオへのアクセスが必要です + デバイス内の音楽を表示するには、オーディオへのアクセスを許可してください。 + アクセスを許可 + ローカルフォルダが見つかりません + このフォルダには曲がありません + 一致するフォルダや曲はありません + ローカルメディアを更新できませんでした + + + %1$d件進行中 • %2$d件完了 + 完了済みを消去 + ダウンロードはまだありません + ダウンロード中 %1$d% + 待機中 + 完了 + キャンセル済み + 失敗 + 失敗: %1$s + ダウンロードをキャンセル + ダウンロードを再試行 + ダウンロードを削除 + + + アーティスト + ブラックリスト登録済み + お気に入りの曲 + 保存した曲 + 不明なアーティスト + 曲名 + アーティスト名 + + 選択済み + + 設定 + + プラグイン + 言語と地域 + 外観 + 再生 + ダウンロード + キャッシュ + デスクトップ + アップデート + サポート + このアプリについて + + 閉じる + 保存 + キャンセル + + 言語 + アプリの表示言語。現在: %1$s + システムのデフォルト + 国 + コンテンツと利用可否に使用する国・地域。現在: %1$s + %1$s (%2$s) + + テーマ + Spotubeをシステムの外観にどう合わせるかを選択します。現在: %1$s + ライト + ダーク + システム + + サーフェス + アプリの背景素材。現在: %1$s + ウォーター + アイス + クローム + アクリル + ガラス + + アクセントカラー + 現在のパレット: %1$s + アクセントカラー + アクセントパレットを選び、ライトとダークの両テーマでプレビューできます。 + ライト + ダーク + プレビュー + ボタン + グリーンゴブリン + エレクトリックバイオレット + オーシャンシアン + サンセットオレンジ + ローズガーデン + ミッドナイトブルー + メタリックスレート + + ルック&フィール + サーフェス素材とアクセント。現在: %1$s · %2$s + ルック&フィール + サーフェス素材を選び、それに合うアクセントを選択してください。どの選択肢も厳選された組み合わせです。 + サーフェス + アクセント + セカンダリ + + パレット + Millenniumアクセントパレット。現在: %1$s + パレット + Millenniumアクセントパレットを選び、ライトとダークでプレビューできます。 + パレット + Millenniumパレットとアクセント。現在: %1$s + ブルー + オレンジ + ライム + + ダウンロードフォルダの変更 + 現在のフォルダ: %1$s + 空欄にするとデフォルトの音楽フォルダを使用します。 + C:\Music\Spotube + ローカルメディアフォルダ + デスクトップでスキャンするカスタムフォルダ。追加済み: %1$d + カスタムフォルダはまだ追加されていません + ローカル音楽ライブラリ用のカスタムフォルダ。 + 管理 + フォルダを追加 + フォルダを削除 + カスタムフォルダは追加されていません。 + %1$sをスキャン対象から削除 + ダウンロードフォルダと音楽キャッシュフォルダは自動的に含まれます。 + ダウンロードフォルダは自動的に含まれます。 + 音楽キャッシュフォルダは自動的に含まれます。 + 保存中… + フォルダを保存できませんでした。変更内容は保持されていますので、もう一度お試しください。 + + %1$d個のフォルダ + + ダウンロードの音楽フォーマット + ダウンロードに使用するコーデックとコンテナ。現在: %1$s + ダウンロードの音質 + + ストリーミングの音楽フォーマット + ストリーミング再生に使用するコーデックとコンテナ。現在: %1$s + ストリーミングの音質 + 現在: %1$s + 音楽のキャッシュを有効化 + ストリーミングした音声をローカルに保存し、再度の再生を高速化します。 + キャッシュフォルダ + 現在: %1$s + 現在: アプリのデフォルトキャッシュ + キャッシュサイズの上限 + 上限なし + 現在の上限: %1$d MB + キャッシュの最大サイズをMB単位で設定します。0を入力するか空欄にすると上限なしになります。 + 500 + サイズは0または正の数で指定してください。 + エンドレス再生を有効化 + キューが終わったら、おすすめの曲で再生を続けます。 + 再生プロキシサーバーのポート + リモート操作を許可 + 同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。 + リモート操作用のデバイス名 + 他のデバイスがこのデバイスを検出したときに表示される名前。現在: %1$s + ネットワーク上の他のデバイスがこのSpotubeを検出したときに表示される、わかりやすい名前を付けてください。 + 例: リビングのSpotube + デバイスのホスト名 + 再生プロキシサーバーが使用するポート。現在: %1$d + 1〜65535のポートを選択してください。 + 14769 + 有効な整数を入力してください。 + ポートは1〜65535の範囲で指定してください。 + + ブラックリスト + ブラックリストに登録した曲とアーティストを管理 + + トレイに最小化 + ウィンドウを最小化したとき、Spotubeをシステムトレイで実行し続けます。 + Discordリッチプレゼンス + デスクトップで現在の再生状況をDiscordに表示します。 + + アップデートを自動確認 + Spotubeの新しいリリースをバックグラウンドで確認します。 + + プラグインを管理 + プラグインを管理します + デフォルトの%1$sプラグイン + メタデータ + オーディオ + 歌詞 + Scrobble + プラグインが選択されていません + 変更 + 選択 + 選択済み + 選択を解除 + 利用できるプラグインはありません + %1$sプラグイン + クリア + + 構成 + プラグインマネージャー + URLを入力してください + URLは http:// または https:// で始まる必要があります + プラグインをダウンロードできませんでした + プラグインはまだインストールされていません + 上でダウンロードURLを貼り付けるか、ファイルを選択してください。 + + %1$d個のプラグインをインストール済み + + プラグインをインストール + https://example.com/plugin.smplug + ダウンロード + ファイルからインストール (.smplug) + 有効 + v%1$s + 内蔵 + プラグインを削除 + ログイン + ログアウト + URLからダウンロード + ファイルからインストール + インストール + + • %1$s + 要求される権限 + このプラグインに特別な権限は必要ありません。 + インストール済み vs 提供版 + インストール済み + 提供版 + API %1$s → %2$s + 永続ストレージ + ディスクへのデータの読み書き + ネットワークリクエスト + インターネット経由でのデータの送受信 + WebView + アプリ内でのWebコンテンツの表示 + グループジャム + 接続をテスト + テスト中… + %1$d msで接続しました + 失敗: %1$s + 接続 + 診断 + + + グループジャム + 作成 + 参加 + ルームを作成して、コードを友達に共有しましょう。 + ルームを作成 + ルームに参加 + 接続中… + ルームコード + ABC123 + 接続済み + 再接続中… + コードをコピー + コピーしました! + 共有 + 参加者 (%1$d) + ホスト + リスナー + リスナーを待っています… + 削除 + BAN + 管理 + ルームから退出 + 接続できませんでした + 閉じる + ジャムセッションを作成できませんでした + ジャムセッションに参加できませんでした + 英数字6文字のルームコードを入力してください。 + コード %1$s で私のSpotubeジャムに参加しよう + ルームサーバー + サーバーURL + https:// で始まるURLを入力してください。 + ルームを始める前にルームサーバーを確認します。 + ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、github.com/team-spotube/jam-server のサーバーをデプロイし、そのURLを入力してください。 + あなた + サーバー: %1$s + ルームをホストまたは参加するには、設定でルームサーバーを指定してください。 + サーバー設定 + %1$s(あなた) + あなたの名前 + ジャムの他の参加者には %1$s と表示されます + ジャムの全員に表示されます。空欄にすると %1$s を使います。新しい名前は次にルームを作成または参加したときから使われます。 + %1$d 文字以内で入力してください。 + ジャムサーバーが設定されていません + ジャムサーバーに接続できません。ネットワーク、または 設定 → グループジャム のサーバーURLを確認してください。 + ジャムサーバーのバージョンに互換性がありません。 + そのコードのルームはありません。コードを確認してもう一度お試しください。 + このルームは満員です。 + ホストによってこのルームからBANされました。 + ホストによってルームから削除されました。 + このデバイスでこのルームが再度開かれたため、この接続は閉じられました。 + + アルバムへ移動 + スリープタイマー + 曲の詳細 + 再生 + 一時停止 + 再生中の曲はありません + キューに追加 + + 無視 + アップデート + Spotube %1$s が利用可能です + このリリースの新機能はSpotubeのダウンロードページでご確認ください。 + %1$s %2$s が利用可能です + インストール済み: %1$s + このアップデートの新機能はプラグインのページでご確認ください。 + + スキップ + 次へ + 戻る + はじめる + Spotube + 音楽を、しがらみなく。 + 音楽の世界へ広がる、オープンソースのプレーヤー。\n自由に探して、自分らしく楽しもう。 + このプロジェクトを支えてください。 + Spotubeはサブスクリプションビジネスではなく、人の手でつくられています。日々の暮らしの一部になったなら、少しのご支援が、このアプリにふさわしい手入れを続ける力になります。\n\n独立したソフトウェアに居場所をつくってくださり、ありがとうございます。 + まずはカタログプラグインで曲やプレイリストを探せるようにしましょう。次に、再生用のオーディオプラグインを選びます。歌詞と再生履歴は任意です。下からプラグインを見つけて権限を確認し、インストールしてからここで選択してください。 + プラグインを探す + ソース + 再試行 + その他のプラグイン + 新しいプラグインは見つかりませんでした。上のリンクや.smplugファイルからインストールすることもできます。 + 現在プラグインを検索できません。再試行するか、リンクまたはファイルからインストールしてください。 + ようこそ + サポート + プラグイン + ガイド + + Spotubeの使い方 + 簡単な4ステップで、すぐに聴けます。 + サインイン + アカウントを連携するか、お好みのカタログプラグインを選んでください。 + 検索して再生 + 曲、アルバム、アーティストを探して再生しましょう。 + オフラインで聴く + 曲をダウンロードすれば、接続がなくても再生できます。 + 歌詞を表示 + 曲の再生中に、プレーヤーから同期歌詞を開けます。 + + + あなたの音楽。あなたのソース。あなたのルール。 + 音楽を愛するコミュニティがつくった、無料でオープンソースの音楽プレーヤー。 + オープンソース + 広告なし + テレメトリなし + プラグインで拡張 + はじめよう + + + サポーターの皆さんに支えられています + 情熱でつくり、あなたが支える。 + Spotubeは100%無料の自由なオープンソースソフトウェアです。広告も、サブスクリプションも、トラッキングもありません。すべての機能は、あなたのような人たちが開発を支えてくれたからこそ存在しています。 + Patreon + 毎月のスポンサー + 継続的な開発とインフラを支援 + Buy Me a Coffee + 1回限りのコーヒー + ちょっとしたチップで深夜のコーディングを応援 + Open Collective + コミュニティ基金 + 透明性が高く、コミュニティが運営する資金支援 + オープンな音楽を信じてくださり、心から感謝します。 + 支援はまったくの任意です。どちらでもSpotubeは無料のままです。 + 支援する + %1$sを開く + + + Spotubeを支援 + Spotubeを支援 + Spotubeは無料の自由なオープンソースソフトウェアです。広告も、サブスクリプションも、トラッキングもありません。気に入っていただけたら、プロジェクトへのご支援をご検討ください。 + このリマインダーは「設定 → サポート」でオフにできます。 + あとで + 支援のリマインダー + 7日ごとに、プロジェクトへの支援をさりげなくお知らせします。 + + + モジュール式アーキテクチャ + 音楽をどこから届けるか、選びましょう。 + Spotubeはカタログの検索、オーディオのストリーミング、同期歌詞をそれぞれ分離しています。下でエンジンを設定でき、いつでも切り替えられます。 + メタデータエンジン + 音楽の発見、検索、曲リスト、プレイリストを担います。 + オーディオエンジン + 再生するオーディオストリームを取得してデコードします。 + 歌詞エンジン + 時間同期されたカラオケ歌詞をリアルタイムで表示します。 + Scrobbleエンジン + 再生履歴をLast.fmやListenBrainzに記録します。 + 有効 + 内蔵 + 準備完了 + 任意 + エンジンを選択 + インストール済みのエンジン + デフォルトに設定 + NewPipe YouTubeとLRCLib Lyricsは内蔵されており、すぐにストリーミングできます。 + コミュニティプラグインの管理や追加は、いつでも「設定 → プラグイン」から行えます。 + + + レトロモダンなアーキテクチャ + 純粋なHi-Fi、仲介者ゼロ + Spotubeがあなたのマシン上で分散型の再生を直接制御する仕組みをご紹介します。 + ダイレクトP2P • 256 KBPS OPUS + ブルーアワー + Spotube · プレーヤーのデモ + 合間のひとときに、少しの音楽を。 + 1. 分散型の検索 + 検索クエリは選択したカタログプラグインに直接送られます。Spotubeがあなたの好みをプロファイリングすることはありません。 + 2. ストリームを直接解決 + オーディオはコンテンツサーバーからあなたのマシンへ直接ストリーミングされます。プロキシの経由やリモートでのデータ収集はありません。 + 3. ローカルDSPと再生 + ローカルでデコード・キャッシュされ、イコライザーやオフライン用ダウンロード (任意) も使えます。 + 4. 同期歌詞とジャム + カラオケ歌詞が再生位置に合わせて流れ、LANでのリモート操作やグループジャムにも対応します。 + 仲介サーバーはありません。再生はすべてあなたのデバイス上で行われ、設計段階からプライバシーを守ります。 + + + デバイスを選択 + %1$s:「%2$s」 + 再生 + キューに追加 + 次に再生 + このデバイス + このデバイスで + リモートデバイス + リモートデバイス (%1$s) + 接続中のデバイスで + + + 接続に失敗しました + 接続が切断されました + 送信に失敗しました + 検出に失敗しました: %1$s + 接続できませんでした: %1$s + + + デバイス + 更新 + 切断 + 接続中... + ネットワーク上のデバイスを検索中... + デバイスが見つかりません + 相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。 + 不明なデバイス + 解決中... + + + リモート操作 + 切断されました + 接続エラー: %1$s + 不明な曲 + 不明なアーティスト + 音量 + キューから削除 + その他のオプション + + + どこで再生しますか? + ジャムセッションが進行中です。このコレクションを再生する場所を選んでください。 + ここで再生 + ジャムに提案 + + + 接続に失敗しました + 接続が切断されました + + + + ジャムのキューに追加しました + + %1$d曲をジャムのキューに追加しました + + + + リモート操作のリクエスト + 「%1$s」がこのデバイスの再生を操作しようとしています。 + 拒否 + 許可 + 常に許可 + + + 検索結果 + すべて + 曲 + ユーザー + 利用できる検索タイプはありません + 入力して検索 + 入力して曲を検索 + 結果が見つかりません + すべて表示 + 検索中 + 曲を読み込み中... + アルバムを読み込み中... + アーティストを読み込み中... + プレイリストを読み込み中... + ユーザーを読み込み中... + アルバムが見つかりません + アーティストが見つかりません + プレイリストが見つかりません + ユーザーが見つかりません + レコード棚を掘り返しています... + 宇宙を探索しています... + スピーカーをウォームアップしています... + レコードのほこりを払っています... + 音楽の神様にお伺いを立てています... + アンテナを調整しています... + レコードをめくっています... + 検索機能を読み込めませんでした + 検索できませんでした + 曲を検索できませんでした + アルバムを検索できませんでした + アーティストを検索できませんでした + プレイリストを検索できませんでした + ユーザーを検索できませんでした + + + ブラウズ + + + 同期 + テキスト + 同期歌詞はありません + テキスト歌詞はありません + + + %1$s (%2$s) + %1$d kbps + %1$s kHz • %2$d ch + メタデータプラグインが選択されていません + + + 戻る + 再試行 + ダイアログを閉じる + 読み込み中... + 不明 + 不明なエラー + + + ユーザー + プレイリスト + + + 申し訳ありません + 現在、一部の機能が正しく動作していません。ご不便をおかけして申し訳ありません。解決に全力で取り組んでいます。もう一度お試しいただくか、下のエラー詳細をご確認ください。 + エラーの詳細 + クリップボードにコピー + ログ全体を表示 + エラーログ全体 + + + シャッフル + シャッフル再生 + フォロー + フォロー解除 + プレイリストを編集 + 作成者: %1$s + + + 曲のオプション + ジャムに追加 + ラジオを開始 + 次に再生 + 次に移動 + キューから削除 + お気に入りから削除 + お気に入りに保存 + プレイリストに追加 + ダウンロード + 共有 + + + 元の順序 + タイトル + アーティスト + アルバム + 再生時間 + 並べ替え + 曲が見つかりません + 絞り込み + 一括操作 + すべてダウンロード + %1$d曲をダウンロード + すべてキューに追加 + %1$d曲をキューに追加 + すべて次に再生 + %1$d曲を次に再生 + すべてプレイリストに追加 + %1$d曲をプレイリストに追加 + すべてジャムに追加 + %1$d曲をジャムに追加 + 他%1$d組 + + + 読み込むURLがありません + ブラウザの履歴を戻る + ブラウザの履歴を進む + + + ブラックリストを絞り込み... + ブラックリストに登録された項目はありません + 「%1$s」に一致するブラックリスト項目はありません + 曲 + 曲 + ブラックリストに追加 + ブラックリストから削除 + + + 曲のメタデータがありません + ストリームURLを解決できませんでした + ストリームURLを解決できませんでした。先に曲を再生してみてください。 + + + 保存した曲 + あなた + + + フォロー中 + 人気曲 + 人気曲を再生 + 人気曲をキューに追加 + 関連アーティスト + 注目のプレイリスト + アーティストの概要を読み込めませんでした + フォロワー数を取得できません + フォロワー %1$sK人 + フォロワー %1$sM人 + フォロワー %1$sB人 + + フォロワー %1$d人 + + + + お気に入りに追加 + お気に入りから削除 + No-op の曲名 + No-op のアーティスト + 不明なアルバム + シーク + 音量 + シャッフルをオン + シャッフルをオフ + 前へ + 次へ + 再生/一時停止 + リピート: オフ + リピート: 1曲 + リピート: すべて + ミュート + ミュート解除 + その他のオプション + プレーヤーのオプション + プレーヤーを折りたたむ + 歌詞を展開 + 再生中 + 歌詞 + 代替ソース + 代替ソース + ダウンロード + ダウンロード済み + ダウンロード中... + ダウンロード済みです + ダウンロードを再試行中... + %1$sをダウンロード中 + %1$s後に再生を一時停止します。 + 選択した時間 (分) が経過したら再生を一時停止します。 + 分 + 1〜600分の間で選択 + タイマーをキャンセル + 開始 + 再生URLは異なるストリーミング設定で解決されました。ハイライトされたストリームが現在の設定に一致しています。有効なURLは次回のストリームリクエスト時に更新されます。 + 一致したオーディオソース + 一致したソースを読み込み中… + ローカルのキャッシュファイルを再生中は、一致するリモートソースは不要です。 + 一致するソースはありません。 + この曲に一致するオーディオソースはキャッシュされていません。 + ストリームの詳細はまだキャッシュされていません。読み込むには先に曲を再生してください。 + 曲の詳細を読み込めませんでした。 + 利用可能なストリーム + この曲はローカルキャッシュから再生されています。この再生ではストリームのバリエーションは使用されません。 + ストリームを読み込み中… + このソースで利用できるストリームはありません。 + 現在選択中のストリーム + ストリーミングのフォーマットと音質の設定に一致 + 有効な再生URL + ローカルキャッシュから再生 + メタデータの曲 + タイトル + ID + アルバム + 一致度 + 外部URI + アルバムID + アルバムの種類 + アルバムのリリース日 + アルバムの曲数 + アルバムのジャンル + 再生時間 + トラック番号 + ディスク番号 + Explicit + 人気度 + ISRC + サムネイルURL + + + サイドバーの表示切り替え + + + 代替ソース + 再生中の曲はありません + 代替ソースを検索中... + 代替ソースが見つかりません + 一致度: %1$d% + 使用中のソース + + + キック + セッションから退出させます + キックして再参加できないようにします + 提案を削除 + このユーザーがキューに追加した曲をすべて削除します + 並べ替え + キューから削除 + %1$s • %2$sが追加 + + + 不明 + プレイリストを読み込み中... + 曲を追加 + プレイリストに曲を追加 + 曲を検索... + 検索 + 曲が見つかりません + 追加する曲を検索 + 完了 + 追加 + 追加済み + + + アルバムを読み込み中... + + + プレイリストに追加 + 検索 + プレイリストを検索... + プレイリストを読み込み中... + 所有しているプレイリストはまだありません。\nプレイリストを作成して曲を追加しましょう。 + プレイリストを作成 + プレイリストを編集 + 作成 + 名前 + プレイリスト名 + 説明 + 説明 (任意) + 公開 + 誰でもこのプレイリストを見られます + 共同編集 + 他のユーザーもこのプレイリストに曲を追加できます + カバー画像 + プレイリストのカバー + 画像なし + 画像を変更 + 画像を選択 + + + インストール + 置き換え + zipファイル内にplugin.jsonが見つかりません + zipファイル内にmanifest.zipline.jsonが見つかりません + plugin.jsonの形式が無効です: %1$s + プラグインを読み込めませんでした: %1$s + プラグインをインストールできませんでした: %1$s + %1$sの新しいバージョンがあります。インストールする前にアップデートを確認してください。 + %1$sはすでにインストールされています。インストールする前に置き換えを確認してください。 + 内蔵プラグインはすでに含まれているため、再度追加することはできません。 + 内蔵プラグインは削除できません。 + %1$sで宣言されたapiVersion (%2$s) が無効です。%3$sと互換性のあるセマンティックバージョニングが必要です。 + SpotubeのプラグインAPIバージョン %1$s が無効です。 + %1$sはプラグインAPI %2$s を対象としていますが、SpotubeがサポートするのはプラグインAPI %3$s (破壊的変更を含むバージョン) です。同じメジャーAPIバージョン向けにビルドされたプラグインをインストールしてください。 + + + プラグインAPIに互換性がありません + プラグインをインストールしますか? + %1$sがインストール済みプラグインに追加されます。 + プラグインをアップデートしますか? + 古いバージョン (%1$s) がインストールされています。%2$sにアップデートしますか? + インストール済みのプラグインを置き換えますか? + %1$sは無効なセマンティックバージョン (%2$s) を使用しているため、アップデートとして比較できません。それでもインストール済みのプラグインを置き換えますか? + インストール済みのバージョン (%1$s) はセマンティックバージョニングで比較できません。%2$sで置き換えますか? + バージョン %1$s はすでにインストールされています。既存のプラグインを提供されたものに置き換えますか? + インストール済みのバージョン %1$s は提供されたバージョン %2$s より新しいです。それでも置き換えますか? + インストール済みのプラグインを提供されたものに置き換えますか? + + + %1$s: + バージョン + APIバージョン + 作者 + ライセンス + 権限 + 機能 + リポジトリ + 連絡先 + バグを報告 + %1$sを支援 + + + 公式 + 認証済み + GitHubのスター + GitHubリポジトリのURL + + + リリースが見つかりません + リリースを選択 + リリースを選択してください + プレリリース + リリースノート + + + ホーム + 検索 + デバイス内 + Connect + デバイス + + + ウィンドウを隠す + ウィンドウを表示 + 次の曲 + 前の曲 + シャッフル + リピート + リピート: オフ + リピート: 1曲 + リピート: すべて + 音量: %1$d% + 音量 + + 音量 - + ミュート + ミュート解除 + お気に入りに追加 + お気に入りから削除 + 終了 + + + 最小化 + 最大化 + 元に戻す + 閉じる + + + 再生 + メディア再生コントロール + + + ブラウズ + 再生中 + 注目 + 保存した曲 + 人気曲 + diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml new file mode 100644 index 00000000..39b8854c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -0,0 +1,920 @@ + + + + 대기열 + 대기열 비우기 + 대기열 필터... + 대기열이 비어 있어요 + + 노래, 아티스트, 앨범 검색... + 기록 모두 지우기 + 검색... + + + 내 라이브러리 + 라이브러리 + 플레이리스트 + 앨범 + 아티스트 + 로컬 트랙 + 다운로드 + 저장한 플레이리스트 검색… + 저장한 앨범 검색… + 저장한 아티스트 검색… + 폴더 또는 트랙 검색… + 라이브러리 필터 + 필터 닫기 + 플레이리스트 만들기 + 로컬 트랙 새로고침 + 그리드 보기로 전환 + 목록 보기로 전환 + 저장한 플레이리스트가 없어요 + “%1$s”와(과) 일치하는 플레이리스트가 없어요 + 저장한 앨범이 없어요 + “%1$s”와(과) 일치하는 앨범이 없어요 + 저장한 아티스트가 없어요 + “%1$s”와(과) 일치하는 아티스트가 없어요 + + + 폴더 + 폴더 + %1$s • %2$s + + 트랙 %1$d개 + + + 폴더 %1$d개 + + 폴더로 돌아가기 + 폴더 재생 + 오디오 접근 권한 필요 + 기기의 음악을 둘러보려면 오디오 접근을 허용하세요. + 접근 허용 + 로컬 폴더를 찾을 수 없어요 + 이 폴더에 트랙이 없어요 + 일치하는 폴더나 트랙이 없어요 + 로컬 미디어를 새로고침하지 못했어요 + + + 진행 중 %1$d개 • 완료 %2$d개 + 완료된 항목 지우기 + 아직 다운로드한 항목이 없어요 + 다운로드 중 %1$d% + 대기 중 + 완료됨 + 취소됨 + 실패 + 실패: %1$s + 다운로드 취소 + 다운로드 다시 시도 + 다운로드 삭제 + + + 아티스트 + 차단됨 + 좋아요 표시한 곡 + 저장한 노래 + 알 수 없는 아티스트 + 트랙 제목 + 아티스트 이름 + + 선택됨 + + 설정 + + 플러그인 + 언어 및 지역 + 화면 스타일 + 재생 + 다운로드 + 캐시 + 데스크톱 + 업데이트 + 후원 + 정보 + + 닫기 + 저장 + 취소 + + 언어 + 앱 인터페이스 언어입니다. 현재: %1$s + 시스템 기본값 + 국가 + 콘텐츠 및 이용 가능 여부에 적용되는 국가입니다. 현재: %1$s + %1$s (%2$s) + + 테마 + Spotube가 시스템 화면 모드를 따르는 방식을 선택하세요. 현재: %1$s + 라이트 + 다크 + 시스템 + + 표면 + 앱의 배경 재질입니다. 현재: %1$s + 물결 + 얼음 + 크롬 + 아크릴 + 유리 + + 강조 색상 + 현재 팔레트: %1$s + 강조 색상 + 강조 팔레트를 선택하고 라이트 및 다크 테마에서 미리 확인하세요. + 라이트 + 다크 + 미리보기 + 버튼 + 그린 고블린 + 일렉트릭 바이올렛 + 오셔닉 시안 + 선셋 오렌지 + 로즈 가든 + 미드나이트 블루 + 메탈릭 슬레이트 + + 디자인 & 스타일 + 표면 재질과 강조 색상. 현재: %1$s · %2$s + 디자인 & 스타일 + 표면 재질을 고른 다음 어울리는 강조 색상을 선택하세요. 모든 옵션은 엄선된 조합이에요. + 표면 + 강조 색상 + 보조 + + 팔레트 + Millennium 강조 팔레트. 현재: %1$s + 팔레트 + Millennium 강조 팔레트를 선택하고 라이트 및 다크 모드에서 미리 확인하세요. + 팔레트 + Millennium 팔레트 및 강조 색상. 현재: %1$s + 블루 + 오렌지 + 라임 + + 다운로드 폴더 지정 + 현재 폴더: %1$s + 기본 음악 폴더를 사용하려면 비워 두세요. + C:\Music\Spotube + 로컬 미디어 폴더 + 데스크톱에서 검색할 사용자 지정 폴더. 추가됨: %1$d + 아직 추가한 사용자 지정 폴더가 없어요 + 로컬 음악 라이브러리용 사용자 지정 폴더입니다. + 관리 + 폴더 추가 + 폴더 삭제 + 추가한 사용자 지정 폴더가 없습니다. + 검색 목록에서 %1$s 삭제 + 다운로드 폴더와 음악 캐시 폴더는 자동으로 포함돼요. + 다운로드 폴더는 자동으로 포함돼요. + 음악 캐시 폴더는 자동으로 포함돼요. + 저장 중… + 폴더를 저장하지 못했어요. 변경 사항은 그대로 남아 있으니 다시 시도하세요. + + 폴더 %1$d개 + + 다운로드 음악 형식 + 다운로드에 사용할 코덱 및 컨테이너. 현재: %1$s + 다운로드 음질 + + 스트리밍 음악 형식 + 음악 스트리밍에 사용할 코덱 및 컨테이너. 현재: %1$s + 스트리밍 음질 + 현재: %1$s + 음악 캐시 사용 + 스트리밍한 오디오를 기기에 저장해 다시 재생할 때 더 빠르게 불러와요. + 캐시 폴더 + 현재: %1$s + 현재: 기본 앱 캐시 + 캐시 용량 제한 + 제한 없음 + 현재 제한: %1$d MB + 최대 캐시 용량을 MB 단위로 설정하세요. 제한을 두지 않으려면 0을 입력하거나 비워 두세요. + 500 + 용량은 0 또는 양수여야 해요. + 끝없는 재생 사용 + 대기열이 끝나면 추천 트랙으로 계속 재생해요. + 재생 프록시 서버 포트 + 원격 제어 허용 + 같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요. + 원격 제어 기기 이름 + 다른 기기가 이 기기를 찾을 때 표시되는 이름입니다. 현재: %1$s + 네트워크의 다른 기기가 이 Spotube를 찾을 때 표시할 이름을 정하세요. + 예: 거실 Spotube + 기기 호스트 이름 + 재생 프록시 서버에서 사용하는 포트입니다. 현재: %1$d + 1에서 65535 사이의 포트를 선택하세요. + 14769 + 올바른 정수를 입력하세요. + 포트는 1에서 65535 사이여야 해요. + + 차단 목록 + 차단한 트랙 및 아티스트 관리 + + 트레이로 최소화 + 창을 최소화하면 Spotube를 시스템 트레이에서 계속 실행해요. + Discord Rich Presence + 데스크톱에서 현재 재생 상태를 Discord에 표시해요. + + 자동으로 업데이트 확인 + 백그라운드에서 Spotube 새 버전을 확인해요. + + 플러그인 관리 + 플러그인을 관리하세요 + 기본 %1$s 플러그인 + 메타데이터 + 오디오 + 가사 + 스크로블 + 선택한 플러그인 없음 + 변경 + 선택 + 선택됨 + 선택 해제 + 사용 가능한 플러그인이 없어요 + %1$s 플러그인 + 지우기 + + 구성 + 플러그인 관리자 + URL을 입력하세요 + URL은 http:// 또는 https://로 시작해야 해요 + 플러그인을 다운로드하지 못했어요 + 아직 설치한 플러그인이 없어요 + 위에 다운로드 URL을 붙여넣거나 파일을 선택하세요. + + 플러그인 %1$d개 설치됨 + + 플러그인 설치 + https://example.com/plugin.smplug + 다운로드 + 파일에서 설치(.smplug) + 사용 중 + v%1$s + 기본 제공 + 플러그인 삭제 + 로그인 + 로그아웃 + URL에서 다운로드 + 파일에서 설치 + 설치 + + • %1$s + 요청된 권한 + 이 플러그인에는 특별한 권한이 필요하지 않아요. + 설치된 버전과 제공된 버전 비교 + 설치됨 + 제공됨 + API %1$s → %2$s + 영구 저장소 + 디스크에 데이터 읽기 및 쓰기 + 네트워크 요청 + 인터넷으로 데이터 송수신 + WebView + 앱 안에 웹 콘텐츠 표시 + 그룹 잼 + 연결 테스트 + 테스트 중… + %1$d ms 만에 연결됨 + 실패: %1$s + 연결 + 진단 + + + 그룹 잼 + 만들기 + 참여 + 방을 만든 다음 친구들에게 코드를 공유하세요. + 방 만들기 + 방 참여 + 연결 중… + 방 코드 + ABC123 + 연결됨 + 다시 연결 중… + 코드 복사 + 복사됨! + 공유 + 참여자(%1$d) + 호스트 + 리스너 + 리스너를 기다리는 중… + 내보내기 + 차단 + 관리 + 방 나가기 + 연결할 수 없어요 + 닫기 + 잼 세션을 만들지 못했어요 + 잼 세션에 참여하지 못했어요 + 영문자와 숫자로 된 6자리 방 코드를 입력하세요. + 내 Spotube 잼에 참여하세요. 코드: %1$s + 방 서버 + 서버 URL + https://로 시작하는 URL을 입력하세요. + 방을 시작하기 전에 방 서버를 확인하세요. + 잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 github.com/team-spotube/jam-server의 서버를 배포하고 URL을 입력하세요. + 나 + 서버: %1$s + 방을 만들거나 참여하려면 설정에서 방 서버를 지정하세요. + 서버 설정 + %1$s (나) + 내 이름 + 잼의 다른 사람들에게 %1$s(으)로 표시됩니다 + 잼의 모든 사람에게 표시됩니다. 비워 두면 %1$s을(를) 사용합니다. 새 이름은 다음에 방을 만들거나 참여할 때부터 적용됩니다. + 최대 %1$d자까지 입력하세요. + 설정된 잼 서버가 없습니다 + 잼 서버에 연결할 수 없습니다. 네트워크 또는 설정 → 그룹 잼의 서버 URL을 확인하세요. + 잼 서버의 버전이 호환되지 않습니다. + 해당 코드의 방이 없습니다. 코드를 확인하고 다시 시도하세요. + 이 방은 가득 찼습니다. + 호스트가 이 방에서 나를 차단했습니다. + 호스트가 나를 방에서 내보냈습니다. + 이 기기에서 이 방이 다시 열려 이 연결이 종료되었습니다. + + 앨범으로 이동 + 취침 타이머 + 트랙 세부정보 + 재생 + 일시정지 + 재생 중인 항목 없음 + 대기열에 추가 + + 무시 + 업데이트 + Spotube %1$s 버전을 사용할 수 있어요 + 이번 릴리스의 새로운 기능은 Spotube 다운로드 페이지에서 확인하세요. + %1$s %2$s 버전을 사용할 수 있어요 + 현재 설치된 버전: %1$s + 이번 업데이트의 새로운 기능은 플러그인 페이지에서 확인하세요. + + 건너뛰기 + 다음 + 뒤로 + 시작하기 + Spotube + 아무런 조건 없는 음악. + 음악의 세계를 위한 오픈 소스 플레이어.\n마음껏 탐색하고, 나만의 방식으로 꾸며 보세요. + 이 프로젝트가 계속될 수 있도록 도와주세요. + Spotube는 구독 비즈니스가 아닌 사람들이 만들어요. Spotube가 일상의 일부가 되었다면, 작은 후원이 Spotube를 더 정성껏 가꾸는 데 큰 힘이 돼요.\n\n독립 소프트웨어에 자리를 내어 주셔서 감사합니다. + 먼저 카탈로그 플러그인으로 노래와 플레이리스트를 찾으세요. 재생하려면 오디오 플러그인을 선택하세요. 가사와 감상 기록은 선택 사항이에요. 아래에서 플러그인을 찾아 권한을 확인하고 설치한 다음 여기서 선택하세요. + 플러그인 둘러보기 + 내 소스 + 다시 시도 + 더 많은 플러그인 + 새 플러그인을 찾지 못했어요. 위에서 링크나 .smplug 파일로 설치할 수도 있어요. + 지금은 플러그인 둘러보기를 사용할 수 없어요. 다시 시도하거나 링크 또는 파일로 설치하세요. + 환영합니다 + 후원 + 플러그인 + 가이드 + + Spotube 사용 방법 + 간단한 네 단계면 바로 감상할 수 있어요. + 로그인 + 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요. + 검색하고 재생하기 + 노래, 앨범, 아티스트를 찾은 다음 재생을 누르세요. + 오프라인으로 감상하기 + 트랙을 다운로드하면 인터넷 연결 없이도 계속 재생할 수 있어요. + 가사 따라가기 + 트랙이 재생되는 동안 플레이어에서 싱크 가사를 열어 보세요. + + + 나의 음악. 나의 소스. 나의 규칙. + 음악을 사랑하는 커뮤니티가 만든 무료 오픈 소스 음악 플레이어. + 오픈 소스 + 광고 없음 + 원격 측정 없음 + 플러그인 기반 + 시작해요 + + + 후원자 덕분에 계속할 수 있어요 + 열정으로 만들고, 여러분 덕분에 이어 갑니다. + Spotube는 100% 무료이자 자유 오픈 소스 소프트웨어예요 — 광고도, 구독도, 추적도 없어요. 모든 기능은 여러분 같은 분들이 코드가 계속 이어지도록 도와주신 덕분에 존재해요. + Patreon + 월간 후원 + 지속적인 개발과 인프라를 지원해 주세요 + Buy Me a Coffee + 커피 한 잔 후원 + 간단한 팁으로 늦은 밤 코딩에 힘을 보태 주세요 + Open Collective + 커뮤니티 기금 + 투명하게 커뮤니티가 운영하는 재정 후원 + 열린 음악을 믿어 주셔서 진심으로 감사합니다. + 후원은 전적으로 선택 사항이에요 — 어떤 경우든 Spotube는 계속 무료예요. + 후원하기 + %1$s 열기 + + + Spotube 후원하기 + Spotube 후원하기 + Spotube는 무료이자 자유 오픈 소스 소프트웨어예요 — 광고도, 구독도, 추적도 없어요. Spotube가 즐거움을 준다면 프로젝트 후원을 고려해 주세요. + 이 알림은 설정 → 후원에서 끌 수 있어요. + 나중에 + 후원 알림 + 7일마다 프로젝트 후원 알림을 가볍게 표시해요. + + + 모듈식 구조 + 음악을 어디서 가져올지 선택하세요. + Spotube는 카탈로그 탐색, 오디오 스트리밍, 싱크 가사를 분리해서 처리해요. 아래에서 엔진을 구성하거나 언제든지 바꿀 수 있어요. + 메타데이터 엔진 + 음악 탐색, 검색, 트랙 목록, 플레이리스트를 담당해요. + 오디오 엔진 + 재생할 오디오 스트림을 가져오고 디코딩해요. + 가사 엔진 + 시간에 맞춘 노래방 가사를 실시간으로 보여 줘요. + 스크로블 엔진 + 감상 기록을 Last.fm 또는 ListenBrainz에 기록해요. + 사용 중 + 기본 제공 + 준비됨 + 선택 사항 + 엔진 선택 + 설치된 엔진 + 기본값으로 설정 + NewPipe YouTube와 LRCLib Lyrics는 기본 제공되며 바로 스트리밍할 수 있어요. + 설정 → 플러그인에서 언제든지 커뮤니티 플러그인을 관리하거나 추가할 수 있어요. + + + 레트로 모던 아키텍처 + 순수한 하이파이, 중개자는 없어요 + Spotube가 기기에서 직접 분산형 재생을 처리하는 방법은 다음과 같아요. + P2P 직접 연결 • 256 KBPS OPUS + 블루 아워 + Spotube · 플레이어 데모 + 그 사이의 순간들을 위한 작은 음악. + 1. 분산형 탐색 + 검색어는 선택한 카탈로그 플러그인으로 바로 전달돼요. Spotube는 취향을 분석하지 않아요. + 2. 직접 스트림 확인 + 오디오는 콘텐츠 서버에서 기기로 바로 스트리밍돼요. 프록시 경유나 원격 데이터 수집은 없어요. + 3. 로컬 DSP & 재생 + 이퀄라이저 컨트롤과 선택적 오프라인 다운로드를 지원하며 기기에서 디코딩하고 캐시해요. + 4. 싱크 가사 & 잼 + 노래방 가사가 재생 시간에 맞춰 흐르고, LAN 원격 제어와 그룹 잼도 지원해요. + 중개 서버가 없어요. 재생은 기기에서 직접 이루어져요 — 설계부터 개인정보를 보호해요. + + + 기기 선택 + %1$s: “%2$s” + 재생 + 대기열에 추가 + 다음에 재생 + 이 기기 + 이 기기에서 + 원격 기기 + 원격 기기(%1$s) + 연결된 기기에서 + + + 연결 실패 + 연결 끊김 + 전송 실패 + 기기 검색 실패: %1$s + 연결하지 못했어요: %1$s + + + 기기 + 새로고침 + 연결 해제 + 연결 중... + 네트워크에서 기기를 찾는 중... + 기기를 찾을 수 없어요 + 상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요. + 알 수 없는 기기 + 확인 중... + + + 원격 제어 + 연결 끊김 + 연결 오류: %1$s + 알 수 없는 트랙 + 알 수 없는 아티스트 + 볼륨 + 대기열에서 삭제 + 옵션 더보기 + + + 어디에서 재생할까요? + 진행 중인 잼 세션이 있어요. 이 컬렉션을 재생할 위치를 선택하세요. + 여기에서 재생 + 잼에 추천하기 + + + 연결 실패 + 연결 끊김 + + + + 잼 대기열에 추가했어요 + + 잼 대기열에 %1$d곡을 추가했어요 + + + + 원격 제어 요청 + “%1$s”에서 이 기기의 재생을 제어하려고 해요. + 거부 + 허용 + 항상 허용 + + + 검색 결과 + 전체 + 트랙 + 사용자 + 사용 가능한 검색 유형이 없어요 + 입력하여 검색하세요 + 입력하여 트랙을 검색하세요 + 검색 결과가 없어요 + 모두 보기 + 검색 중 + 트랙 불러오는 중... + 앨범 불러오는 중... + 아티스트 불러오는 중... + 플레이리스트 불러오는 중... + 사용자 불러오는 중... + 앨범을 찾을 수 없어요 + 아티스트를 찾을 수 없어요 + 플레이리스트를 찾을 수 없어요 + 사용자를 찾을 수 없어요 + 레코드 상자를 뒤지는 중... + 우주를 탐색하는 중... + 스피커 예열하는 중... + 바이닐 먼지 터는 중... + 음악의 신께 여쭤보는 중... + 안테나 맞추는 중... + 레코드를 넘겨보는 중... + 검색 기능을 불러오지 못했어요 + 검색하지 못했어요 + 트랙을 검색하지 못했어요 + 앨범을 검색하지 못했어요 + 아티스트를 검색하지 못했어요 + 플레이리스트를 검색하지 못했어요 + 사용자를 검색하지 못했어요 + + + 둘러보기 + + + 싱크 + 일반 + 싱크 가사가 없어요 + 일반 가사가 없어요 + + + %2$s 컨테이너의 %1$s + %1$d kbps + %1$s kHz • %2$d ch + 선택한 메타데이터 플러그인이 없어요 + + + 뒤로 + 다시 시도 + 대화상자 닫기 + 불러오는 중... + 알 수 없음 + 알 수 없는 오류 + + + 사용자 + 플레이리스트 + + + 정말 죄송합니다 + 지금 문제가 발생했어요. 여러분의 경험은 저희에게 소중하며, 문제를 해결하기 위해 최선을 다하고 있어요. 다시 시도하거나 아래 오류 세부정보를 확인하세요. + 오류 세부정보 + 클립보드에 복사 + 전체 로그 보기 + 전체 오류 로그 + + + 셔플 + 셔플 재생 + 팔로우 + 팔로우 취소 + 플레이리스트 편집 + 만든 사람: %1$s + + + 트랙 옵션 + 잼에 추가 + 라디오 시작 + 다음에 재생 + 다음 곡으로 이동 + 대기열에서 삭제 + 즐겨찾기에서 삭제 + 즐겨찾기에 저장 + 플레이리스트에 추가 + 다운로드 + 공유 + + + 기본 순서 + 제목 + 아티스트 + 앨범 + 재생 시간 + 정렬 + 트랙을 찾을 수 없어요 + 필터 + 일괄 작업 + 모두 다운로드 + %1$d곡 다운로드 + 모두 대기열에 추가 + %1$d곡 대기열에 추가 + 모두 다음에 재생 + %1$d곡 다음에 재생 + 모두 플레이리스트에 추가 + %1$d곡 플레이리스트에 추가 + 모두 잼에 추가 + %1$d곡 잼에 추가 + 외 %1$d명 + + + 불러올 URL이 없어요 + 브라우저 기록에서 뒤로 이동 + 브라우저 기록에서 앞으로 이동 + + + 차단 목록 필터... + 차단한 항목이 없어요 + ‘%1$s’와(과) 일치하는 차단 항목이 없어요 + 트랙 + 트랙 + 차단 목록에 추가 + 차단 목록에서 삭제 + + + 트랙 메타데이터 없음 + 스트림 URL을 확인할 수 없어요 + 스트림 URL을 확인할 수 없어요. 먼저 트랙을 재생해 보세요. + + + 저장한 트랙 + 나 + + + 팔로잉 + 인기 트랙 + 인기 트랙 재생 + 인기 트랙을 대기열에 추가 + 관련 아티스트 + 추천 플레이리스트 + 아티스트 개요를 불러오지 못했어요 + 팔로워 정보 없음 + 팔로워 %1$sK명 + 팔로워 %1$sM명 + 팔로워 %1$sB명 + + 팔로워 %1$d명 + + + + 좋아요 + 좋아요 취소 + 트랙 제목 없음 + 아티스트 없음 + 알 수 없는 앨범 + 탐색 + 볼륨 + 셔플 사용 + 셔플 사용 안 함 + 이전 + 다음 + 재생 또는 일시정지 + 반복 끄기 + 한 곡 반복 + 전체 반복 + 음소거 + 음소거 해제 + 옵션 더보기 + 플레이어 옵션 + 플레이어 접기 + 가사 펼치기 + 지금 재생 중 + 가사 + 대체 소스 + 대체 소스 + 다운로드 + 다운로드 완료 + 다운로드 중... + 이미 다운로드됨 + 다운로드 다시 시도 중... + %1$s 다운로드 중 + %1$s 후 재생이 일시정지돼요. + 선택한 시간(분)이 지나면 재생을 일시정지해요. + 분 + 1~600분 사이에서 선택하세요 + 타이머 취소 + 시작 + 재생 URL이 다른 스트리밍 설정으로 확인되었어요. 강조 표시된 스트림이 현재 설정과 일치하며, 사용 중인 URL은 다음 스트림 요청 시 새로고침돼요. + 일치하는 오디오 소스 + 일치하는 소스 불러오는 중… + 로컬 캐시 파일을 재생하는 동안에는 일치하는 원격 소스가 필요하지 않아요. + 일치하는 소스가 없어요. + 이 트랙에 캐시된 일치 오디오 소스가 없어요. + 스트림 정보가 아직 캐시되지 않았어요. 먼저 트랙을 재생해 불러오세요. + 트랙 세부정보를 불러오지 못했어요. + 사용 가능한 스트림 + 이 트랙은 로컬 캐시에서 재생 중이에요. 이번 재생에는 스트림 변형이 사용되지 않아요. + 스트림 불러오는 중… + 이 소스에 사용 가능한 스트림이 없어요. + 현재 선택된 스트림 + 스트리밍 형식 & 음질 설정과 일치 + 현재 재생 URL + 로컬 캐시에서 재생 중 + 메타데이터 트랙 + 제목 + ID + 앨범 + 일치도 + 외부 URI + 앨범 ID + 앨범 유형 + 앨범 발매일 + 앨범 트랙 수 + 앨범 장르 + 재생 시간 + 트랙 번호 + 디스크 번호 + 청소년 이용불가 + 인기도 + ISRC + 썸네일 URL + + + 사이드바 전환 + + + 대체 소스 + 현재 재생 중인 트랙이 없어요 + 대체 소스를 찾는 중... + 대체 소스를 찾을 수 없어요 + 일치도: %1$d% + 현재 소스 + + + 내보내기 + 세션에서 내보내요 + 내보내고 다시 참여하지 못하게 해요 + 추천곡 삭제 + 이 참여자가 대기열에 추가한 모든 트랙을 삭제해요 + 순서 변경 + 대기열에서 삭제 + %1$s • %2$s님이 추가함 + + + 알 수 없음 + 플레이리스트 불러오는 중... + 트랙 추가 + 플레이리스트에 트랙 추가 + 트랙 검색... + 검색 + 트랙을 찾을 수 없어요 + 추가할 트랙을 검색하세요 + 완료 + 추가 + 추가됨 + + + 앨범 불러오는 중... + + + 내 플레이리스트에 추가 + 검색 + 내 플레이리스트 검색... + 내 플레이리스트 불러오는 중... + 아직 소유한 플레이리스트가 없어요.\n플레이리스트를 만들어 트랙을 추가하세요. + 플레이리스트 만들기 + 플레이리스트 편집 + 만들기 + 이름 + 플레이리스트 이름 + 설명 + 설명(선택 사항) + 공개 + 누구나 이 플레이리스트를 볼 수 있어요 + 공동 작업 + 다른 사람도 이 플레이리스트에 트랙을 추가할 수 있어요 + 커버 이미지 + 플레이리스트 커버 + 이미지 없음 + 이미지 변경 + 이미지 선택 + + + 설치 + 교체 + zip 파일에서 plugin.json을 찾을 수 없어요 + zip 파일에서 manifest.zipline.json을 찾을 수 없어요 + plugin.json 형식이 올바르지 않아요: %1$s + 플러그인을 읽지 못했어요: %1$s + 플러그인을 설치하지 못했어요: %1$s + %1$s의 새 버전을 사용할 수 있어요. 설치하기 전에 업데이트를 확인하세요. + %1$s이(가) 이미 설치되어 있어요. 설치하기 전에 교체를 확인하세요. + 기본 제공 플러그인은 이미 포함되어 있어 다시 추가할 수 없어요. + 기본 제공 플러그인은 삭제할 수 없어요. + %1$s에 잘못된 apiVersion(%2$s)이 선언되어 있어요. %3$s과(와) 호환되는 유의적 버전이 필요해요. + Spotube 플러그인 API 버전 %1$s이(가) 올바르지 않아요. + %1$s은(는) 플러그인 API %2$s용이지만, Spotube는 호환성이 깨지는 플러그인 API %3$s을(를) 지원해요. 같은 주 API 버전용으로 빌드된 플러그인을 설치하세요. + + + 호환되지 않는 플러그인 API + 플러그인을 설치할까요? + %1$s이(가) 설치된 플러그인에 추가돼요. + 플러그인을 업데이트할까요? + 이전 버전(%1$s)이 설치되어 있어요. %2$s(으)로 업데이트할까요? + 설치된 플러그인을 교체할까요? + %1$s은(는) 잘못된 유의적 버전(%2$s)을 사용하므로 업데이트로 비교할 수 없어요. 그래도 설치된 플러그인을 교체할까요? + 설치된 버전(%1$s)은 유의적 버전으로 비교할 수 없어요. %2$s(으)로 교체할까요? + 버전 %1$s이(가) 이미 설치되어 있어요. 기존 플러그인을 제공된 사본으로 교체할까요? + 설치된 버전 %1$s이(가) 제공된 버전 %2$s보다 최신이에요. 그래도 교체할까요? + 설치된 플러그인을 제공된 사본으로 교체할까요? + + + %1$s: + 버전 + API 버전 + 제작자 + 라이선스 + 권한 + 기능 + 저장소 + 연락처 + 버그 신고 + %1$s 후원하기 + + + 공식 + 인증됨 + GitHub 스타 + GitHub 저장소 URL + + + 릴리스를 찾을 수 없어요 + 릴리스 선택 + 릴리스를 선택하세요 + 사전 릴리스 + 릴리스 노트 + + + 홈 + 검색 + 이 기기 + 연결 + 기기 + + + 창 숨기기 + 창 표시 + 다음 트랙 + 이전 트랙 + 셔플 + 반복 + 반복: 끔 + 반복: 한 곡 + 반복: 전체 + 볼륨: %1$d% + 볼륨 + + 볼륨 - + 음소거 + 음소거 해제 + 트랙 좋아요 + 트랙 좋아요 취소 + 종료 + + + 최소화 + 최대화 + 이전 크기로 복원 + 닫기 + + + 재생 + 미디어 재생 컨트롤 + + + 둘러보기 + 지금 재생 중 + 추천 + 저장한 트랙 + 인기 트랙 + diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml new file mode 100644 index 00000000..e3520c74 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -0,0 +1,926 @@ + + + + Wachtrij + Wachtrij wissen + Wachtrij filteren... + Wachtrij is leeg + + Zoek nummers, artiesten, albums... + Alle geschiedenis wissen + Zoeken... + + + Jouw bibliotheek + Bibliotheek + Playlists + Albums + Artiesten + Lokale nummers + Downloads + Zoek in opgeslagen playlists… + Zoek in opgeslagen albums… + Zoek in opgeslagen artiesten… + Zoek mappen of nummers… + Bibliotheek filteren + Filter sluiten + Playlist maken + Lokale nummers vernieuwen + Rasterweergave + Lijstweergave + Geen opgeslagen playlists + Geen playlists gevonden voor “%1$s” + Geen opgeslagen albums + Geen albums gevonden voor “%1$s” + Geen opgeslagen artiesten + Geen artiesten gevonden voor “%1$s” + + + Mappen + Map + %1$s • %2$s + + %1$d nummer + %1$d nummers + + + %1$d map + %1$d mappen + + Terug naar mappen + Map afspelen + Toegang tot audio vereist + Geef toegang tot audio om muziek op je apparaat te bekijken. + Toegang geven + Geen lokale mappen gevonden + Geen nummers in deze map + Geen overeenkomende mappen of nummers + Lokale media vernieuwen mislukt + + + %1$d actief • %2$d voltooid + Voltooide wissen + Nog geen downloads + Downloaden %1$d% + In wachtrij + Voltooid + Geannuleerd + Mislukt + Mislukt: %1$s + Download annuleren + Download opnieuw proberen + Download verwijderen + + + Artiest + Op zwarte lijst + Gelikete nummers + Je opgeslagen nummers + Onbekende artiest + Titel van nummer + Naam van artiest + + Geselecteerd + + Instellingen + + Plug-ins + Taal en regio + Weergave + Afspelen + Downloads + Cache + Desktop + Updates + Steunen + Over + + Sluiten + Opslaan + Annuleren + + Taal + Taal van de app. Huidig: %1$s + Systeemstandaard + Land + Land voor content en beschikbaarheid. Huidig: %1$s + %1$s (%2$s) + + Thema + Kies hoe Spotube aansluit op de weergave van je systeem. Huidig: %1$s + Licht + Donker + Systeem + + Oppervlak + Achtergrondmateriaal van de app. Huidig: %1$s + Waterig + IJzig + Chroom + Acryl + Glas + + Accentkleur + Huidig palet: %1$s + Accentkleur + Kies een accentpalet en bekijk het in zowel het lichte als het donkere thema. + Licht + Donker + Voorbeeld + Knop + Groene kobold + Elektrisch violet + Oceaancyaan + Zonsondergangoranje + Rozentuin + Middernachtblauw + Metaalgrijs + + Uiterlijk + Oppervlaktemateriaal en accent. Huidig: %1$s · %2$s + Uiterlijk + Kies een oppervlaktemateriaal en daarna een accent dat erbij past. Elke optie is een zorgvuldig gekozen combinatie. + Oppervlak + Accent + Secundair + + Palet + Millennium-accentpalet. Huidig: %1$s + Palet + Kies een Millennium-accentpalet en bekijk het in licht en donker. + Palet + Millennium-palet en accent. Huidig: %1$s + Blauw + Oranje + Limoen + + Andere downloadmap + Huidige map: %1$s + Laat dit leeg om de standaard muziekmap te gebruiken. + C:\Music\Spotube + Lokale mediamappen + Eigen mappen om te scannen op desktop. Toegevoegd: %1$d + Nog geen eigen map toegevoegd + Eigen mappen voor je lokale muziekbibliotheek. + Beheren + Map toevoegen + Map verwijderen + Geen eigen mappen toegevoegd. + %1$s verwijderen uit de scanlijst + De download- en muziekcachemappen worden automatisch meegenomen. + Je downloadmap wordt automatisch meegenomen. + De muziekcachemap wordt automatisch meegenomen. + Opslaan… + Kan mappen niet opslaan. Je wijzigingen zijn bewaard; probeer het opnieuw. + + %1$d map + %1$d mappen + + Downloadformaat + Voorkeurscodec en -container voor downloads. Huidig: %1$s + Downloadkwaliteit + + Streamingformaat + Voorkeurscodec en -container voor het streamen van muziek. Huidig: %1$s + Streamingkwaliteit + Huidig: %1$s + Muziekcache inschakelen + Gestreamde audio lokaal opslaan zodat herhaald afspelen sneller gaat. + Cachemap + Huidig: %1$s + Huidig: standaard app-cache + Maximale cachegrootte + Geen limiet + Huidige limiet: %1$d MB + Stel de maximale cachegrootte in MB in. Vul 0 in of laat leeg voor geen limiet. + 500 + Grootte moet 0 of een positief getal zijn. + Eindeloos afspelen inschakelen + Blijf afspelen met aanbevolen nummers als je wachtrij leeg is. + Poort van afspeelproxyserver + Bediening op afstand toestaan + Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken. + Apparaatnaam voor bediening op afstand + De naam die andere apparaten zien als ze dit apparaat vinden. Huidig: %1$s + Kies een herkenbare naam die andere apparaten op je netwerk te zien krijgen als ze deze Spotube-installatie vinden. + bijv. Spotube woonkamer + hostnaam van apparaat + Poort die de afspeelproxyserver gebruikt. Huidig: %1$d + Kies een poort tussen 1 en 65535. + 14769 + Voer een geldig geheel getal in. + Poort moet tussen 1 en 65535 liggen. + + Zwarte lijst + Nummers en artiesten op de zwarte lijst beheren + + Minimaliseren naar systeemvak + Houd Spotube actief in het systeemvak als het venster wordt geminimaliseerd. + Discord Rich Presence + Toon wat je afspeelt in Discord op desktop. + + Automatisch controleren op updates + Op de achtergrond zoeken naar nieuwe Spotube-versies. + + Plug-ins beheren + Beheer je plug-ins + Standaardplug-in voor %1$s + Metadata + Audio + Songteksten + Scrobbelen + Geen plug-in geselecteerd + Wijzigen + Selecteren + Geselecteerd + Selectie wissen + Geen plug-ins beschikbaar + Plug-in %1$s + Wissen + + Configureren + Plug-inbeheer + Voer een URL in + URL moet beginnen met http:// of https:// + Plug-in downloaden mislukt + Nog geen plug-ins geïnstalleerd + Plak hierboven een download-URL of kies een bestand. + + %1$d plug-in geïnstalleerd + %1$d plug-ins geïnstalleerd + + Plug-in installeren + https://example.com/plugin.smplug + Downloaden + Installeren uit bestand (.smplug) + Actief + v%1$s + Ingebouwd + Plug-in verwijderen + Inloggen + Uitloggen + Downloaden via URL + Installeren uit bestand + Installeren + + • %1$s + Gevraagde machtigingen + Deze plug-in heeft geen speciale machtigingen nodig. + Geïnstalleerd vs. aangeboden + Geïnstalleerd + Aangeboden + API %1$s → %2$s + Permanente opslag + Gegevens lezen van en schrijven naar schijf + Netwerkverzoeken + Gegevens verzenden en ontvangen via internet + WebView + Webcontent weergeven in de app + Group Jam + Verbinding testen + Testen… + Verbonden in %1$d ms + Mislukt: %1$s + Verbinding + Diagnose + + + Group Jam + Maken + Deelnemen + Maak een sessie en deel de code met je vrienden. + Sessie maken + Deelnemen aan sessie + Verbinden… + Sessiecode + ABC123 + Verbonden + Opnieuw verbinden… + Code kopiëren + Gekopieerd! + Delen + Deelnemers (%1$d) + Host + Luisteraar + Wachten op luisteraars… + Verwijderen + Blokkeren + Beheren + Sessie verlaten + Kan geen verbinding maken + Sluiten + Jam-sessie maken mislukt + Deelnemen aan Jam-sessie mislukt + Voer een sessiecode van zes tekens in met letters en cijfers. + Doe mee met mijn Spotube Jam met code: %1$s + Sessieserver + Server-URL + Voer een URL in die begint met https:// + Controleer de sessieserver voordat je een sessie start. + Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan de server van github.com/team-spotube/jam-server en voer de URL in. + Jij + Server: %1$s + Stel in Instellingen een sessieserver in om een sessie te hosten of eraan deel te nemen. + Serverinstellingen + %1$s (jij) + Je naam + Anderen in de jam zien je als %1$s + Zichtbaar voor iedereen in de jam. Laat leeg om %1$s te gebruiken. Een nieuwe naam geldt vanaf de volgende keer dat je een sessie start of eraan deelneemt. + Gebruik maximaal %1$d tekens. + Geen jamserver ingesteld + Kan de jamserver niet bereiken. Controleer je netwerk of de server-URL in Instellingen → Group Jam. + De jamserver draait een incompatibele versie. + Geen sessie met die code. Controleer de code en probeer het opnieuw. + Deze sessie is vol. + De host heeft je uit deze sessie verbannen. + De host heeft je uit de sessie verwijderd. + Deze sessie is opnieuw geopend op dit apparaat, dus deze verbinding is gesloten. + + Naar album + Slaaptimer + Details van nummer + Afspelen + Pauzeren + Er wordt niets afgespeeld + Toevoegen aan wachtrij + + Negeren + Bijwerken + Spotube %1$s is beschikbaar + Bekijk wat er nieuw is in deze versie op de downloadpagina van Spotube. + %1$s %2$s is beschikbaar + Momenteel geïnstalleerd: %1$s + Bekijk wat er nieuw is in deze update op de pagina van de plug-in. + + Overslaan + Volgende + Terug + Aan de slag + Spotube + Muziek, zonder verplichtingen. + Een opensource-speler voor een wereld vol muziek.\nOm te ontdekken. Om helemaal van jou te maken. + Houd dit project in leven. + Spotube wordt gemaakt door mensen, niet door een abonnementsbedrijf. Als het onderdeel wordt van je dagelijks leven, helpt een beetje steun ons om het de zorg te geven die het verdient.\n\nBedankt dat je ruimte maakt voor onafhankelijke software. + Begin met een catalogusplug-in om nummers en playlists te vinden. Kies een audioplug-in om ze af te spelen. Songteksten en luistergeschiedenis zijn optioneel. Ontdek hieronder een plug-in, bekijk de machtigingen, installeer hem en selecteer hem hier. + Plug-ins ontdekken + Je bronnen + Opnieuw proberen + Meer plug-ins + Geen nieuwe plug-ins gevonden. Je kunt er ook hierboven een installeren via een link of een .smplug-bestand. + Plug-ins ontdekken is nu niet beschikbaar. Probeer het opnieuw of installeer via een link of bestand. + Welkom + Steunen + Plug-ins + Uitleg + + Zo gebruik je Spotube + Vier snelle stappen en je luistert al. + Inloggen + Koppel je account of kies je eigen catalogusplug-in. + Zoeken en afspelen + Vind elk nummer, album of elke artiest en druk op afspelen. + Offline luisteren + Download nummers om ook zonder verbinding te luisteren. + Volg de songtekst + Open gesynchroniseerde songteksten in de speler terwijl een nummer speelt. + + + Jouw muziek. Jouw bronnen. Jouw regels. + Een gratis opensource-muziekspeler, gemaakt door een community die gewoon van muziek houdt. + Opensource + Geen advertenties + Geen telemetrie + Werkt met plug-ins + Aan de slag + + + Supporters houden ons op gang + Gemaakt met passie. In leven gehouden door jou. + Spotube is 100% gratis, vrij en opensource — geen advertenties, geen abonnementen, geen tracking. Elke functie bestaat omdat mensen zoals jij hebben geholpen de code te laten stromen. + Patreon + Maandelijkse sponsor + Steun doorlopende ontwikkeling en infrastructuur + Buy Me a Coffee + Eenmalig koffie + Geef een nachtelijke codeersessie een boost met een kleine fooi + Open Collective + Communityfonds + Transparante financiële steun, beheerd door de community + Uit de grond van ons hart bedankt dat je gelooft in open muziek. + Steunen is helemaal optioneel — Spotube blijft hoe dan ook gratis. + Sponsoren + %1$s openen + + + Spotube steunen + Spotube steunen + Spotube is gratis, vrij en opensource — geen advertenties, geen abonnementen, geen tracking. Als je er blij van wordt, overweeg dan het project te steunen. + Je kunt deze herinneringen uitzetten via Instellingen → Steunen. + Misschien later + Herinneringen om te steunen + Toon elke 7 dagen een vriendelijke herinnering om het project te steunen. + + + Modulaire architectuur + Kies waar je muziek vandaan komt. + Spotube scheidt het ontdekken van de catalogus, het streamen van audio en gesynchroniseerde songteksten. Stel je engines hieronder in of wissel ze op elk moment. + Metadata-engine + Zorgt voor muziek ontdekken, zoeken, tracklijsten en playlists. + Audio-engine + Haalt de audiostreams op die worden afgespeeld en decodeert ze. + Songtekst-engine + Streamt tijdgesynchroniseerde karaokesongteksten in realtime. + Scrobble-engine + Legt je luistergeschiedenis vast op Last.fm of ListenBrainz. + Actief + Ingebouwd + Klaar + Optioneel + Kies een engine + Geïnstalleerde engines + Instellen als standaard + NewPipe YouTube en LRCLib Lyrics zijn ingebouwd en direct klaar om te streamen. + Beheer of voeg op elk moment communityplug-ins toe via Instellingen → Plug-ins. + + + Retromoderne architectuur + Pure hi-fi, geen tussenpersonen + Zo regelt Spotube gedecentraliseerd afspelen, rechtstreeks op je apparaat. + RECHTSTREEKS PEER-TO-PEER • 256 KBPS OPUS + Blauw uur + Spotube · demonstratie van de speler + Een beetje muziek voor de momenten tussendoor. + 1. Gedecentraliseerd ontdekken + Zoekopdrachten gaan rechtstreeks naar je gekozen catalogusplug-in. Spotube maakt nooit een profiel van je smaak. + 2. Directe streams + Audio wordt rechtstreeks van de contentservers naar je apparaat gestreamd. Geen proxy\'s ertussen en geen gegevensverzameling op afstand. + 3. Lokale DSP & afspelen + Lokaal gedecodeerd en gecachet, met equalizer en optioneel offline downloaden. + 4. Gesynchroniseerde songteksten & Jam + Karaokesongteksten volgen de afspeelpositie, met bediening op afstand via LAN en Group Jam. + Geen tussenservers. Afspelen gebeurt gewoon op je apparaat — privé vanaf het ontwerp. + + + Kies een apparaat + %1$s: ‘%2$s’ + Afspelen + Toevoegen aan wachtrij + Hierna afspelen + Dit apparaat + Op dit apparaat + Extern apparaat + Extern apparaat (%1$s) + Op het verbonden apparaat + + + Verbinding mislukt + Verbinding verbroken + Verzenden mislukt + Zoeken mislukt: %1$s + Verbinden mislukt: %1$s + + + Apparaten + Vernieuwen + Verbinding verbreken + Verbinden... + Zoeken naar apparaten op het netwerk... + Geen apparaten gevonden + Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat. + Onbekend apparaat + Bezig met ophalen... + + + Bediening op afstand + Niet verbonden + Verbindingsfout: %1$s + Onbekend nummer + Onbekende artiest + Volume + Verwijderen uit wachtrij + Meer opties + + + Waar afspelen? + Je hebt een actieve Jam-sessie. Kies waar je deze collectie wilt afspelen. + Hier afspelen + Voorstellen aan Jam + + + Verbinding mislukt + Verbinding verbroken + + + + Toegevoegd aan de Jam-wachtrij + + %1$d nummer toegevoegd aan de Jam-wachtrij + %1$d nummers toegevoegd aan de Jam-wachtrij + + + + Verzoek voor bediening op afstand + \"%1$s\" wil het afspelen op dit apparaat bedienen. + Weigeren + Toestaan + Altijd toestaan + + + Zoekresultaten + Alles + Nummers + Gebruikers + Geen zoektypen beschikbaar + Begin met typen om te zoeken + Begin met typen om nummers te zoeken + Geen resultaten gevonden + Alles weergeven + Zoeken + Nummers laden... + Albums laden... + Artiesten laden... + Playlists laden... + Gebruikers laden... + Geen albums gevonden + Geen artiesten gevonden + Geen playlists gevonden + Geen gebruikers gevonden + Door de platenbakken aan het spitten... + De kosmos aan het doorzoeken... + De speakers aan het opwarmen... + Het vinyl aan het afstoffen... + De muziekgoden aan het raadplegen... + De antennes aan het afstemmen... + Door de platen aan het bladeren... + Zoekmogelijkheden laden mislukt + Zoeken mislukt + Nummers zoeken mislukt + Albums zoeken mislukt + Artiesten zoeken mislukt + Playlists zoeken mislukt + Gebruikers zoeken mislukt + + + Ontdekken + + + Gesynchroniseerd + Platte tekst + Geen gesynchroniseerde songtekst beschikbaar + Geen songtekst beschikbaar + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d ch + Geen metadataplug-in geselecteerd + + + Terug + Opnieuw proberen + Dialoogvenster sluiten + Laden... + Onbekend + Onbekende fout + + + Gebruiker + Playlist + + + Het spijt ons echt + Er werkt momenteel iets niet goed. Jouw ervaring is belangrijk voor ons en we doen ons best om dit op te lossen. Probeer het opnieuw of bekijk de foutdetails hieronder. + Foutdetails + Kopiëren naar klembord + Volledig logboek bekijken + Volledig foutenlogboek + + + Shuffle + Shuffle afspelen + Volgen + Ontvolgen + Playlist bewerken + Van %1$s + + + Opties voor nummer + Toevoegen aan Jam + Radio starten + Hierna afspelen + Naar volgende verplaatsen + Verwijderen uit wachtrij + Verwijderen uit favorieten + Opslaan als favoriet + Toevoegen aan playlist + Downloaden + Delen + + + Oorspronkelijk + Titel + Artiest + Album + Duur + Sorteren + Geen nummers gevonden + Filteren + Bulkacties + Alles downloaden + %1$d downloaden + Alles toevoegen aan wachtrij + %1$d toevoegen aan wachtrij + Alles hierna afspelen + %1$d hierna afspelen + Alles toevoegen aan playlist + %1$d toevoegen aan playlist + Alles toevoegen aan Jam + %1$d toevoegen aan Jam + +%1$d meer + + + Geen URL om te laden + Terug in browsergeschiedenis + Vooruit in browsergeschiedenis + + + Zwarte lijst filteren... + Niets op de zwarte lijst + Niets op de zwarte lijst komt overeen met \'%1$s\' + Nummers + Nummer + Toevoegen aan zwarte lijst + Verwijderen van zwarte lijst + + + Geen metadata voor nummer + Kan stream-URL niet ophalen + Kan stream-URL niet ophalen. Speel het nummer eerst af. + + + Opgeslagen nummers + Jij + + + Volgend + Populairste nummers + Populairste nummers afspelen + Populairste nummers toevoegen aan wachtrij + Vergelijkbare artiesten + Uitgelichte playlists + Artiestoverzicht laden mislukt + Volgers niet beschikbaar + %1$sK volgers + %1$s mln. volgers + %1$s mld. volgers + + %1$d volger + %1$d volgers + + + + Liken + Niet meer liken + Titel van no-op-nummer + No-op-artiesten + Onbekend album + Zoeken in nummer + Volume + Shuffle aanzetten + Shuffle uitzetten + Vorige + Volgende + Afspelen of pauzeren + Herhalen uit + Eén nummer herhalen + Alles herhalen + Dempen + Dempen opheffen + Meer opties + Speleropties + Speler inklappen + Songtekst uitvouwen + Nu aan het afspelen + Songtekst + Alternatieve bron + Alternatieve bron + Downloaden + Gedownload + Downloaden... + Al gedownload + Download opnieuw proberen... + %1$s downloaden + Afspelen pauzeert over %1$s. + Pauzeer het afspelen na het gekozen aantal minuten. + Minuten + Kies tussen 1 en 600 minuten + Timer annuleren + Starten + De afspeel-URL is opgehaald met andere streaminginstellingen. De gemarkeerde stream komt overeen met je huidige voorkeur; de actieve URL wordt vernieuwd bij het volgende streamverzoek. + Gekoppelde audiobron + Gekoppelde bron laden… + Een gekoppelde externe bron is niet nodig zolang het lokale cachebestand wordt afgespeeld. + Er is geen gekoppelde bron beschikbaar. + Er is geen gekoppelde audiobron gecachet voor dit nummer. + Streamdetails zijn nog niet gecachet. Speel het nummer eerst af om ze te laden. + Details van nummer laden mislukt. + Beschikbare streams + Dit nummer wordt afgespeeld vanuit de lokale cache. Streamvarianten worden voor deze weergave niet gebruikt. + Streams laden… + Er zijn geen streams beschikbaar voor deze bron. + Momenteel geselecteerde stream + Komt overeen met je voorkeur voor streamingformaat & -kwaliteit + Actieve afspeel-URL + Afgespeeld vanuit lokale cache + Metadata van nummer + Titel + ID + Album + Betrouwbaarheid + Externe URI + Album-ID + Albumtype + Releasedatum album + Aantal nummers op album + Albumgenres + Duur + Nummer op album + Schijfnummer + Expliciet + Populariteit + ISRC + Miniatuur-URL\'s + + + Zijbalk tonen/verbergen + + + Alternatieve bronnen + Er wordt nu geen nummer afgespeeld + Zoeken naar alternatieve bronnen... + Geen alternatieve bronnen gevonden + Betrouwbaarheid: %1$d% + Actieve bron + + + Verwijderen + Verwijder deze persoon uit de sessie + Verwijderen en voorkomen dat deze persoon opnieuw deelneemt + Suggesties verwijderen + Verwijder alle nummers die deze persoon aan de wachtrij heeft toegevoegd + Volgorde wijzigen + Verwijderen uit wachtrij + %1$s • Toegevoegd door %2$s + + + Onbekend + Playlist laden... + Nummers toevoegen + Nummers toevoegen aan playlist + Nummers zoeken... + Zoeken + Geen nummers gevonden + Zoek nummers om toe te voegen + Klaar + Toevoegen + Toegevoegd + + + Album laden... + + + Toevoegen aan je playlist + Zoeken + Zoek in je playlists... + Je playlists laden... + Je hebt nog geen eigen playlists.\nMaak er een om nummers toe te voegen. + Playlist maken + Playlist bewerken + Maken + Naam + Naam van playlist + Beschrijving + Beschrijving (optioneel) + Openbaar + Iedereen kan deze playlist zien + Gezamenlijk + Anderen kunnen nummers aan deze playlist toevoegen + Coverafbeelding + Playlistcover + Geen afbeelding + Afbeelding wijzigen + Afbeelding kiezen + + + Installeren + Vervangen + plugin.json niet gevonden in het zip-bestand + manifest.zipline.json niet gevonden in het zip-bestand + Ongeldige indeling van plugin.json: %1$s + Plug-in lezen mislukt: %1$s + Plug-in installeren mislukt: %1$s + Er is een nieuwere versie van %1$s beschikbaar. Bevestig de update voordat je installeert. + %1$s is al geïnstalleerd. Bevestig het vervangen voordat je installeert. + Ingebouwde plug-ins zijn al inbegrepen en kunnen niet opnieuw worden toegevoegd. + Ingebouwde plug-ins kunnen niet worden verwijderd. + %1$s geeft een ongeldige apiVersion op (%2$s). Verwacht: semantische versie compatibel met %3$s. + Plug-in-API-versie %1$s van Spotube is ongeldig. + %1$s is gemaakt voor plug-in-API %2$s, maar Spotube ondersteunt de breaking plug-in-API %3$s. Installeer een plug-in die is gemaakt voor dezelfde breaking API-versie. + + + Plug-in-API niet compatibel + Plug-in installeren? + %1$s wordt toegevoegd aan je geïnstalleerde plug-ins. + Plug-in bijwerken? + Er is een oudere versie geïnstalleerd (%1$s). Bijwerken naar %2$s? + Geïnstalleerde plug-in vervangen? + %1$s gebruikt een ongeldige semantische versie (%2$s) en kan niet als update worden vergeleken. De geïnstalleerde plug-in toch vervangen? + De geïnstalleerde versie (%1$s) kan niet worden vergeleken met semantische versies. Vervangen door %2$s? + Versie %1$s is al geïnstalleerd. De bestaande plug-in vervangen door de aangeboden kopie? + De geïnstalleerde versie %1$s is nieuwer dan de aangeboden versie %2$s. Toch vervangen? + De geïnstalleerde plug-in vervangen door de aangeboden kopie? + + + %1$s: + Versie + API-versie + Auteur + Licentie + Mogelijkheden + Functies + Repository + Contact + Bugs melden + %1$s steunen + + + Officieel + Geverifieerd + GitHub-sterren + URL van GitHub-repository + + + Geen releases gevonden + Release kiezen + Kies een release + Pre-release + Release-opmerkingen + + + Home + Zoeken + Op apparaat + Verbinden + Apparaten + + + Venster verbergen + Venster tonen + Volgend nummer + Vorig nummer + Shuffle + Herhalen + Herhalen: UIT + Herhalen: ÉÉN + Herhalen: ALLES + Volume: %1$d% + Volume + + Volume - + Dempen + Dempen opheffen + Nummer liken + Nummer niet meer liken + Afsluiten + + + Minimaliseren + Maximaliseren + Herstellen + Sluiten + + + Afspelen + Bediening voor media-afspelen + + + Ontdekken + Nu aan het afspelen + Uitgelicht + Opgeslagen nummers + Populairste nummers + diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml new file mode 100644 index 00000000..92c9b443 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -0,0 +1,938 @@ + + + + Kolejka + Wyczyść kolejkę + Filtruj kolejkę... + Kolejka jest pusta + + Szukaj utworów, wykonawców, albumów... + Wyczyść całą historię + Szukaj... + + + Twoja biblioteka + Biblioteka + Playlisty + Albumy + Wykonawcy + Lokalne utwory + Pobrane + Szukaj w zapisanych playlistach… + Szukaj w zapisanych albumach… + Szukaj wśród zapisanych wykonawców… + Szukaj folderów lub utworów… + Filtruj bibliotekę + Zamknij filtr + Utwórz playlistę + Odśwież lokalne utwory + Przełącz na widok siatki + Przełącz na widok listy + Brak zapisanych playlist + Brak playlist pasujących do „%1$s” + Brak zapisanych albumów + Brak albumów pasujących do „%1$s” + Brak zapisanych wykonawców + Brak wykonawców pasujących do „%1$s” + + + Foldery + Folder + %1$s • %2$s + + %1$d utwór + %1$d utwory + %1$d utworów + %1$d utworu + + + %1$d folder + %1$d foldery + %1$d folderów + %1$d folderu + + Wróć do folderów + Odtwórz folder + Wymagany dostęp do audio + Zezwól na dostęp do audio, aby przeglądać muzykę na urządzeniu. + Zezwól na dostęp + Nie znaleziono lokalnych folderów + Brak utworów w tym folderze + Brak pasujących folderów lub utworów + Nie udało się odświeżyć lokalnych multimediów + + + Aktywne: %1$d • Ukończone: %2$d + Wyczyść ukończone + Nic jeszcze nie pobrano + Pobieranie %1$d% + W kolejce + Ukończono + Anulowano + Niepowodzenie + Niepowodzenie: %1$s + Anuluj pobieranie + Ponów pobieranie + Usuń pobrany plik + + + Wykonawca + Na czarnej liście + Polubione utwory + Twoje zapisane utwory + Nieznany wykonawca + Tytuł utworu + Nazwa wykonawcy + + Zaznaczono + + Ustawienia + + Wtyczki + Język i region + Wygląd + Odtwarzanie + Pobieranie + Pamięć podręczna + Komputer + Aktualizacje + Wsparcie + Informacje + + Zamknij + Zapisz + Anuluj + + Język + Język interfejsu aplikacji. Obecnie: %1$s + Domyślny systemowy + Kraj + Kraj regionu dla treści i dostępności. Obecnie: %1$s + %1$s (%2$s) + + Motyw + Wybierz, jak Spotube dopasowuje się do wyglądu systemu. Obecnie: %1$s + Jasny + Ciemny + Systemowy + + Powierzchnia + Materiał tła aplikacji. Obecnie: %1$s + Wodny + Lodowy + Chrom + Akryl + Szkło + + Kolor akcentu + Obecna paleta: %1$s + Kolor akcentu + Wybierz paletę akcentu i zobacz jej podgląd w jasnym i ciemnym motywie. + Jasny + Ciemny + Podgląd + Przycisk + Zielony goblin + Elektryczny fiolet + Oceaniczny cyjan + Pomarańcz zachodu + Różany ogród + Granat północy + Metaliczny łupek + + Wygląd i styl + Materiał powierzchni i akcent. Obecnie: %1$s · %2$s + Wygląd i styl + Wybierz materiał powierzchni, a następnie pasujący do niego akcent. Każda opcja to starannie dobrane połączenie. + Powierzchnia + Akcent + Dodatkowy + + Paleta + Paleta akcentów Millennium. Obecnie: %1$s + Paleta + Wybierz paletę akcentów Millennium i zobacz jej podgląd w jasnym i ciemnym motywie. + Paleta + Paleta i akcent Millennium. Obecnie: %1$s + Niebieska + Pomarańczowa + Limonkowa + + Własny folder pobierania + Obecny folder: %1$s + Pozostaw puste, aby używać domyślnego folderu muzyki. + C:\Music\Spotube + Foldery lokalnych multimediów + Własne foldery skanowane na komputerze. Dodano: %1$d + Nie dodano jeszcze własnego folderu + Własne foldery dla lokalnej biblioteki muzyki. + Zarządzaj + Dodaj folder + Usuń folder + Nie dodano własnych folderów. + Usuń %1$s z listy skanowania + Foldery pobierania i pamięci podręcznej muzyki są uwzględniane automatycznie. + Folder pobierania jest uwzględniany automatycznie. + Folder pamięci podręcznej muzyki jest uwzględniany automatycznie. + Zapisywanie… + Nie udało się zapisać folderów. Twoje zmiany nadal tu są – spróbuj ponownie. + + %1$d folder + %1$d foldery + %1$d folderów + %1$d folderu + + Format pobieranej muzyki + Preferowany kodek i kontener pobierania. Obecnie: %1$s + Jakość pobieranej muzyki + + Format strumieniowanej muzyki + Preferowany kodek i kontener strumieniowania muzyki. Obecnie: %1$s + Jakość strumieniowanej muzyki + Obecnie: %1$s + Włącz pamięć podręczną muzyki + Zapisuj strumieniowany dźwięk lokalnie, aby przyspieszyć ponowne odtwarzanie. + Folder pamięci podręcznej + Obecnie: %1$s + Obecnie: domyślna pamięć podręczna aplikacji + Limit pamięci podręcznej + Bez limitu + Obecny limit: %1$d MB + Ustaw maksymalny rozmiar pamięci podręcznej w MB. Wpisz 0 lub pozostaw puste, aby nie ustawiać limitu. + 500 + Rozmiar musi wynosić 0 lub być liczbą dodatnią. + Włącz nieskończone odtwarzanie + Gdy kolejka się skończy, odtwarzaj dalej polecane utwory. + Port serwera proxy odtwarzania + Zezwól na zdalne sterowanie + Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu. + Nazwa urządzenia do zdalnego sterowania + Nazwa widoczna dla innych urządzeń podczas wykrywania. Obecnie: %1$s + Wybierz przyjazną nazwę, którą zobaczą inne urządzenia w Twojej sieci, gdy wykryją tę instancję Spotube. + np. Spotube w salonie + nazwa hosta urządzenia + Port używany przez serwer proxy odtwarzania. Obecnie: %1$d + Wybierz port od 1 do 65535. + 14769 + Wpisz poprawną liczbę całkowitą. + Port musi mieścić się w zakresie od 1 do 65535. + + Czarna lista + Zarządzaj utworami i wykonawcami na czarnej liście + + Minimalizuj do zasobnika + Po zminimalizowaniu okna Spotube działa dalej w zasobniku systemowym. + Status Discord (Rich Presence) + Pokazuj w Discordzie, czego teraz słuchasz (na komputerze). + + Automatycznie sprawdzaj aktualizacje + Wyszukuj nowe wersje Spotube w tle. + + Zarządzaj wtyczkami + Zarządzaj swoimi wtyczkami + Domyślna wtyczka: %1$s + Metadane + Audio + Teksty + Scrobbling + Nie wybrano wtyczki + Zmień + Wybierz + Wybrano + Wyczyść wybór + Brak dostępnych wtyczek + Wtyczka %1$s + Wyczyść + + Konfiguruj + Menedżer wtyczek + Wpisz adres URL + Adres URL musi zaczynać się od http:// lub https:// + Nie udało się pobrać wtyczki + Nie zainstalowano jeszcze żadnych wtyczek + Wklej adres URL pobierania lub wybierz plik powyżej. + + %1$d wtyczka zainstalowana + %1$d wtyczki zainstalowane + %1$d wtyczek zainstalowanych + %1$d wtyczki zainstalowanej + + Zainstaluj wtyczkę + https://example.com/plugin.smplug + Pobierz + Zainstaluj z pliku (.smplug) + Aktywna + v%1$s + Wbudowana + Usuń wtyczkę + Zaloguj się + Wyloguj się + Pobierz z adresu URL + Zainstaluj z pliku + Zainstaluj + + • %1$s + Wymagane uprawnienia + Ta wtyczka nie wymaga specjalnych uprawnień. + Zainstalowana a dostarczona + Zainstalowana + Dostarczona + API %1$s → %2$s + Trwała pamięć + Odczyt i zapis danych na dysku + Żądania sieciowe + Wysyłanie i odbieranie danych przez internet + WebView + Wyświetlanie treści internetowych w aplikacji + Grupowy Jam + Testuj połączenie + Testowanie… + Połączono w %1$d ms + Niepowodzenie: %1$s + Połączenie + Diagnostyka + + + Grupowy Jam + Utwórz + Dołącz + Utwórz pokój, a potem udostępnij jego kod znajomym. + Utwórz pokój + Dołącz do pokoju + Łączenie… + Kod pokoju + ABC123 + Połączono + Ponowne łączenie… + Kopiuj kod + Skopiowano! + Udostępnij + Uczestnicy (%1$d) + Gospodarz + Słuchacz + Czekamy na słuchaczy… + Usuń + Zablokuj + Zarządzaj + Opuść pokój + Nie udało się połączyć + Zamknij + Nie udało się utworzyć sesji Jam + Nie udało się dołączyć do sesji Jam + Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr. + Dołącz do mojego Jamu w Spotube, kod: %1$s + Serwer pokojów + Adres URL serwera + Wpisz adres URL zaczynający się od https:// + Sprawdź serwer pokojów przed utworzeniem pokoju. + Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż serwer z github.com/team-spotube/jam-server i wpisz jego adres URL. + Ty + Serwer: %1$s + Ustaw serwer pokojów w Ustawieniach, aby utworzyć pokój lub do niego dołączyć. + Ustawienia serwera + %1$s (ty) + Twoja nazwa + Inni w jamie widzą cię jako %1$s + Widoczna dla wszystkich w jamie. Zostaw puste, aby użyć %1$s. Nowa nazwa zacznie obowiązywać przy następnym utworzeniu pokoju lub dołączeniu do niego. + Użyj maksymalnie %1$d znaków. + Nie skonfigurowano serwera jamu + Nie można połączyć się z serwerem jamu. Sprawdź sieć lub adres URL serwera w Ustawienia → Grupowy Jam. + Serwer jamu używa niezgodnej wersji. + Nie ma pokoju o tym kodzie. Sprawdź go i spróbuj ponownie. + Ten pokój jest pełny. + Gospodarz zablokował cię w tym pokoju. + Gospodarz usunął cię z pokoju. + Ten pokój został ponownie otwarty na tym urządzeniu, więc to połączenie zostało zamknięte. + + Przejdź do albumu + Wyłącznik czasowy + Szczegóły utworu + Odtwórz + Wstrzymaj + Nic nie jest odtwarzane + Dodaj do kolejki + + Ignoruj + Aktualizuj + Dostępna jest wersja Spotube %1$s + Sprawdź nowości w tej wersji na stronie pobierania Spotube. + Dostępna jest wersja %2$s wtyczki %1$s + Obecnie zainstalowana: %1$s + Sprawdź nowości w tej aktualizacji na stronie wtyczki. + + Pomiń + Dalej + Wstecz + Zaczynamy + Spotube + Muzyka bez żadnych warunków. + Otwartoźródłowy odtwarzacz dla całego świata muzyki.\nOdkrywaj go. Zrób go po swojemu. + Pomóż utrzymać ten projekt. + Spotube tworzą ludzie, a nie biznes oparty na subskrypcjach. Jeśli stanie się częścią Twojej codzienności, niewielkie wsparcie pomoże nam dbać o niego tak, jak na to zasługuje.\n\nDziękujemy, że dajesz miejsce niezależnemu oprogramowaniu. + Zacznij od wtyczki katalogu, aby wyszukiwać utwory i playlisty. Wybierz wtyczkę audio, aby je odtwarzać. Teksty i historia słuchania są opcjonalne. Znajdź wtyczkę poniżej, sprawdź jej uprawnienia, zainstaluj ją, a potem wybierz ją tutaj. + Odkrywaj wtyczki + Twoje źródła + Spróbuj ponownie + Więcej wtyczek + Nie znaleziono nowych wtyczek. Możesz też zainstalować wtyczkę z linku lub pliku .smplug powyżej. + Wyszukiwanie wtyczek jest teraz niedostępne. Spróbuj ponownie lub zainstaluj z linku albo pliku. + Witaj + Wsparcie + Wtyczki + Przewodnik + + Jak korzystać ze Spotube + Cztery szybkie kroki i już słuchasz. + Zaloguj się + Połącz konto lub wybierz własną wtyczkę katalogu. + Szukaj i odtwarzaj + Znajdź dowolny utwór, album lub wykonawcę i naciśnij Odtwórz. + Słuchaj offline + Pobieraj utwory, aby słuchać ich bez połączenia. + Śledź tekst + Otwórz zsynchronizowany tekst w odtwarzaczu podczas słuchania utworu. + + + Twoja muzyka. Twoje źródła. Twoje zasady. + Darmowy, otwartoźródłowy odtwarzacz muzyki tworzony przez społeczność, która po prostu kocha muzykę. + Open source + Bez reklam + Bez telemetrii + Oparty na wtyczkach + Zaczynamy + + + Wspierający dodają nam sił + Stworzony z pasją. Utrzymywany dzięki Tobie. + Spotube jest w 100% darmowy, wolny i otwartoźródłowy — bez reklam, subskrypcji i śledzenia. Każda funkcja istnieje dzięki osobom takim jak Ty, które zdecydowały się wspierać rozwój kodu. + Patreon + Miesięczny sponsor + Wspieraj stały rozwój i infrastrukturę + Buy Me a Coffee + Jednorazowa kawa + Dodaj energii nocnej sesji kodowania drobnym napiwkiem + Open Collective + Fundusz społeczności + Przejrzyste wsparcie finansowe zarządzane przez społeczność + Dziękujemy z całego serca, że wierzysz w otwartą muzykę. + Wsparcie jest całkowicie dobrowolne — Spotube i tak pozostanie darmowy. + Wesprzyj + Otwórz %1$s + + + Wesprzyj Spotube + Wesprzyj Spotube + Spotube jest darmowy, wolny i otwartoźródłowy — bez reklam, subskrypcji i śledzenia. Jeśli sprawia Ci radość, rozważ wsparcie projektu. + Te przypomnienia możesz wyłączyć w Ustawienia → Wsparcie. + Może później + Przypomnienia o wsparciu + Co 7 dni pokazuj delikatne przypomnienie o wsparciu projektu. + + + Modułowa architektura + Wybierz, skąd pochodzi Twoja muzyka. + Spotube rozdziela odkrywanie katalogu, strumieniowanie dźwięku i zsynchronizowane teksty. Skonfiguruj silniki poniżej lub zmień je w dowolnej chwili. + Silnik metadanych + Odpowiada za odkrywanie muzyki, wyszukiwanie, listy utworów i playlisty. + Silnik audio + Pobiera i dekoduje odtwarzane strumienie audio. + Silnik tekstów + Wyświetla zsynchronizowane teksty karaoke w czasie rzeczywistym. + Silnik scrobblingu + Zapisuje historię słuchania w Last.fm lub ListenBrainz. + Aktywny + Wbudowany + Gotowy + Opcjonalny + Wybierz silnik + Zainstalowane silniki + Ustaw jako domyślny + NewPipe YouTube i LRCLib Lyrics są wbudowane i od razu gotowe do działania. + Zarządzaj wtyczkami społeczności lub dodawaj nowe w dowolnej chwili w Ustawienia → Wtyczki. + + + Retro-nowoczesna architektura + Czyste Hi-Fi, zero pośredników + Oto jak Spotube organizuje zdecentralizowane odtwarzanie bezpośrednio na Twoim komputerze. + BEZPOŚREDNIO PEER-TO-PEER • 256 KBPS OPUS + Niebieska godzina + Spotube · demonstracja odtwarzacza + Trochę muzyki na chwile pomiędzy. + 1. Zdecentralizowane odkrywanie + Zapytania trafiają prosto do wybranej wtyczki katalogu. Spotube nigdy nie profiluje Twojego gustu. + 2. Bezpośrednie pozyskiwanie strumienia + Dźwięk płynie bezpośrednio z serwerów treści na Twój komputer. Bez pośrednich serwerów proxy i zdalnego zbierania danych. + 3. Lokalne DSP i odtwarzanie + Dekodowanie i buforowanie odbywa się lokalnie, z korektorem i opcjonalnym pobieraniem offline. + 4. Zsynchronizowane teksty i Jam + Teksty karaoke podążają za odtwarzaniem, a do tego zdalne sterowanie w sieci LAN i Grupowy Jam. + Żadnych serwerów pośredniczących. Odtwarzanie odbywa się na Twoim urządzeniu — prywatność w standardzie. + + + Wybierz urządzenie + %1$s: „%2$s” + Odtwórz + Dodaj do kolejki + Odtwórz jako następny + To urządzenie + Na tym urządzeniu + Urządzenie zdalne + Urządzenie zdalne (%1$s) + Na połączonym urządzeniu + + + Połączenie nieudane + Utracono połączenie + Wysyłanie nieudane + Wykrywanie nieudane: %1$s + Nie udało się połączyć: %1$s + + + Urządzenia + Odśwież + Rozłącz + Łączenie... + Wyszukiwanie urządzeń w sieci... + Nie znaleziono urządzeń + Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”. + Nieznane urządzenie + Ustalanie adresu... + + + Zdalne sterowanie + Rozłączono + Błąd połączenia: %1$s + Nieznany utwór + Nieznany wykonawca + Głośność + Usuń z kolejki + Więcej opcji + + + Gdzie odtworzyć? + Masz aktywną sesję Jam. Wybierz, gdzie odtworzyć tę kolekcję. + Odtwórz tutaj + Zaproponuj w Jamie + + + Połączenie nieudane + Utracono połączenie + + + + Dodano do kolejki Jamu + + Dodano %1$d utwór do kolejki Jamu + Dodano %1$d utwory do kolejki Jamu + Dodano %1$d utworów do kolejki Jamu + Dodano %1$d utworu do kolejki Jamu + + + + Prośba o zdalne sterowanie + „%1$s” chce sterować odtwarzaniem na tym urządzeniu. + Odmów + Zezwól + Zawsze zezwalaj + + + Wyniki wyszukiwania + Wszystko + Utwory + Użytkownicy + Brak dostępnych typów wyszukiwania + Zacznij pisać, aby wyszukać + Zacznij pisać, aby wyszukać utwory + Brak wyników + Pokaż wszystko + Wyszukiwanie + Wczytywanie utworów... + Wczytywanie albumów... + Wczytywanie wykonawców... + Wczytywanie playlist... + Wczytywanie użytkowników... + Nie znaleziono albumów + Nie znaleziono wykonawców + Nie znaleziono playlist + Nie znaleziono użytkowników + Przekopujemy skrzynki z płytami... + Przeszukujemy kosmos... + Rozgrzewamy głośniki... + Odkurzamy winyle... + Radzimy się bogów muzyki... + Stroimy anteny... + Przeglądamy płyty... + Nie udało się wczytać możliwości wyszukiwania + Wyszukiwanie nie powiodło się + Nie udało się wyszukać utworów + Nie udało się wyszukać albumów + Nie udało się wyszukać wykonawców + Nie udało się wyszukać playlist + Nie udało się wyszukać użytkowników + + + Przeglądaj + + + Zsynchronizowany + Zwykły + Brak zsynchronizowanego tekstu + Brak zwykłego tekstu + + + %1$s w %2$s + %1$d kbps + %1$s kHz • %2$d kan. + Nie wybrano wtyczki metadanych + + + Wstecz + Ponów + Zamknij okno + Wczytywanie... + Nieznane + Nieznany błąd + + + Użytkownik + Playlista + + + Bardzo nam przykro + Coś teraz nie działa. Zależy nam na Twoich wrażeniach i robimy wszystko, aby to naprawić. Spróbuj ponownie lub sprawdź szczegóły błędu poniżej. + Szczegóły błędu + Kopiuj do schowka + Pokaż pełny dziennik + Pełny dziennik błędu + + + Losowo + Odtwarzaj losowo + Obserwuj + Przestań obserwować + Edytuj playlistę + Autor: %1$s + + + Opcje utworu + Dodaj do Jamu + Włącz radio + Odtwórz jako następny + Przenieś na następny + Usuń z kolejki + Usuń z ulubionych + Dodaj do ulubionych + Dodaj do playlisty + Pobierz + Udostępnij + + + Oryginalna + Tytuł + Wykonawca + Album + Czas trwania + Sortuj + Nie znaleziono utworów + Filtruj + Działania zbiorcze + Pobierz wszystkie + Pobierz (%1$d) + Dodaj wszystkie do kolejki + Dodaj do kolejki (%1$d) + Odtwórz wszystkie jako następne + Odtwórz jako następne (%1$d) + Dodaj wszystkie do playlisty + Dodaj do playlisty (%1$d) + Dodaj wszystkie do Jamu + Dodaj do Jamu (%1$d) + +%1$d więcej + + + Brak adresu URL do wczytania + Wstecz w historii przeglądarki + Dalej w historii przeglądarki + + + Filtruj czarną listę... + Czarna lista jest pusta + Brak elementów czarnej listy pasujących do „%1$s” + Utwory + Utwór + Dodaj do czarnej listy + Usuń z czarnej listy + + + Brak metadanych utworu + Nie udało się ustalić adresu URL strumienia + Nie udało się ustalić adresu URL strumienia. Spróbuj najpierw odtworzyć utwór. + + + Zapisane utwory + Ty + + + Obserwujesz + Popularne utwory + Odtwórz popularne utwory + Dodaj popularne utwory do kolejki + Podobni wykonawcy + Polecane playlisty + Nie udało się wczytać przeglądu wykonawcy + Liczba obserwujących niedostępna + %1$s tys. obserwujących + %1$s mln obserwujących + %1$s mld obserwujących + + %1$d obserwujący + %1$d obserwujących + %1$d obserwujących + %1$d obserwującego + + + + Polub + Cofnij polubienie + Tytuł utworu zastępczego + Wykonawcy zastępczy + Nieznany album + Przewiń + Głośność + Włącz odtwarzanie losowe + Wyłącz odtwarzanie losowe + Poprzedni + Następny + Odtwórz lub wstrzymaj + Powtarzanie wyłączone + Powtarzaj jeden utwór + Powtarzaj wszystko + Wycisz + Wyłącz wyciszenie + Więcej opcji + Opcje odtwarzacza + Zwiń odtwarzacz + Rozwiń tekst + Teraz odtwarzane + Tekst + Alternatywne źródło + Alternatywne źródło + Pobierz + Pobrano + Pobieranie... + Już pobrano + Ponawianie pobierania... + Pobieranie: %1$s + Odtwarzanie zostanie wstrzymane za %1$s. + Wstrzymaj odtwarzanie po wybranej liczbie minut. + Minuty + Wybierz od 1 do 600 minut + Anuluj wyłącznik + Start + Adres URL odtwarzania został ustalony przy innych ustawieniach strumieniowania. Wyróżniony strumień odpowiada Twoim obecnym preferencjom; aktywny adres URL odświeży się przy następnym żądaniu strumienia. + Dopasowane źródło audio + Wczytywanie dopasowanego źródła… + Dopasowane zdalne źródło nie jest potrzebne podczas odtwarzania lokalnego pliku z pamięci podręcznej. + Brak dostępnego dopasowanego źródła. + Brak dopasowanego źródła audio w pamięci podręcznej dla tego utworu. + Szczegóły strumienia nie są jeszcze zapisane. Najpierw odtwórz utwór, aby je wczytać. + Nie udało się wczytać szczegółów utworu. + Dostępne strumienie + Ten utwór jest odtwarzany z lokalnej pamięci podręcznej. Warianty strumienia nie są używane podczas tego odtwarzania. + Wczytywanie strumieni… + Brak strumieni dla tego źródła. + Obecnie wybrany strumień + Zgodny z preferowanym formatem i jakością strumieniowania + Aktywny adres URL odtwarzania + Odtwarzane z lokalnej pamięci podręcznej + Utwór z metadanych + Tytuł + ID + Album + Pewność + Zewnętrzny URI + ID albumu + Typ albumu + Data wydania albumu + Liczba utworów w albumie + Gatunki albumu + Czas trwania + Numer utworu + Numer płyty + Treści dla dorosłych + Popularność + ISRC + Adresy URL miniatur + + + Przełącz pasek boczny + + + Alternatywne źródła + Nic nie jest obecnie odtwarzane + Wyszukiwanie alternatywnych źródeł... + Nie znaleziono alternatywnych źródeł + Pewność: %1$d% + Aktywne źródło + + + Wyrzuć + Usuń tę osobę z sesji + Wyrzuć i uniemożliw ponowne dołączenie + Usuń propozycje + Usuń wszystkie utwory dodane przez tę osobę do kolejki + Zmień kolejność + Usuń z kolejki + %1$s • Dodane przez: %2$s + + + Nieznany + Wczytywanie playlisty... + Dodaj utwory + Dodaj utwory do playlisty + Szukaj utworów... + Szukaj + Nie znaleziono utworów + Wyszukaj utwory do dodania + Gotowe + Dodaj + Dodano + + + Wczytywanie albumu... + + + Dodaj do swojej playlisty + Szukaj + Szukaj w swoich playlistach... + Wczytywanie Twoich playlist... + Nie masz jeszcze własnych playlist.\nUtwórz playlistę, aby dodać utwory. + Utwórz playlistę + Edytuj playlistę + Utwórz + Nazwa + Nazwa playlisty + Opis + Opis (opcjonalnie) + Publiczna + Każdy może zobaczyć tę playlistę + Wspólna + Inni mogą dodawać utwory do tej playlisty + Okładka + Okładka playlisty + Brak obrazu + Zmień obraz + Wybierz obraz + + + Zainstaluj + Zastąp + Nie znaleziono pliku plugin.json w archiwum zip + Nie znaleziono pliku manifest.zipline.json w archiwum zip + Nieprawidłowy format pliku plugin.json: %1$s + Nie udało się odczytać wtyczki: %1$s + Nie udało się zainstalować wtyczki: %1$s + Dostępna jest nowsza wersja %1$s. Potwierdź aktualizację przed instalacją. + %1$s jest już zainstalowana. Potwierdź zastąpienie przed instalacją. + Wbudowane wtyczki są już dołączone i nie można ich dodać ponownie. + Nie można usunąć wbudowanych wtyczek. + %1$s deklaruje nieprawidłową wartość apiVersion (%2$s). Oczekiwano wersji semantycznej zgodnej z %3$s. + Wersja API wtyczek Spotube %1$s jest nieprawidłowa. + %1$s jest przeznaczona dla API wtyczek %2$s, a Spotube obsługuje niezgodne wstecz API wtyczek %3$s. Zainstaluj wtyczkę zbudowaną dla tej samej głównej wersji API. + + + Niezgodne API wtyczki + Zainstalować wtyczkę? + %1$s zostanie dodana do zainstalowanych wtyczek. + Zaktualizować wtyczkę? + Zainstalowana jest starsza wersja (%1$s). Zaktualizować do %2$s? + Zastąpić zainstalowaną wtyczkę? + %1$s używa nieprawidłowej wersji semantycznej (%2$s) i nie można jej porównać jako aktualizacji. Mimo to zastąpić zainstalowaną wtyczkę? + Zainstalowanej wersji (%1$s) nie można porównać za pomocą wersjonowania semantycznego. Zastąpić ją wersją %2$s? + Wersja %1$s jest już zainstalowana. Zastąpić istniejącą wtyczkę dostarczoną kopią? + Zainstalowana wersja %1$s jest nowsza niż dostarczona wersja %2$s. Mimo to zastąpić? + Zastąpić zainstalowaną wtyczkę dostarczoną kopią? + + + %1$s: + Wersja + Wersja API + Autor + Licencja + Uprawnienia + Funkcje + Repozytorium + Kontakt + Zgłoś błędy + Wesprzyj %1$s + + + Oficjalna + Zweryfikowana + Gwiazdki w GitHub + Adres URL repozytorium GitHub + + + Nie znaleziono wydań + Wybierz wydanie + Wybierz wydanie + Wersja przedpremierowa + Informacje o wydaniu + + + Główna + Szukaj + Na urządzeniu + Połącz + Urządzenia + + + Ukryj okno + Pokaż okno + Następny utwór + Poprzedni utwór + Losowo + Powtarzanie + Powtarzanie: WYŁ. + Powtarzanie: JEDEN + Powtarzanie: WSZYSTKO + Głośność: %1$d% + Głośność + + Głośność - + Wycisz + Wyłącz wyciszenie + Polub utwór + Cofnij polubienie utworu + Zakończ + + + Minimalizuj + Maksymalizuj + Przywróć + Zamknij + + + Odtwarzanie + Sterowanie odtwarzaniem multimediów + + + Przeglądaj + Teraz odtwarzane + Polecane + Zapisane utwory + Popularne utwory + diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml new file mode 100644 index 00000000..0f971be6 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -0,0 +1,932 @@ + + + + Fila + Limpar fila + Filtrar fila... + A fila está vazia + + Buscar músicas, artistas, álbuns... + Limpar todo o histórico + Buscar... + + + Sua Biblioteca + Biblioteca + Playlists + Álbuns + Artistas + Arquivos locais + Downloads + Buscar playlists salvas… + Buscar álbuns salvos… + Buscar artistas salvos… + Buscar pastas ou faixas… + Filtrar biblioteca + Fechar filtro + Criar playlist + Atualizar arquivos locais + Mudar para visualização em grade + Mudar para visualização em lista + Nenhuma playlist salva + Nenhuma playlist corresponde a “%1$s” + Nenhum álbum salvo + Nenhum álbum corresponde a “%1$s” + Nenhum artista salvo + Nenhum artista corresponde a “%1$s” + + + Pastas + Pasta + %1$s • %2$s + + %1$d faixa + %1$d de faixas + %1$d faixas + + + %1$d pasta + %1$d de pastas + %1$d pastas + + Voltar para pastas + Tocar pasta + Acesso ao áudio necessário + Permita o acesso ao áudio para navegar pelas músicas do seu dispositivo. + Permitir acesso + Nenhuma pasta local encontrada + Nenhuma faixa nesta pasta + Nenhuma pasta ou faixa correspondente + Falha ao atualizar a mídia local + + + %1$d em andamento • %2$d concluídos + Limpar concluídos + Nenhum download ainda + Baixando %1$d% + Na fila + Concluído + Cancelado + Falhou + Falhou: %1$s + Cancelar download + Tentar baixar de novo + Remover download + + + Artista + Bloqueado + Músicas Curtidas + Suas músicas salvas + Artista desconhecido + Título da faixa + Nome do artista + + Selecionado + + Configurações + + Plugins + Idioma e região + Aparência + Reprodução + Downloads + Cache + Desktop + Atualizações + Apoio + Sobre + + Fechar + Salvar + Cancelar + + Idioma + Idioma da interface do app. Atual: %1$s + Padrão do sistema + País + País usado para conteúdo e disponibilidade. Atual: %1$s + %1$s (%2$s) + + Tema + Escolha como o Spotube acompanha a aparência do sistema. Atual: %1$s + Claro + Escuro + Sistema + + Superfície + Material de fundo do app. Atual: %1$s + Aquoso + Gelo + Cromado + Acrílico + Vidro + + Cor de destaque + Paleta atual: %1$s + Cor de destaque + Escolha uma paleta de destaque e veja como fica nos temas claro e escuro. + Claro + Escuro + Prévia + Botão + Duende Verde + Violeta Elétrico + Ciano Oceânico + Laranja Pôr do Sol + Jardim de Rosas + Azul Meia-Noite + Ardósia Metálica + + Visual + Material da superfície e destaque. Atual: %1$s · %2$s + Visual + Escolha um material de superfície e depois um destaque que combine com ele. Cada opção é uma combinação selecionada. + Superfície + Destaque + Secundário + + Paleta + Paleta de destaque Millennium. Atual: %1$s + Paleta + Escolha uma paleta de destaque Millennium e veja como fica no claro e no escuro. + Paleta + Paleta Millennium e destaque. Atual: %1$s + Azul + Laranja + Lima + + Pasta de download personalizada + Pasta atual: %1$s + Deixe em branco para usar a pasta de músicas padrão. + C:\Music\Spotube + Pastas de mídia local + Pastas personalizadas a serem verificadas no desktop. Adicionadas: %1$d + Nenhuma pasta personalizada adicionada ainda + Pastas personalizadas para sua biblioteca de músicas local. + Gerenciar + Adicionar pasta + Remover pasta + Nenhuma pasta personalizada adicionada. + Remover %1$s da lista de verificação + As pastas de download e de cache de músicas são incluídas automaticamente. + Sua pasta de download é incluída automaticamente. + A pasta de cache de músicas é incluída automaticamente. + Salvando… + Não foi possível salvar as pastas. Suas alterações continuam aqui; tente de novo. + + %1$d pasta + %1$d de pastas + %1$d pastas + + Formato de download das músicas + Codec e contêiner preferidos para downloads. Atual: %1$s + Qualidade de download das músicas + + Formato de streaming das músicas + Codec e contêiner preferidos para o streaming de música. Atual: %1$s + Qualidade de streaming das músicas + Atual: %1$s + Ativar cache de músicas + Armazena o áudio transmitido no dispositivo para acelerar as próximas reproduções. + Pasta de cache + Atual: %1$s + Atual: cache padrão do app + Limite de tamanho do cache + Sem limite + Limite atual: %1$d MB + Defina o tamanho máximo do cache em MB. Digite 0 ou deixe em branco para não ter limite. + 500 + O tamanho deve ser 0 ou um número positivo. + Ativar reprodução contínua + Continua tocando faixas recomendadas quando sua fila acabar. + Porta do servidor proxy de reprodução + Permitir controle remoto + Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem. + Nome do dispositivo para controle remoto + O nome que outros dispositivos veem ao encontrar este. Atual: %1$s + Escolha um nome amigável para exibir aos outros dispositivos da sua rede quando encontrarem esta instância do Spotube. + ex.: Spotube da Sala + nome do host do dispositivo + Porta usada pelo servidor proxy de reprodução. Atual: %1$d + Escolha uma porta entre 1 e 65535. + 14769 + Digite um número inteiro válido. + A porta deve estar entre 1 e 65535. + + Lista de bloqueio + Gerencie faixas e artistas bloqueados + + Minimizar para a bandeja + Mantém o Spotube em execução na bandeja do sistema quando a janela for minimizada. + Rich Presence do Discord + Mostra o que você está ouvindo no Discord no desktop. + + Verificar atualizações automaticamente + Procura novas versões do Spotube em segundo plano. + + Gerenciar plugins + Gerencie seus plugins + Plugin de %1$s padrão + Metadados + Áudio + Letras + Scrobble + Nenhum plugin selecionado + Alterar + Selecionar + Selecionado + Limpar seleção + Nenhum plugin disponível + Plugin %1$s + Limpar + + Configurar + Gerenciador de plugins + Digite uma URL + A URL deve começar com http:// ou https:// + Falha ao baixar o plugin + Nenhum plugin instalado ainda + Cole uma URL de download ou escolha um arquivo acima. + + %1$d plugin instalado + %1$d de plugins instalados + %1$d plugins instalados + + Instalar um plugin + https://example.com/plugin.smplug + Baixar + Instalar de arquivo (.smplug) + Ativo + v%1$s + Integrado + Remover plugin + Entrar + Sair + Baixar de URL + Instalar de arquivo + Instalar + + • %1$s + Permissões solicitadas + Este plugin não precisa de permissões especiais. + Instalado vs. fornecido + Instalado + Fornecido + API %1$s → %2$s + Armazenamento persistente + Ler e gravar dados no disco + Solicitações de rede + Enviar e receber dados pela internet + WebView + Exibir conteúdo da web dentro do app + Jam em grupo + Testar conexão + Testando… + Conectado em %1$d ms + Falhou: %1$s + Conexão + Diagnóstico + + + Jam em grupo + Criar + Entrar + Crie uma sala e compartilhe o código com seus amigos. + Criar sala + Entrar na sala + Conectando… + Código da sala + ABC123 + Conectado + Reconectando… + Copiar código + Copiado! + Compartilhar + Participantes (%1$d) + Anfitrião + Ouvinte + Aguardando ouvintes… + Remover + Banir + Gerenciar + Sair da sala + Não foi possível conectar + Dispensar + Falha ao criar a Jam + Falha ao entrar na Jam + Digite um código de sala com seis caracteres, usando letras e números. + Entre na minha Jam do Spotube com o código: %1$s + Servidor de salas + URL do servidor + Digite uma URL que comece com https:// + Verifique o servidor de salas antes de criar uma sala. + O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o servidor de github.com/team-spotube/jam-server e informe a URL dele. + Você + Servidor: %1$s + Defina um servidor de salas nas Configurações para criar ou entrar em uma sala. + Configurações do servidor + %1$s (você) + Seu nome + Os outros no jam veem você como %1$s + Visível para todos no jam. Deixe em branco para usar %1$s. Um novo nome vale a partir da próxima vez que você criar ou entrar em uma sala. + Use no máximo %1$d caracteres. + Nenhum servidor de jam configurado + Não foi possível acessar o servidor de jam. Verifique sua rede ou a URL do servidor em Configurações → Jam em grupo. + O servidor de jam usa uma versão incompatível. + Nenhuma sala com esse código. Confira e tente de novo. + Esta sala está cheia. + O anfitrião baniu você desta sala. + O anfitrião removeu você da sala. + Esta sala foi aberta de novo neste dispositivo, então esta conexão foi encerrada. + + Ir para o álbum + Timer + Detalhes da faixa + Tocar + Pausar + Nada tocando + Adicionar à fila + + Ignorar + Atualizar + Spotube %1$s está disponível + Veja as novidades desta versão na página de downloads do Spotube. + %1$s %2$s está disponível + Versão instalada: %1$s + Veja as novidades desta atualização na página do plugin. + + Pular + Avançar + Voltar + Começar + Spotube + Música, sem amarras. + Um player de código aberto para um mundo de música.\nPara você explorar. Para você deixar do seu jeito. + Mantenha este projeto vivo. + O Spotube é feito por pessoas, não por uma empresa de assinaturas. Se ele fizer parte do seu dia a dia, um pequeno apoio nos ajuda a dar a ele o cuidado que merece.\n\nObrigado por abrir espaço para software independente. + Comece com um plugin de catálogo para encontrar músicas e playlists. Escolha um plugin de áudio para tocá-las. Letras e histórico de reprodução são opcionais. Descubra um plugin abaixo, revise as permissões, instale-o e depois selecione-o aqui. + Descobrir plugins + Suas fontes + Tentar de novo + Mais plugins + Nenhum plugin novo encontrado. Você também pode instalar um a partir de um link ou de um arquivo .smplug acima. + A descoberta de plugins está indisponível no momento. Tente de novo ou instale a partir de um link ou arquivo. + Boas-vindas + Apoio + Plugins + Guia + + Como usar o Spotube + Quatro passos rápidos e você já está ouvindo. + Entrar + Conecte sua conta ou escolha seu próprio plugin de catálogo. + Busque e toque + Encontre qualquer música, álbum ou artista e aperte o play. + Ouça offline + Baixe faixas para continuar ouvindo sem conexão. + Acompanhe a letra + Abra a letra sincronizada no player enquanto a faixa toca. + + + Sua música. Suas fontes. Suas regras. + Um player de música gratuito e de código aberto, feito por uma comunidade que simplesmente ama música. + Código aberto + Sem anúncios + Sem telemetria + Movido a plugins + Vamos lá + + + Apoiadores nos mantêm de pé + Feito com paixão. Mantido vivo por você. + O Spotube é 100% gratuito, livre e de código aberto — sem anúncios, sem assinaturas, sem rastreamento. Cada recurso existe porque pessoas como você decidiram ajudar a manter o código fluindo. + Patreon + Apoio mensal + Apoie o desenvolvimento contínuo e a infraestrutura + Buy Me a Coffee + Um café avulso + Dê energia a uma sessão de programação noturna com uma gorjeta rápida + Open Collective + Fundo da comunidade + Apoio financeiro transparente e gerido pela comunidade + Do fundo do coração, obrigado por acreditar na música aberta. + Apoiar é totalmente opcional — o Spotube continua gratuito de qualquer forma. + Apoiar + Abrir %1$s + + + Apoiar o Spotube + Apoie o Spotube + O Spotube é gratuito, livre e de código aberto — sem anúncios, sem assinaturas, sem rastreamento. Se ele te traz alegria, considere apoiar o projeto. + Você pode desativar esses lembretes em Configurações → Apoio. + Agora não + Lembretes de apoio + Mostra um lembrete gentil para apoiar o projeto a cada 7 dias. + + + Arquitetura modular + Escolha de onde vem a sua música. + O Spotube separa a descoberta do catálogo, o streaming de áudio e as letras sincronizadas. Configure seus motores abaixo ou troque-os quando quiser. + Motor de metadados + Alimenta a descoberta de músicas, a busca, as listas de faixas e as playlists. + Motor de áudio + Obtém e decodifica os streams de áudio que tocam. + Motor de letras + Transmite letras de karaokê sincronizadas em tempo real. + Motor de scrobble + Registra o histórico de reprodução no Last.fm ou no ListenBrainz. + Ativo + Integrado + Pronto + Opcional + Selecione um motor + Motores instalados + Definir como padrão + NewPipe YouTube e LRCLib Lyrics já vêm integrados e prontos para usar. + Gerencie ou adicione novos plugins da comunidade quando quiser em Configurações → Plugins. + + + Arquitetura retrô-moderna + Hi-Fi puro, zero intermediários + Veja como o Spotube orquestra a reprodução descentralizada diretamente na sua máquina. + PEER-TO-PEER DIRETO • OPUS 256 KBPS + Hora azul + Spotube · demonstração do player + Um pouco de música para os momentos entre um e outro. + 1. Descoberta descentralizada + As buscas vão direto para o plugin de catálogo que você escolheu. O Spotube nunca traça o perfil do seu gosto. + 2. Resolução direta do stream + O áudio vem direto dos servidores de conteúdo para a sua máquina. Sem saltos por proxy nem coleta remota de dados. + 3. DSP e reprodução locais + Decodificado e armazenado em cache localmente, com controles de equalizador e download offline opcional. + 4. Letras sincronizadas e Jam + As letras de karaokê acompanham o tempo da reprodução, com controle remoto via LAN e Jam em grupo. + Sem servidores intermediários. A reprodução acontece no seu dispositivo — privado por natureza. + + + Escolha um dispositivo + %1$s: “%2$s” + Tocar + Adicionar à fila + Tocar em seguida + Este dispositivo + Neste dispositivo + Dispositivo remoto + Dispositivo remoto (%1$s) + No dispositivo conectado + + + Falha na conexão + Conexão perdida + Falha no envio + Falha na descoberta: %1$s + Falha ao conectar: %1$s + + + Dispositivos + Atualizar + Desconectar + Conectando... + Procurando dispositivos na rede... + Nenhum dispositivo encontrado + Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações. + Dispositivo desconhecido + Resolvendo... + + + Controle remoto + Desconectado + Erro de conexão: %1$s + Faixa desconhecida + Artista desconhecido + Volume + Remover da fila + Mais opções + + + Tocar onde? + Você tem uma Jam ativa. Escolha onde tocar esta coleção. + Tocar aqui + Sugerir na Jam + + + Falha na conexão + Conexão perdida + + + + Adicionada à fila da Jam + + %1$d faixa adicionada à fila da Jam + %1$d de faixas adicionadas à fila da Jam + %1$d faixas adicionadas à fila da Jam + + + + Solicitação de controle remoto + \"%1$s\" quer controlar a reprodução neste dispositivo. + Negar + Permitir + Sempre permitir + + + Resultados da busca + Tudo + Faixas + Usuários + Nenhum tipo de busca disponível + Comece a digitar para buscar + Comece a digitar para buscar faixas + Nenhum resultado encontrado + Ver tudo + Buscando + Carregando faixas... + Carregando álbuns... + Carregando artistas... + Carregando playlists... + Carregando usuários... + Nenhum álbum encontrado + Nenhum artista encontrado + Nenhuma playlist encontrada + Nenhum usuário encontrado + Garimpando nos caixotes de discos... + Vasculhando o cosmos... + Aquecendo as caixas de som... + Tirando o pó do vinil... + Consultando os deuses da música... + Sintonizando as antenas... + Folheando os discos... + Falha ao carregar os recursos de busca + Falha na busca + Falha ao buscar faixas + Falha ao buscar álbuns + Falha ao buscar artistas + Falha ao buscar playlists + Falha ao buscar usuários + + + Explorar + + + Sincronizada + Simples + Nenhuma letra sincronizada disponível + Nenhuma letra simples disponível + + + %1$s em %2$s + %1$d kbps + %1$s kHz • %2$d canais + Nenhum plugin de metadados selecionado + + + Voltar + Tentar de novo + Fechar caixa de diálogo + Carregando... + Desconhecido + Erro desconhecido + + + Usuário + Playlist + + + Pedimos sinceras desculpas + Algo não está funcionando agora. Sua experiência importa para nós, e estamos fazendo o possível para resolver isso. Tente de novo ou confira os detalhes do erro abaixo. + Detalhes do erro + Copiar para a área de transferência + Ver log completo + Log de erro completo + + + Aleatório + Tocar em ordem aleatória + Seguir + Deixar de seguir + Editar playlist + De %1$s + + + Opções da faixa + Adicionar à Jam + Iniciar rádio + Tocar em seguida + Mover para a próxima + Remover da fila + Remover dos favoritos + Salvar nos favoritos + Adicionar à playlist + Baixar + Compartilhar + + + Original + Título + Artista + Álbum + Duração + Ordenar + Nenhuma faixa encontrada + Filtrar + Ações em massa + Baixar tudo + Baixar %1$d + Adicionar tudo à fila + Adicionar %1$d à fila + Tocar tudo em seguida + Tocar %1$d em seguida + Adicionar tudo à playlist + Adicionar %1$d à playlist + Adicionar tudo à Jam + Adicionar %1$d à Jam + +%1$d outros + + + Nenhuma URL para carregar + Voltar no histórico do navegador + Avançar no histórico do navegador + + + Filtrar lista de bloqueio... + Nenhum item bloqueado + Nenhum item bloqueado corresponde a \'%1$s\' + Faixas + Faixa + Adicionar à lista de bloqueio + Remover da lista de bloqueio + + + Sem metadados da faixa + Não foi possível obter a URL do stream + Não foi possível obter a URL do stream. Tente tocar a faixa primeiro. + + + Faixas salvas + Você + + + Seguindo + Mais tocadas + Tocar as mais tocadas + Adicionar as mais tocadas à fila + Artistas relacionados + Playlists em destaque + Falha ao carregar a visão geral do artista + Seguidores indisponíveis + %1$s mil seguidores + %1$s mi de seguidores + %1$s bi de seguidores + + %1$d seguidor + %1$d de seguidores + %1$d seguidores + + + + Curtir + Descurtir + Título de faixa genérico + Artistas genéricos + Álbum desconhecido + Avançar/retroceder + Volume + Ativar ordem aleatória + Desativar ordem aleatória + Anterior + Próxima + Tocar ou pausar + Repetição desativada + Repetir uma + Repetir todas + Silenciar + Ativar som + Mais opções + Opções do player + Recolher player + Expandir letra + Tocando agora + Letra + Fonte alternativa + Fonte alternativa + Baixar + Baixada + Baixando... + Já baixada + Tentando baixar de novo... + Baixando %1$s + A reprodução será pausada em %1$s. + Pausa a reprodução após o número de minutos selecionado. + Minutos + Escolha entre 1 e 600 minutos + Cancelar timer + Iniciar + A URL de reprodução foi obtida com outras configurações de streaming. O stream destacado corresponde à sua preferência atual; a URL ativa será atualizada na próxima solicitação de stream. + Fonte de áudio correspondente + Carregando fonte correspondente… + Uma fonte remota correspondente não é necessária enquanto o arquivo em cache local estiver tocando. + Nenhuma fonte correspondente disponível. + Nenhuma fonte de áudio correspondente está em cache para esta faixa. + Os detalhes do stream ainda não estão em cache. Toque a faixa primeiro para carregá-los. + Falha ao carregar os detalhes da faixa. + Streams disponíveis + Esta faixa está sendo reproduzida do cache local. As variantes de stream não são usadas nesta reprodução. + Carregando streams… + Nenhum stream disponível para esta fonte. + Stream selecionado no momento + Corresponde à sua preferência de formato e qualidade de streaming + URL de reprodução ativa + Reproduzida do cache local + Faixa nos metadados + Título + ID + Álbum + Confiança + URI externo + ID do álbum + Tipo de álbum + Data de lançamento do álbum + Número de faixas do álbum + Gêneros do álbum + Duração + Número da faixa + Número do disco + Explícito + Popularidade + ISRC + URLs das miniaturas + + + Alternar barra lateral + + + Fontes alternativas + Nenhuma faixa tocando no momento + Procurando fontes alternativas... + Nenhuma fonte alternativa encontrada + Confiança: %1$d% + Fonte ativa + + + Expulsar + Remove a pessoa da sessão + Expulsa e impede que a pessoa volte a entrar + Remover sugestões + Remove todas as faixas que a pessoa adicionou à fila + Reordenar + Remover da fila + %1$s • Adicionada por %2$s + + + Desconhecido + Carregando playlist... + Adicionar faixas + Adicionar faixas à playlist + Buscar faixas... + Buscar + Nenhuma faixa encontrada + Busque faixas para adicionar + Concluído + Adicionar + Adicionada + + + Carregando álbum... + + + Adicionar à sua playlist + Buscar + Buscar nas suas playlists... + Carregando suas playlists... + Você ainda não tem nenhuma playlist.\nCrie uma para adicionar faixas. + Criar playlist + Editar playlist + Criar + Nome + Nome da playlist + Descrição + Descrição (opcional) + Pública + Qualquer pessoa pode ver esta playlist + Colaborativa + Outras pessoas podem adicionar faixas a esta playlist + Imagem de capa + Capa da playlist + Sem imagem + Alterar imagem + Escolher imagem + + + Instalar + Substituir + plugin.json não encontrado no arquivo zip + manifest.zipline.json não encontrado no arquivo zip + Formato de plugin.json inválido: %1$s + Falha ao ler o plugin: %1$s + Falha ao instalar o plugin: %1$s + Há uma versão mais recente de %1$s disponível. Confirme a atualização antes de instalar. + %1$s já está instalado. Confirme a substituição antes de instalar. + Os plugins integrados já estão incluídos e não podem ser adicionados de novo. + Os plugins integrados não podem ser removidos. + %1$s declara uma apiVersion inválida (%2$s). Era esperado um versionamento semântico compatível com %3$s. + A versão %1$s da API de plugins do Spotube é inválida. + %1$s foi feito para a API de plugins %2$s, mas o Spotube suporta a versão principal %3$s da API. Instale um plugin feito para a mesma versão principal da API. + + + API do plugin incompatível + Instalar plugin? + %1$s será adicionado aos seus plugins instalados. + Atualizar plugin? + Uma versão mais antiga está instalada (%1$s). Atualizar para %2$s? + Substituir plugin instalado? + %1$s usa uma versão semântica inválida (%2$s) e não pode ser comparado como uma atualização. Substituir o plugin instalado mesmo assim? + A versão instalada (%1$s) não pode ser comparada usando versionamento semântico. Substituí-la por %2$s? + A versão %1$s já está instalada. Substituir o plugin existente pela cópia fornecida? + A versão instalada %1$s é mais recente que a versão fornecida %2$s. Substituir mesmo assim? + Substituir o plugin instalado pela cópia fornecida? + + + %1$s: + Versão + Versão da API + Autor + Licença + Permissões + Funções + Repositório + Contato + Relatar bugs + Apoiar %1$s + + + Oficial + Verificado + Estrelas no GitHub + URL do repositório no GitHub + + + Nenhuma versão encontrada + Selecionar versão + Selecione uma versão + Pré-lançamento + Notas da versão + + + Início + Buscar + No dispositivo + Conectar + Dispositivos + + + Ocultar janela + Mostrar janela + Próxima faixa + Faixa anterior + Ordem aleatória + Repetir + Repetir: DESATIVADO + Repetir: UMA + Repetir: TODAS + Volume: %1$d% + Volume + + Volume - + Silenciar + Ativar som + Curtir faixa + Descurtir faixa + Sair + + + Minimizar + Maximizar + Restaurar + Fechar + + + Reprodução + Controles de reprodução de mídia + + + Explorar + Tocando agora + Em destaque + Faixas salvas + Mais tocadas + diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml new file mode 100644 index 00000000..b413416b --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -0,0 +1,938 @@ + + + + Очередь + Очистить очередь + Фильтр очереди... + Очередь пуста + + Треки, исполнители, альбомы... + Очистить историю + Поиск... + + + Моя медиатека + Медиатека + Плейлисты + Альбомы + Исполнители + Локальные треки + Загрузки + Поиск по сохранённым плейлистам… + Поиск по сохранённым альбомам… + Поиск по сохранённым исполнителям… + Поиск папок или треков… + Фильтр медиатеки + Закрыть фильтр + Создать плейлист + Обновить локальные треки + Показать сеткой + Показать списком + Нет сохранённых плейлистов + Нет плейлистов по запросу «%1$s» + Нет сохранённых альбомов + Нет альбомов по запросу «%1$s» + Нет сохранённых исполнителей + Нет исполнителей по запросу «%1$s» + + + Папки + Папка + %1$s • %2$s + + %1$d трек + %1$d трека + %1$d треков + %1$d трека + + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Назад к папкам + Слушать папку + Нужен доступ к аудио + Разрешите доступ к аудио, чтобы просматривать музыку на устройстве. + Разрешить доступ + Локальные папки не найдены + В этой папке нет треков + Подходящих папок или треков нет + Не удалось обновить локальные файлы + + + Активных: %1$d • Завершённых: %2$d + Очистить завершённые + Загрузок пока нет + Загрузка %1$d% + В очереди + Завершено + Отменено + Ошибка + Ошибка: %1$s + Отменить загрузку + Повторить загрузку + Удалить загрузку + + + Исполнитель + В чёрном списке + Любимые треки + Ваши сохранённые треки + Неизвестный исполнитель + Название трека + Имя исполнителя + + Выбрано + + Настройки + + Плагины + Язык и регион + Оформление + Воспроизведение + Загрузки + Кеширование + Компьютер + Обновления + Поддержка + О приложении + + Закрыть + Сохранить + Отмена + + Язык + Язык интерфейса приложения. Сейчас: %1$s + Как в системе + Страна + Страна для контента и доступности. Сейчас: %1$s + %1$s (%2$s) + + Тема + Как Spotube подстраивается под тему системы. Сейчас: %1$s + Светлая + Тёмная + Системная + + Поверхность + Фоновый материал приложения. Сейчас: %1$s + Вода + Лёд + Хром + Акрил + Стекло + + Акцентный цвет + Текущая палитра: %1$s + Акцентный цвет + Выберите акцентную палитру и посмотрите, как она выглядит в светлой и тёмной темах. + Светлая + Тёмная + Предпросмотр + Кнопка + Зелёный гоблин + Электрик-фиолетовый + Океанский циан + Оранжевый закат + Розовый сад + Полночный синий + Металлик-графит + + Внешний вид + Материал поверхности и акцент. Сейчас: %1$s · %2$s + Внешний вид + Выберите материал поверхности, а затем подходящий к нему акцент. Все варианты — продуманные сочетания. + Поверхность + Акцент + Вторичная + + Палитра + Акцентная палитра Millennium. Сейчас: %1$s + Палитра + Выберите акцентную палитру Millennium и посмотрите её в светлой и тёмной темах. + Палитра + Палитра Millennium и акцент. Сейчас: %1$s + Синяя + Оранжевая + Лаймовая + + Своя папка для загрузок + Текущая папка: %1$s + Оставьте пустым, чтобы использовать стандартную папку «Музыка». + C:\Music\Spotube + Папки с локальной музыкой + Дополнительные папки для сканирования на компьютере. Добавлено: %1$d + Дополнительные папки ещё не добавлены + Дополнительные папки для вашей локальной медиатеки. + Управлять + Добавить папку + Удалить папку + Дополнительные папки не добавлены. + Убрать %1$s из списка сканирования + Папки загрузок и музыкального кеша добавляются автоматически. + Папка загрузок добавляется автоматически. + Папка музыкального кеша добавляется автоматически. + Сохранение… + Не удалось сохранить папки. Изменения не потеряны — попробуйте ещё раз. + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Формат загрузки музыки + Предпочтительный кодек и контейнер для загрузок. Сейчас: %1$s + Качество загрузки музыки + + Формат стриминга музыки + Предпочтительный кодек и контейнер для стриминга. Сейчас: %1$s + Качество стриминга музыки + Сейчас: %1$s + Кешировать музыку + Сохранять прослушанное аудио на устройстве для быстрого повторного воспроизведения. + Папка кеша + Сейчас: %1$s + Сейчас: стандартный кеш приложения + Лимит размера кеша + Без ограничений + Текущий лимит: %1$d MB + Укажите максимальный размер кеша в MB. Введите 0 или оставьте пустым, чтобы снять ограничение. + 500 + Размер должен быть 0 или положительным числом. + Бесконечное воспроизведение + Продолжать воспроизведение рекомендованными треками, когда очередь закончится. + Порт прокси-сервера воспроизведения + Разрешить удалённое управление + Разрешить другим устройствам в той же сети управлять воспроизведением после подключения. + Имя устройства для удалённого управления + Имя, которое видят другие устройства при поиске. Сейчас: %1$s + Задайте понятное имя, которое будут видеть другие устройства в вашей сети при обнаружении этого Spotube. + например, Spotube в гостиной + имя хоста устройства + Порт прокси-сервера воспроизведения. Сейчас: %1$d + Укажите порт от 1 до 65535. + 14769 + Введите целое число. + Порт должен быть от 1 до 65535. + + Чёрный список + Управление заблокированными треками и исполнителями + + Сворачивать в трей + Оставлять Spotube в системном трее при сворачивании окна. + Статус в Discord + Показывать в Discord, что сейчас играет (на компьютере). + + Автоматически проверять обновления + Искать новые версии Spotube в фоновом режиме. + + Управление плагинами + Управляйте своими плагинами + Плагин по умолчанию: %1$s + Метаданные + Аудио + Тексты песен + Скробблинг + Плагин не выбран + Изменить + Выбрать + Выбран + Сбросить выбор + Нет доступных плагинов + Плагин %1$s + Сбросить + + Настроить + Менеджер плагинов + Введите URL + URL должен начинаться с http:// или https:// + Не удалось скачать плагин + Плагины ещё не установлены + Вставьте ссылку для загрузки или выберите файл выше. + + %1$d плагин установлен + %1$d плагина установлено + %1$d плагинов установлено + %1$d плагина установлено + + Установка плагина + https://example.com/plugin.smplug + Скачать + Установить из файла (.smplug) + Активен + v%1$s + Встроенный + Удалить плагин + Войти + Выйти + Скачать по URL + Установить из файла + Установить + + • %1$s + Запрашиваемые разрешения + Этому плагину не нужны особые разрешения. + Установленный и новый + Установлен + Новый + API %1$s → %2$s + Постоянное хранилище + Чтение и запись данных на диск + Сетевые запросы + Отправка и получение данных через интернет + WebView + Показ веб-контента внутри приложения + Групповой джем + Проверить подключение + Проверка… + Подключено за %1$d мс + Ошибка: %1$s + Подключение + Диагностика + + + Групповой джем + Создать + Присоединиться + Создайте комнату и поделитесь её кодом с друзьями. + Создать комнату + Войти в комнату + Подключение… + Код комнаты + ABC123 + Подключено + Переподключение… + Копировать код + Скопировано! + Поделиться + Участники (%1$d) + Организатор + Слушатель + Ждём слушателей… + Удалить + Заблокировать + Управлять + Покинуть комнату + Не удалось подключиться + Закрыть + Не удалось создать джем + Не удалось присоединиться к джему + Введите код комнаты из шести букв и цифр. + Присоединяйся к моему джему в Spotube по коду: %1$s + Сервер комнат + URL сервера + Введите URL, начинающийся с https:// + Проверьте сервер комнат перед созданием комнаты. + Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите сервер из github.com/team-spotube/jam-server и укажите его URL. + Вы + Сервер: %1$s + Укажите сервер комнат в настройках, чтобы создать комнату или присоединиться к ней. + Настройки сервера + %1$s (вы) + Ваше имя + Другие участники джема видят вас как %1$s + Видно всем в джеме. Оставьте пустым, чтобы использовать %1$s. Новое имя применится, когда вы в следующий раз создадите комнату или присоединитесь к ней. + Не больше %1$d символов. + Сервер джема не настроен + Не удалось подключиться к серверу джема. Проверьте сеть или URL сервера в разделе Настройки → Групповой джем. + Сервер джема использует несовместимую версию. + Комнаты с таким кодом нет. Проверьте код и попробуйте снова. + Эта комната заполнена. + Организатор заблокировал вас в этой комнате. + Организатор удалил вас из комнаты. + Эта комната была снова открыта на этом устройстве, поэтому это подключение закрыто. + + Перейти к альбому + Таймер сна + Сведения о треке + Воспроизвести + Пауза + Сейчас ничего не играет + Добавить в очередь + + Пропустить + Обновить + Доступен Spotube %1$s + Узнайте, что нового в этой версии, на странице загрузок Spotube. + Доступен %1$s %2$s + Установлена версия: %1$s + Узнайте, что нового в этом обновлении, на странице плагина. + + Пропустить + Далее + Назад + Начать + Spotube + Музыка без лишних условий. + Плеер с открытым исходным кодом для целого мира музыки.\nИсследуйте его. Сделайте его своим. + Помогите проекту жить. + Spotube создают люди, а не бизнес на подписках. Если он стал частью вашей жизни, даже небольшая поддержка поможет нам уделять ему то внимание, которого он заслуживает.\n\nСпасибо, что находите место для независимого софта. + Начните с плагина каталога, чтобы находить треки и плейлисты. Выберите аудиоплагин для их воспроизведения. Тексты песен и история прослушиваний — по желанию. Найдите плагин ниже, проверьте его разрешения, установите и выберите здесь. + Обзор плагинов + Ваши источники + Повторить + Другие плагины + Новых плагинов не найдено. Плагин также можно установить по ссылке или из файла .smplug выше. + Каталог плагинов сейчас недоступен. Повторите попытку или установите плагин по ссылке или из файла. + Добро пожаловать + Поддержка + Плагины + Руководство + + Как пользоваться Spotube + Четыре простых шага — и музыка уже играет. + Вход + Подключите аккаунт или выберите свой плагин каталога. + Ищите и слушайте + Найдите любой трек, альбом или исполнителя и нажмите «Воспроизвести». + Слушайте офлайн + Скачивайте треки, чтобы слушать их без интернета. + Следите за текстом + Открывайте синхронизированный текст в плеере во время воспроизведения. + + + Ваша музыка. Ваши источники. Ваши правила. + Бесплатный музыкальный плеер с открытым исходным кодом от сообщества, которое просто любит музыку. + Открытый код + Без рекламы + Без телеметрии + На плагинах + Поехали + + + Нас поддерживают слушатели + Создан с любовью. Живёт благодаря вам. + Spotube на 100% бесплатный, свободный и открытый — без рекламы, подписок и слежки. Каждая функция существует благодаря таким людям, как вы, которые помогают проекту развиваться. + Patreon + Ежемесячная поддержка + Поддержите постоянную разработку и инфраструктуру + Buy Me a Coffee + Разовый кофе + Взбодрите ночную сессию программирования небольшим донатом + Open Collective + Фонд сообщества + Прозрачное финансирование под управлением сообщества + От всего сердца спасибо, что верите в открытую музыку. + Поддержка полностью добровольная — Spotube в любом случае останется бесплатным. + Поддержать + Открыть %1$s + + + Поддержать Spotube + Поддержите Spotube + Spotube бесплатный, свободный и открытый — без рекламы, подписок и слежки. Если он приносит вам радость, поддержите проект. + Эти напоминания можно отключить в разделе «Настройки → Поддержка». + Может, позже + Напоминания о поддержке + Раз в 7 дней ненавязчиво напоминать о поддержке проекта. + + + Модульная архитектура + Выберите, откуда брать музыку. + В Spotube каталог, стриминг аудио и синхронизированные тексты работают отдельно. Настройте движки ниже или меняйте их в любой момент. + Движок метаданных + Отвечает за поиск музыки, списки треков и плейлисты. + Аудиодвижок + Получает и декодирует аудиопотоки для воспроизведения. + Движок текстов + Показывает синхронизированные караоке-тексты в реальном времени. + Движок скробблинга + Записывает историю прослушиваний в Last.fm или ListenBrainz. + Активен + Встроенный + Готов + Необязательно + Выберите движок + Установленные движки + Сделать основным + NewPipe YouTube и LRCLib Lyrics встроены и работают сразу после установки. + Управлять плагинами сообщества и добавлять новые можно в любой момент в разделе «Настройки → Плагины». + + + Ретромодерн-архитектура + Чистый Hi-Fi без посредников + Вот как Spotube организует децентрализованное воспроизведение прямо на вашем устройстве. + НАПРЯМУЮ, P2P • 256 KBPS OPUS + Синий час + Spotube · демонстрация плеера + Немного музыки для мгновений между делами. + 1. Децентрализованный поиск + Запросы идут напрямую в выбранный плагин каталога. Spotube никогда не анализирует ваши вкусы. + 2. Прямое получение потока + Аудио поступает напрямую с серверов контента на ваше устройство. Никаких прокси и удалённого сбора данных. + 3. Локальная обработка и воспроизведение + Декодирование и кеширование на устройстве, эквалайзер и загрузка для офлайн-прослушивания по желанию. + 4. Синхронные тексты и джем + Караоке-тексты следуют за воспроизведением, а ещё есть удалённое управление по LAN и групповой джем. + Никаких серверов-посредников. Воспроизведение происходит прямо на вашем устройстве — приватность по умолчанию. + + + Выберите устройство + %1$s: «%2$s» + Воспроизвести + Добавить в очередь + Воспроизвести следующим + Это устройство + На этом устройстве + Удалённое устройство + Удалённое устройство (%1$s) + На подключённом устройстве + + + Не удалось подключиться + Соединение потеряно + Не удалось отправить + Ошибка поиска: %1$s + Не удалось подключиться: %1$s + + + Устройства + Обновить + Отключиться + Подключение... + Поиск устройств в сети... + Устройства не найдены + Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление». + Неизвестное устройство + Определение адреса... + + + Удалённое управление + Отключено + Ошибка подключения: %1$s + Неизвестный трек + Неизвестный исполнитель + Громкость + Удалить из очереди + Другие действия + + + Где воспроизвести? + У вас активный джем. Выберите, где воспроизвести эту подборку. + Воспроизвести здесь + Предложить в джем + + + Не удалось подключиться + Соединение потеряно + + + + Добавлено в очередь джема + + %1$d трек добавлен в очередь джема + %1$d трека добавлено в очередь джема + %1$d треков добавлено в очередь джема + %1$d трека добавлено в очередь джема + + + + Запрос на удалённое управление + «%1$s» хочет управлять воспроизведением на этом устройстве. + Отклонить + Разрешить + Разрешать всегда + + + Результаты поиска + Все + Треки + Пользователи + Нет доступных типов поиска + Начните вводить запрос + Начните вводить, чтобы найти треки + Ничего не найдено + Показать все + Поиск + Загрузка треков... + Загрузка альбомов... + Загрузка исполнителей... + Загрузка плейлистов... + Загрузка пользователей... + Альбомы не найдены + Исполнители не найдены + Плейлисты не найдены + Пользователи не найдены + Роемся в ящиках с пластинками... + Ищем по всей вселенной... + Прогреваем колонки... + Сдуваем пыль с винила... + Советуемся с богами музыки... + Настраиваем антенны... + Перебираем пластинки... + Не удалось загрузить возможности поиска + Не удалось выполнить поиск + Не удалось найти треки + Не удалось найти альбомы + Не удалось найти исполнителей + Не удалось найти плейлисты + Не удалось найти пользователей + + + Обзор + + + Синхронный + Простой + Синхронизированный текст недоступен + Текст недоступен + + + %1$s в %2$s + %1$d kbps + %1$s kHz • %2$d кан. + Плагин метаданных не выбран + + + Назад + Повторить + Закрыть окно + Загрузка... + Неизвестно + Неизвестная ошибка + + + Пользователь + Плейлист + + + Нам очень жаль + Что-то пошло не так. Нам важно ваше впечатление, и мы делаем всё, чтобы это исправить. Попробуйте ещё раз или посмотрите подробности ошибки ниже. + Подробности ошибки + Копировать в буфер обмена + Показать полный журнал + Полный журнал ошибки + + + Перемешать + Перемешать и слушать + Подписаться + Отписаться + Изменить плейлист + Автор: %1$s + + + Действия с треком + Добавить в джем + Запустить радио + Воспроизвести следующим + Сделать следующим + Удалить из очереди + Удалить из избранного + Добавить в избранное + Добавить в плейлист + Скачать + Поделиться + + + Исходный порядок + Название + Исполнитель + Альбом + Длительность + Сортировка + Треки не найдены + Фильтр + Действия с выбранными + Скачать все + Скачать (%1$d) + Добавить все в очередь + Добавить в очередь (%1$d) + Воспроизвести все следующими + Воспроизвести следующими (%1$d) + Добавить все в плейлист + Добавить в плейлист (%1$d) + Добавить все в джем + Добавить в джем (%1$d) + и ещё %1$d + + + Нет URL для загрузки + Назад по истории браузера + Вперёд по истории браузера + + + Фильтр чёрного списка... + Чёрный список пуст + В чёрном списке нет совпадений с «%1$s» + Треки + Трек + Добавить в чёрный список + Удалить из чёрного списка + + + Нет метаданных трека + Не удалось получить URL потока + Не удалось получить URL потока. Сначала попробуйте воспроизвести трек. + + + Сохранённые треки + Вы + + + Вы подписаны + Популярные треки + Слушать популярные треки + Добавить популярные треки в очередь + Похожие исполнители + Рекомендуемые плейлисты + Не удалось загрузить страницу исполнителя + Число подписчиков недоступно + Подписчиков: %1$s тыс. + Подписчиков: %1$s млн + Подписчиков: %1$s млрд + + %1$d подписчик + %1$d подписчика + %1$d подписчиков + %1$d подписчика + + + + Нравится + Больше не нравится + Название трека-заглушки + Исполнители-заглушки + Неизвестный альбом + Перемотка + Громкость + Включить перемешивание + Выключить перемешивание + Предыдущий + Следующий + Воспроизвести или приостановить + Повтор выключен + Повтор трека + Повтор всех + Выключить звук + Включить звук + Другие действия + Параметры плеера + Свернуть плеер + Развернуть текст + Сейчас играет + Текст + Альтернативный источник + Альтернативный источник + Скачать + Скачано + Загрузка... + Уже скачано + Повторная загрузка... + Загрузка: %1$s + Воспроизведение остановится через %1$s. + Поставить воспроизведение на паузу через выбранное число минут. + Минуты + От 1 до 600 минут + Отменить таймер + Запустить + URL воспроизведения был получен с другими настройками стриминга. Выделенный поток соответствует текущим предпочтениям; активный URL обновится при следующем запросе потока. + Подобранный источник аудио + Загрузка подобранного источника… + Удалённый источник не нужен, пока воспроизводится локальный кешированный файл. + Подходящий источник недоступен. + Для этого трека нет кешированного подобранного источника аудио. + Сведения о потоках ещё не закешированы. Сначала воспроизведите трек, чтобы загрузить их. + Не удалось загрузить сведения о треке. + Доступные потоки + Этот трек воспроизводится из локального кеша. Варианты потоков сейчас не используются. + Загрузка потоков… + Для этого источника нет доступных потоков. + Выбранный поток + Соответствует вашим настройкам формата и качества стриминга + Активный URL воспроизведения + Из локального кеша + Трек из метаданных + Название + ID + Альбом + Точность совпадения + Внешний URI + ID альбома + Тип альбома + Дата выхода альбома + Треков в альбоме + Жанры альбома + Длительность + Номер трека + Номер диска + Ненормативный контент + Популярность + ISRC + URL миниатюр + + + Показать или скрыть боковую панель + + + Альтернативные источники + Сейчас ничего не играет + Поиск альтернативных источников... + Альтернативные источники не найдены + Совпадение: %1$d% + Текущий источник + + + Исключить + Удалить участника из сессии + Исключить и запретить возвращаться + Удалить предложения + Удалить все треки, которые участник добавил в очередь + Изменить порядок + Удалить из очереди + %1$s • Добавил(а) %2$s + + + Неизвестно + Загрузка плейлиста... + Добавить треки + Добавить треки в плейлист + Поиск треков... + Поиск + Треки не найдены + Найдите треки, чтобы добавить их + Готово + Добавить + Добавлено + + + Загрузка альбома... + + + Добавить в ваш плейлист + Поиск + Поиск по вашим плейлистам... + Загрузка ваших плейлистов... + У вас пока нет своих плейлистов.\nСоздайте плейлист, чтобы добавлять в него треки. + Новый плейлист + Изменить плейлист + Создать + Название + Название плейлиста + Описание + Описание (необязательно) + Открытый + Этот плейлист виден всем + Совместный + Другие могут добавлять треки в этот плейлист + Обложка + Обложка плейлиста + Нет изображения + Сменить изображение + Выбрать изображение + + + Установить + Заменить + В zip-архиве не найден plugin.json + В zip-архиве не найден manifest.zipline.json + Неверный формат plugin.json: %1$s + Не удалось прочитать плагин: %1$s + Не удалось установить плагин: %1$s + Доступна новая версия %1$s. Подтвердите обновление перед установкой. + %1$s уже установлен. Подтвердите замену перед установкой. + Встроенные плагины уже включены, их нельзя добавить повторно. + Встроенные плагины нельзя удалить. + %1$s указывает недопустимую apiVersion (%2$s). Ожидается семантическая версия, совместимая с %3$s. + Недопустимая версия API плагинов Spotube: %1$s. + %1$s рассчитан на API плагинов %2$s, а Spotube поддерживает несовместимую версию API %3$s. Установите плагин, собранный для той же основной версии API. + + + Несовместимый API плагина + Установить плагин? + %1$s будет добавлен к установленным плагинам. + Обновить плагин? + Установлена более старая версия (%1$s). Обновить до %2$s? + Заменить установленный плагин? + %1$s использует недопустимую семантическую версию (%2$s), поэтому её нельзя сравнить как обновление. Всё равно заменить установленный плагин? + Установленную версию (%1$s) нельзя сравнить по правилам семантического версионирования. Заменить её на %2$s? + Версия %1$s уже установлена. Заменить существующий плагин новой копией? + Установленная версия %1$s новее предложенной версии %2$s. Всё равно заменить? + Заменить установленный плагин новой копией? + + + %1$s: + Версия + Версия API + Автор + Лицензия + Разрешения + Функции + Репозиторий + Контакты + Сообщить об ошибке + Поддержать %1$s + + + Официальный + Проверенный + Звёзды на GitHub + URL репозитория на GitHub + + + Релизы не найдены + Выберите релиз + Выберите релиз + Предварительный релиз + Примечания к выпуску + + + Главная + Поиск + На устройстве + Подключения + Устройства + + + Скрыть окно + Показать окно + Следующий трек + Предыдущий трек + Перемешать + Повтор + Повтор: ВЫКЛ + Повтор: ТРЕК + Повтор: ВСЕ + Громкость: %1$d% + Громкость + + Громкость - + Выключить звук + Включить звук + Нравится + Больше не нравится + Выход + + + Свернуть + Развернуть + Восстановить + Закрыть + + + Воспроизведение + Управление воспроизведением + + + Обзор + Сейчас играет + Рекомендуемое + Сохранённые треки + Популярные треки + diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml new file mode 100644 index 00000000..6498a53b --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -0,0 +1,920 @@ + + + + คิว + ล้างคิว + กรองคิว... + ไม่มีรายการในคิว + + ค้นหาเพลง ศิลปิน อัลบั้ม... + ล้างประวัติทั้งหมด + ค้นหา... + + + คลังเพลงของคุณ + คลังเพลง + เพลย์ลิสต์ + อัลบั้ม + ศิลปิน + เพลงในเครื่อง + ดาวน์โหลด + ค้นหาเพลย์ลิสต์ที่บันทึกไว้… + ค้นหาอัลบั้มที่บันทึกไว้… + ค้นหาศิลปินที่บันทึกไว้… + ค้นหาโฟลเดอร์หรือเพลง… + กรองคลังเพลง + ปิดตัวกรอง + สร้างเพลย์ลิสต์ + รีเฟรชเพลงในเครื่อง + เปลี่ยนเป็นมุมมองตาราง + เปลี่ยนเป็นมุมมองรายการ + ไม่มีเพลย์ลิสต์ที่บันทึกไว้ + ไม่มีเพลย์ลิสต์ที่ตรงกับ “%1$s” + ไม่มีอัลบั้มที่บันทึกไว้ + ไม่มีอัลบั้มที่ตรงกับ “%1$s” + ไม่มีศิลปินที่บันทึกไว้ + ไม่มีศิลปินที่ตรงกับ “%1$s” + + + โฟลเดอร์ + โฟลเดอร์ + %1$s • %2$s + + %1$d เพลง + + + %1$d โฟลเดอร์ + + กลับไปที่โฟลเดอร์ + เล่นโฟลเดอร์ + ต้องได้รับสิทธิ์เข้าถึงไฟล์เสียง + อนุญาตการเข้าถึงไฟล์เสียงเพื่อเรียกดูเพลงในอุปกรณ์ของคุณ + อนุญาตการเข้าถึง + ไม่พบโฟลเดอร์ในเครื่อง + ไม่มีเพลงในโฟลเดอร์นี้ + ไม่มีโฟลเดอร์หรือเพลงที่ตรงกัน + รีเฟรชสื่อในเครื่องไม่สำเร็จ + + + กำลังดำเนินการ %1$d • เสร็จสิ้น %2$d + ล้างรายการที่เสร็จแล้ว + ยังไม่มีการดาวน์โหลด + กำลังดาวน์โหลด %1$d% + อยู่ในคิว + เสร็จสิ้น + ยกเลิกแล้ว + ล้มเหลว + ล้มเหลว: %1$s + ยกเลิกการดาวน์โหลด + ลองดาวน์โหลดอีกครั้ง + ลบการดาวน์โหลด + + + ศิลปิน + อยู่ในบัญชีดำ + เพลงที่ถูกใจ + เพลงที่คุณบันทึกไว้ + ไม่ทราบศิลปิน + ชื่อเพลง + ชื่อศิลปิน + + เลือกแล้ว + + การตั้งค่า + + ปลั๊กอิน + ภาษาและภูมิภาค + รูปลักษณ์ + การเล่น + การดาวน์โหลด + แคช + เดสก์ท็อป + อัปเดต + สนับสนุน + เกี่ยวกับ + + ปิด + บันทึก + ยกเลิก + + ภาษา + ภาษาของอินเทอร์เฟซแอป ปัจจุบัน: %1$s + ค่าเริ่มต้นของระบบ + ประเทศ + ประเทศสำหรับเนื้อหาและความพร้อมใช้งาน ปัจจุบัน: %1$s + %1$s (%2$s) + + ธีม + เลือกว่าจะให้ Spotube แสดงผลตามระบบอย่างไร ปัจจุบัน: %1$s + สว่าง + มืด + ตามระบบ + + พื้นผิว + วัสดุพื้นหลังของแอป ปัจจุบัน: %1$s + สายน้ำ + น้ำแข็ง + โครม + อะคริลิก + กระจก + + สีเน้น + ชุดสีปัจจุบัน: %1$s + สีเน้น + เลือกชุดสีเน้นและดูตัวอย่างทั้งในธีมสว่างและธีมมืด + สว่าง + มืด + ตัวอย่าง + ปุ่ม + กรีนก็อบลิน + ม่วงไฟฟ้า + ฟ้าครามมหาสมุทร + ส้มอัสดง + สวนกุหลาบ + น้ำเงินเที่ยงคืน + เทาเมทัลลิก + + รูปลักษณ์และความรู้สึก + วัสดุพื้นผิวและสีเน้น ปัจจุบัน: %1$s · %2$s + รูปลักษณ์และความรู้สึก + เลือกวัสดุพื้นผิว แล้วเลือกสีเน้นที่เข้ากัน ทุกตัวเลือกเป็นชุดที่คัดสรรมาแล้ว + พื้นผิว + สีเน้น + รอง + + ชุดสี + ชุดสีเน้น Millennium ปัจจุบัน: %1$s + ชุดสี + เลือกชุดสีเน้น Millennium และดูตัวอย่างทั้งในโหมดสว่างและมืด + ชุดสี + ชุดสีและสีเน้น Millennium ปัจจุบัน: %1$s + น้ำเงิน + ส้ม + เขียวมะนาว + + กำหนดโฟลเดอร์ดาวน์โหลดเอง + โฟลเดอร์ปัจจุบัน: %1$s + เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น + C:\Music\Spotube + โฟลเดอร์สื่อในเครื่อง + โฟลเดอร์ที่กำหนดเองเพื่อสแกนบนเดสก์ท็อป เพิ่มแล้ว: %1$d + ยังไม่ได้เพิ่มโฟลเดอร์ที่กำหนดเอง + โฟลเดอร์ที่กำหนดเองสำหรับคลังเพลงในเครื่องของคุณ + จัดการ + เพิ่มโฟลเดอร์ + ลบโฟลเดอร์ + ยังไม่ได้เพิ่มโฟลเดอร์ที่กำหนดเอง + ลบ %1$s ออกจากรายการสแกน + โฟลเดอร์ดาวน์โหลดและโฟลเดอร์แคชเพลงจะถูกรวมโดยอัตโนมัติ + โฟลเดอร์ดาวน์โหลดของคุณจะถูกรวมโดยอัตโนมัติ + โฟลเดอร์แคชเพลงจะถูกรวมโดยอัตโนมัติ + กำลังบันทึก… + บันทึกโฟลเดอร์ไม่ได้ การเปลี่ยนแปลงของคุณยังอยู่ โปรดลองอีกครั้ง + + %1$d โฟลเดอร์ + + รูปแบบเพลงที่ดาวน์โหลด + โคเดกและคอนเทนเนอร์ที่ต้องการสำหรับการดาวน์โหลด ปัจจุบัน: %1$s + คุณภาพเพลงที่ดาวน์โหลด + + รูปแบบการสตรีมเพลง + โคเดกและคอนเทนเนอร์ที่ต้องการสำหรับการสตรีมเพลง ปัจจุบัน: %1$s + คุณภาพการสตรีมเพลง + ปัจจุบัน: %1$s + เปิดใช้แคชเพลง + จัดเก็บเสียงที่สตรีมไว้ในเครื่องเพื่อให้เล่นซ้ำได้เร็วขึ้น + โฟลเดอร์แคช + ปัจจุบัน: %1$s + ปัจจุบัน: แคชเริ่มต้นของแอป + ขีดจำกัดขนาดแคช + ไม่จำกัด + ขีดจำกัดปัจจุบัน: %1$d MB + กำหนดขนาดแคชสูงสุดเป็น MB ใส่ 0 หรือเว้นว่างไว้เพื่อไม่จำกัด + 500 + ขนาดต้องเป็น 0 หรือจำนวนบวก + เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด + เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด + พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น + อนุญาตการควบคุมระยะไกล + ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ + ชื่ออุปกรณ์สำหรับควบคุมระยะไกล + ชื่อที่อุปกรณ์อื่นจะเห็นเมื่อค้นหาอุปกรณ์นี้ ปัจจุบัน: %1$s + เลือกชื่อที่จำง่ายเพื่อแสดงให้อุปกรณ์อื่นในเครือข่ายของคุณเห็นเมื่อค้นพบ Spotube เครื่องนี้ + เช่น Spotube ห้องนั่งเล่น + ชื่อโฮสต์ของอุปกรณ์ + พอร์ตที่เซิร์ฟเวอร์พร็อกซีการเล่นใช้ ปัจจุบัน: %1$d + เลือกพอร์ตระหว่าง 1 ถึง 65535 + 14769 + ใส่จำนวนเต็มที่ถูกต้อง + พอร์ตต้องอยู่ระหว่าง 1 ถึง 65535 + + บัญชีดำ + จัดการเพลงและศิลปินในบัญชีดำ + + ย่อไปที่ถาดระบบ + ให้ Spotube ทำงานต่อในถาดระบบเมื่อย่อหน้าต่าง + Discord Rich Presence + แสดงสถานะการเล่นปัจจุบันของคุณใน Discord บนเดสก์ท็อป + + ตรวจหาอัปเดตโดยอัตโนมัติ + ค้นหา Spotube เวอร์ชันใหม่ในเบื้องหลัง + + จัดการปลั๊กอิน + จัดการปลั๊กอินของคุณ + ปลั๊กอิน%1$sเริ่มต้น + เมทาดาทา + เสียง + เนื้อเพลง + Scrobble + ยังไม่ได้เลือกปลั๊กอิน + เปลี่ยน + เลือก + เลือกแล้ว + ล้างการเลือก + ไม่มีปลั๊กอินให้ใช้ + ปลั๊กอิน %1$s + ล้าง + + กำหนดค่า + ตัวจัดการปลั๊กอิน + โปรดใส่ URL + URL ต้องขึ้นต้นด้วย http:// หรือ https:// + ดาวน์โหลดปลั๊กอินไม่สำเร็จ + ยังไม่ได้ติดตั้งปลั๊กอิน + วาง URL สำหรับดาวน์โหลดหรือเลือกไฟล์ด้านบน + + ติดตั้งปลั๊กอินแล้ว %1$d รายการ + + ติดตั้งปลั๊กอิน + https://example.com/plugin.smplug + ดาวน์โหลด + ติดตั้งจากไฟล์ (.smplug) + ใช้งานอยู่ + v%1$s + ในตัว + ลบปลั๊กอิน + เข้าสู่ระบบ + ออกจากระบบ + ดาวน์โหลดจาก URL + ติดตั้งจากไฟล์ + ติดตั้ง + + • %1$s + สิทธิ์ที่ร้องขอ + ปลั๊กอินนี้ไม่ต้องใช้สิทธิ์พิเศษ + ที่ติดตั้งอยู่เทียบกับที่ให้มา + ติดตั้งอยู่ + ที่ให้มา + API %1$s → %2$s + พื้นที่จัดเก็บถาวร + อ่านและเขียนข้อมูลลงดิสก์ + คำขอเครือข่าย + ส่งและรับข้อมูลผ่านอินเทอร์เน็ต + WebView + แสดงเนื้อหาเว็บภายในแอป + แจมกลุ่ม + ทดสอบการเชื่อมต่อ + กำลังทดสอบ… + เชื่อมต่อแล้วใน %1$d ms + ล้มเหลว: %1$s + การเชื่อมต่อ + การวินิจฉัย + + + แจมกลุ่ม + สร้าง + เข้าร่วม + สร้างห้อง แล้วแชร์รหัสห้องให้เพื่อนของคุณ + สร้างห้อง + เข้าร่วมห้อง + กำลังเชื่อมต่อ… + รหัสห้อง + ABC123 + เชื่อมต่อแล้ว + กำลังเชื่อมต่อใหม่… + คัดลอกรหัส + คัดลอกแล้ว! + แชร์ + ผู้เข้าร่วม (%1$d) + โฮสต์ + ผู้ฟัง + กำลังรอผู้ฟัง… + นำออก + แบน + จัดการ + ออกจากห้อง + เชื่อมต่อไม่ได้ + ปิด + สร้างเซสชันแจมไม่สำเร็จ + เข้าร่วมเซสชันแจมไม่สำเร็จ + ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข + มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s + เซิร์ฟเวอร์ห้อง + URL ของเซิร์ฟเวอร์ + ใส่ URL ที่ขึ้นต้นด้วย https:// + ตรวจสอบเซิร์ฟเวอร์ห้องก่อนเริ่มห้อง + แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้งเซิร์ฟเวอร์จาก github.com/team-spotube/jam-server แล้วใส่ URL ของเซิร์ฟเวอร์นั้น + คุณ + เซิร์ฟเวอร์: %1$s + ตั้งค่าเซิร์ฟเวอร์ห้องในการตั้งค่าเพื่อโฮสต์หรือเข้าร่วมห้อง + การตั้งค่าเซิร์ฟเวอร์ + %1$s (คุณ) + ชื่อของคุณ + คนอื่นในแจมจะเห็นคุณเป็น %1$s + ทุกคนในแจมจะเห็นชื่อนี้ เว้นว่างไว้เพื่อใช้ %1$s ชื่อใหม่จะมีผลเมื่อคุณสร้างหรือเข้าร่วมห้องครั้งถัดไป + ใช้ได้ไม่เกิน %1$d ตัวอักษร + ยังไม่ได้ตั้งค่าเซิร์ฟเวอร์แจม + เชื่อมต่อเซิร์ฟเวอร์แจมไม่ได้ ตรวจสอบเครือข่ายหรือ URL ของเซิร์ฟเวอร์ใน การตั้งค่า → แจมกลุ่ม + เซิร์ฟเวอร์แจมใช้เวอร์ชันที่เข้ากันไม่ได้ + ไม่มีห้องที่ใช้รหัสนี้ ตรวจสอบแล้วลองอีกครั้ง + ห้องนี้เต็มแล้ว + โฮสต์แบนคุณออกจากห้องนี้ + โฮสต์นำคุณออกจากห้อง + ห้องนี้ถูกเปิดอีกครั้งบนอุปกรณ์นี้ จึงปิดการเชื่อมต่อนี้ + + ไปที่อัลบั้ม + ตั้งเวลาปิด + รายละเอียดเพลง + เล่น + หยุดชั่วคราว + ไม่มีเพลงที่กำลังเล่น + เพิ่มลงในคิว + + ละเว้น + อัปเดต + Spotube %1$s พร้อมให้ใช้งานแล้ว + ดูว่ามีอะไรใหม่ในเวอร์ชันนี้ได้ที่หน้าดาวน์โหลดของ Spotube + %1$s %2$s พร้อมให้ใช้งานแล้ว + ติดตั้งอยู่ในปัจจุบัน: %1$s + ดูว่ามีอะไรใหม่ในอัปเดตนี้ได้จากหน้าของปลั๊กอิน + + ข้าม + ถัดไป + ย้อนกลับ + เริ่มต้นใช้งาน + Spotube + ดนตรีที่ไร้พันธนาการ + เครื่องเล่นโอเพนซอร์สสำหรับโลกแห่งดนตรี\nให้คุณสำรวจ ให้คุณสร้างในแบบของคุณเอง + ช่วยให้โปรเจกต์นี้อยู่ต่อไป + Spotube สร้างขึ้นโดยผู้คน ไม่ใช่ธุรกิจแบบสมัครสมาชิก หากแอปนี้กลายเป็นส่วนหนึ่งในชีวิตประจำวันของคุณ การสนับสนุนเพียงเล็กน้อยจะช่วยให้เราดูแลแอปได้อย่างที่ควรเป็น\n\nขอบคุณที่เปิดพื้นที่ให้ซอฟต์แวร์อิสระ + เริ่มต้นด้วยปลั๊กอินแคตตาล็อกเพื่อค้นหาเพลงและเพลย์ลิสต์ เลือกปลั๊กอินเสียงเพื่อเล่นเพลง ส่วนเนื้อเพลงและประวัติการฟังเป็นตัวเลือกเสริม ค้นพบปลั๊กอินด้านล่าง ตรวจสอบสิทธิ์ ติดตั้ง แล้วเลือกใช้ที่นี่ + ค้นพบปลั๊กอิน + แหล่งที่มาของคุณ + ลองอีกครั้ง + ปลั๊กอินเพิ่มเติม + ไม่พบปลั๊กอินใหม่ คุณยังติดตั้งจากลิงก์หรือไฟล์ .smplug ด้านบนได้ + ไม่สามารถค้นหาปลั๊กอินได้ในขณะนี้ ลองอีกครั้ง หรือติดตั้งจากลิงก์หรือไฟล์ + ยินดีต้อนรับ + สนับสนุน + ปลั๊กอิน + คู่มือ + + วิธีใช้ Spotube + แค่ 4 ขั้นตอนง่าย ๆ ก็เริ่มฟังได้เลย + เข้าสู่ระบบ + เชื่อมต่อบัญชีของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง + ค้นหาและเล่น + ค้นหาเพลง อัลบั้ม หรือศิลปิน แล้วกดเล่น + ฟังแบบออฟไลน์ + ดาวน์โหลดเพลงเพื่อฟังต่อได้แม้ไม่มีการเชื่อมต่อ + ร้องตามเนื้อเพลง + เปิดเนื้อเพลงแบบซิงค์จากเครื่องเล่นขณะที่เพลงกำลังเล่น + + + เพลงของคุณ แหล่งที่มาของคุณ กฎของคุณ + เครื่องเล่นเพลงฟรีและโอเพนซอร์ส สร้างโดยชุมชนที่รักในเสียงเพลง + โอเพนซอร์ส + ไม่มีโฆษณา + ไม่มีการเก็บข้อมูล + ขับเคลื่อนด้วยปลั๊กอิน + ไปกันเลย + + + ผู้สนับสนุนคือแรงผลักดันของเรา + สร้างด้วยใจ อยู่ได้ด้วยคุณ + Spotube ฟรี เสรี และโอเพนซอร์ส 100% — ไม่มีโฆษณา ไม่มีการสมัครสมาชิก ไม่มีการติดตาม ทุกฟีเจอร์มีอยู่ได้เพราะคนอย่างคุณเลือกที่จะช่วยให้การพัฒนาเดินหน้าต่อไป + Patreon + ผู้สนับสนุนรายเดือน + สนับสนุนการพัฒนาและโครงสร้างพื้นฐานอย่างต่อเนื่อง + Buy Me a Coffee + กาแฟครั้งเดียว + เติมพลังให้การเขียนโค้ดยามดึกด้วยทิปเล็ก ๆ + Open Collective + กองทุนชุมชน + การสนับสนุนทางการเงินที่โปร่งใสและบริหารโดยชุมชน + ขอบคุณจากใจที่เชื่อมั่นในดนตรีแบบเปิด + การสนับสนุนเป็นทางเลือกโดยสมบูรณ์ — Spotube ยังคงฟรีไม่ว่าอย่างไร + สนับสนุน + เปิด %1$s + + + สนับสนุน Spotube + สนับสนุน Spotube + Spotube ฟรี เสรี และโอเพนซอร์ส — ไม่มีโฆษณา ไม่มีการสมัครสมาชิก ไม่มีการติดตาม หากแอปนี้ทำให้คุณมีความสุข ลองพิจารณาสนับสนุนโปรเจกต์นี้ + คุณปิดการแจ้งเตือนเหล่านี้ได้ที่ การตั้งค่า → สนับสนุน + ไว้ทีหลัง + การแจ้งเตือนให้สนับสนุน + แสดงการแจ้งเตือนเบา ๆ ให้สนับสนุนโปรเจกต์ทุก 7 วัน + + + สถาปัตยกรรมแบบโมดูล + เลือกได้ว่าเพลงของคุณมาจากไหน + Spotube แยกการค้นหาแคตตาล็อก การสตรีมเสียง และเนื้อเพลงแบบซิงค์ออกจากกัน กำหนดค่าเอนจินของคุณด้านล่างหรือเปลี่ยนได้ทุกเมื่อ + เอนจินเมทาดาทา + ขับเคลื่อนการค้นพบเพลง การค้นหา รายชื่อเพลง และเพลย์ลิสต์ + เอนจินเสียง + ดึงและถอดรหัสสตรีมเสียงที่จะเล่น + เอนจินเนื้อเพลง + สตรีมเนื้อเพลงคาราโอเกะที่ซิงค์ตามเวลาแบบเรียลไทม์ + เอนจิน Scrobble + บันทึกประวัติการฟังไปยัง Last.fm หรือ ListenBrainz + ใช้งานอยู่ + ในตัว + พร้อม + ไม่บังคับ + เลือกเอนจิน + เอนจินที่ติดตั้งไว้ + ตั้งเป็นค่าเริ่มต้น + NewPipe YouTube และ LRCLib Lyrics มีมาในตัวและพร้อมสตรีมได้ทันที + จัดการหรือเพิ่มปลั๊กอินจากชุมชนได้ทุกเมื่อที่ การตั้งค่า → ปลั๊กอิน + + + สถาปัตยกรรมย้อนยุคผสมสมัยใหม่ + Hi-Fi บริสุทธิ์ ไม่มีคนกลาง + นี่คือวิธีที่ Spotube จัดการการเล่นแบบกระจายศูนย์บนเครื่องของคุณโดยตรง + เพียร์ทูเพียร์โดยตรง • 256 KBPS OPUS + Blue hour + Spotube · ตัวอย่างเครื่องเล่น + ดนตรีเล็ก ๆ สำหรับช่วงเวลาระหว่างทาง + 1. การค้นพบแบบกระจายศูนย์ + คำค้นหาส่งตรงไปยังปลั๊กอินแคตตาล็อกที่คุณเลือก Spotube ไม่เคยเก็บข้อมูลรสนิยมของคุณ + 2. ระบุสตรีมโดยตรง + เสียงสตรีมตรงจากเซิร์ฟเวอร์เนื้อหามายังเครื่องของคุณ ไม่ผ่านพร็อกซีหรือการเก็บข้อมูลจากระยะไกล + 3. DSP และการเล่นในเครื่อง + ถอดรหัสและแคชในเครื่อง พร้อมอีควอไลเซอร์และการดาวน์โหลดแบบออฟไลน์ (ไม่บังคับ) + 4. เนื้อเพลงแบบซิงค์และแจม + เนื้อเพลงคาราโอเกะเลื่อนตามเวลาที่เล่น พร้อมการควบคุมระยะไกลผ่าน LAN และแจมกลุ่ม + ไม่มีเซิร์ฟเวอร์คนกลาง การเล่นเกิดขึ้นบนอุปกรณ์ของคุณ — เป็นส่วนตัวตั้งแต่การออกแบบ + + + เลือกอุปกรณ์ + %1$s: “%2$s” + เล่น + เพิ่มลงในคิว + เล่นถัดไป + อุปกรณ์นี้ + บนอุปกรณ์นี้ + อุปกรณ์ระยะไกล + อุปกรณ์ระยะไกล (%1$s) + บนอุปกรณ์ที่เชื่อมต่ออยู่ + + + การเชื่อมต่อล้มเหลว + การเชื่อมต่อขาดหาย + ส่งไม่สำเร็จ + การค้นหาล้มเหลว: %1$s + เชื่อมต่อไม่สำเร็จ: %1$s + + + อุปกรณ์ + รีเฟรช + ยกเลิกการเชื่อมต่อ + กำลังเชื่อมต่อ... + กำลังค้นหาอุปกรณ์ในเครือข่าย... + ไม่พบอุปกรณ์ + ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า + อุปกรณ์ที่ไม่รู้จัก + กำลังระบุ... + + + รีโมตคอนโทรล + ยกเลิกการเชื่อมต่อแล้ว + ข้อผิดพลาดในการเชื่อมต่อ: %1$s + ไม่ทราบชื่อเพลง + ไม่ทราบศิลปิน + ระดับเสียง + นำออกจากคิว + ตัวเลือกเพิ่มเติม + + + เล่นที่ไหน? + คุณมีเซสชันแจมที่ใช้งานอยู่ เลือกว่าจะเล่นคอลเลกชันนี้ที่ไหน + เล่นที่นี่ + เสนอให้แจม + + + การเชื่อมต่อล้มเหลว + การเชื่อมต่อขาดหาย + + + + เพิ่มลงในคิวแจมแล้ว + + เพิ่ม %1$d เพลงลงในคิวแจมแล้ว + + + + คำขอควบคุมระยะไกล + “%1$s” ต้องการควบคุมการเล่นบนอุปกรณ์นี้ + ปฏิเสธ + อนุญาต + อนุญาตเสมอ + + + ผลการค้นหา + ทั้งหมด + เพลง + ผู้ใช้ + ไม่มีประเภทการค้นหาให้ใช้ + เริ่มพิมพ์เพื่อค้นหา + เริ่มพิมพ์เพื่อค้นหาเพลง + ไม่พบผลลัพธ์ + ดูทั้งหมด + กำลังค้นหา + กำลังโหลดเพลง... + กำลังโหลดอัลบั้ม... + กำลังโหลดศิลปิน... + กำลังโหลดเพลย์ลิสต์... + กำลังโหลดผู้ใช้... + ไม่พบอัลบั้ม + ไม่พบศิลปิน + ไม่พบเพลย์ลิสต์ + ไม่พบผู้ใช้ + กำลังคุ้ยกล่องแผ่นเสียง... + กำลังค้นหาทั่วจักรวาล... + กำลังวอร์มลำโพง... + กำลังปัดฝุ่นแผ่นไวนิล... + กำลังปรึกษาเทพแห่งดนตรี... + กำลังจูนเสาอากาศ... + กำลังพลิกหาแผ่นเสียง... + โหลดความสามารถในการค้นหาไม่สำเร็จ + ค้นหาไม่สำเร็จ + ค้นหาเพลงไม่สำเร็จ + ค้นหาอัลบั้มไม่สำเร็จ + ค้นหาศิลปินไม่สำเร็จ + ค้นหาเพลย์ลิสต์ไม่สำเร็จ + ค้นหาผู้ใช้ไม่สำเร็จ + + + เรียกดู + + + ซิงค์ + ธรรมดา + ไม่มีเนื้อเพลงแบบซิงค์ + ไม่มีเนื้อเพลงแบบธรรมดา + + + %1$s ใน %2$s + %1$d kbps + %1$s kHz • %2$d ช่อง + ยังไม่ได้เลือกปลั๊กอินเมทาดาทา + + + ย้อนกลับ + ลองอีกครั้ง + ปิดกล่องโต้ตอบ + กำลังโหลด... + ไม่ทราบ + ข้อผิดพลาดที่ไม่ทราบสาเหตุ + + + ผู้ใช้ + เพลย์ลิสต์ + + + ขออภัยเป็นอย่างยิ่ง + มีบางอย่างทำงานไม่ถูกต้องในขณะนี้ ประสบการณ์ของคุณสำคัญสำหรับเรา และเรากำลังพยายามแก้ไขอย่างเต็มที่ โปรดลองอีกครั้งหรือตรวจสอบรายละเอียดข้อผิดพลาดด้านล่าง + รายละเอียดข้อผิดพลาด + คัดลอกไปยังคลิปบอร์ด + ดูบันทึกทั้งหมด + บันทึกข้อผิดพลาดทั้งหมด + + + สุ่มเพลง + เล่นแบบสุ่ม + ติดตาม + เลิกติดตาม + แก้ไขเพลย์ลิสต์ + โดย %1$s + + + ตัวเลือกเพลง + เพิ่มลงในแจม + เริ่มวิทยุ + เล่นถัดไป + ย้ายไปเป็นเพลงถัดไป + นำออกจากคิว + นำออกจากรายการโปรด + บันทึกเป็นรายการโปรด + เพิ่มลงในเพลย์ลิสต์ + ดาวน์โหลด + แชร์ + + + ลำดับเดิม + ชื่อเพลง + ศิลปิน + อัลบั้ม + ความยาว + เรียงลำดับ + ไม่พบเพลง + กรอง + การดำเนินการแบบกลุ่ม + ดาวน์โหลดทั้งหมด + ดาวน์โหลด %1$d เพลง + เพิ่มทั้งหมดลงในคิว + เพิ่ม %1$d เพลงลงในคิว + เล่นทั้งหมดถัดไป + เล่น %1$d เพลงถัดไป + เพิ่มทั้งหมดลงในเพลย์ลิสต์ + เพิ่ม %1$d เพลงลงในเพลย์ลิสต์ + เพิ่มทั้งหมดลงในแจม + เพิ่ม %1$d เพลงลงในแจม + +อีก %1$d + + + ไม่มี URL ให้โหลด + ย้อนกลับในประวัติเบราว์เซอร์ + ไปข้างหน้าในประวัติเบราว์เซอร์ + + + กรองบัญชีดำ... + ไม่มีรายการในบัญชีดำ + ไม่มีรายการในบัญชีดำที่ตรงกับ “%1$s” + เพลง + เพลง + เพิ่มลงในบัญชีดำ + นำออกจากบัญชีดำ + + + ไม่มีเมทาดาทาของเพลง + ไม่สามารถระบุ URL ของสตรีมได้ + ไม่สามารถระบุ URL ของสตรีมได้ ลองเล่นเพลงนี้ก่อน + + + เพลงที่บันทึกไว้ + คุณ + + + กำลังติดตาม + เพลงยอดนิยม + เล่นเพลงยอดนิยม + เพิ่มเพลงยอดนิยมลงในคิว + ศิลปินที่เกี่ยวข้อง + เพลย์ลิสต์แนะนำ + โหลดภาพรวมศิลปินไม่สำเร็จ + ไม่มีข้อมูลผู้ติดตาม + ผู้ติดตาม %1$sK คน + ผู้ติดตาม %1$sM คน + ผู้ติดตาม %1$sB คน + + ผู้ติดตาม %1$d คน + + + + ถูกใจ + เลิกถูกใจ + ชื่อเพลงตัวอย่าง + ศิลปินตัวอย่าง + ไม่ทราบอัลบั้ม + เลื่อนตำแหน่ง + ระดับเสียง + เปิดการสุ่มเพลง + ปิดการสุ่มเพลง + ก่อนหน้า + ถัดไป + เล่นหรือหยุดชั่วคราว + ปิดการเล่นซ้ำ + เล่นซ้ำเพลงเดียว + เล่นซ้ำทั้งหมด + ปิดเสียง + เปิดเสียง + ตัวเลือกเพิ่มเติม + ตัวเลือกเครื่องเล่น + ยุบเครื่องเล่น + ขยายเนื้อเพลง + กำลังเล่น + เนื้อเพลง + แหล่งที่มาสำรอง + แหล่งที่มาสำรอง + ดาวน์โหลด + ดาวน์โหลดแล้ว + กำลังดาวน์โหลด... + ดาวน์โหลดไว้แล้ว + กำลังลองดาวน์โหลดอีกครั้ง... + กำลังดาวน์โหลด %1$s + การเล่นจะหยุดชั่วคราวในอีก %1$s + หยุดการเล่นชั่วคราวหลังจากจำนวนนาทีที่เลือก + นาที + เลือกระหว่าง 1 ถึง 600 นาที + ยกเลิกตัวตั้งเวลา + เริ่ม + URL สำหรับเล่นถูกระบุด้วยการตั้งค่าการสตรีมที่ต่างออกไป สตรีมที่ไฮไลต์ตรงกับค่าที่คุณต้องการในปัจจุบัน URL ที่ใช้งานอยู่จะรีเฟรชเมื่อมีการขอสตรีมครั้งถัดไป + แหล่งเสียงที่จับคู่ได้ + กำลังโหลดแหล่งที่จับคู่ได้… + ไม่จำเป็นต้องใช้แหล่งระยะไกลที่จับคู่ได้ขณะเล่นไฟล์ที่แคชไว้ในเครื่อง + ไม่มีแหล่งที่จับคู่ได้ + ไม่มีแหล่งเสียงที่จับคู่ได้ถูกแคชไว้สำหรับเพลงนี้ + ยังไม่ได้แคชรายละเอียดสตรีม เล่นเพลงนี้ก่อนเพื่อโหลดข้อมูล + โหลดรายละเอียดเพลงไม่สำเร็จ + สตรีมที่มีให้ใช้ + เพลงนี้กำลังเล่นจากแคชในเครื่อง จึงไม่ได้ใช้สตรีมรูปแบบต่าง ๆ สำหรับการเล่นนี้ + กำลังโหลดสตรีม… + ไม่มีสตรีมสำหรับแหล่งนี้ + สตรีมที่เลือกอยู่ในปัจจุบัน + ตรงกับรูปแบบและคุณภาพการสตรีมที่คุณต้องการ + URL ที่ใช้เล่นอยู่ + เล่นจากแคชในเครื่อง + เพลงจากเมทาดาทา + ชื่อเพลง + ID + อัลบั้ม + ความแม่นยำ + URI ภายนอก + ID อัลบั้ม + ประเภทอัลบั้ม + วันที่วางจำหน่ายอัลบั้ม + จำนวนเพลงในอัลบั้ม + แนวเพลงของอัลบั้ม + ความยาว + หมายเลขเพลง + หมายเลขแผ่น + เนื้อหาไม่เหมาะสม + ความนิยม + ISRC + URL ภาพขนาดย่อ + + + สลับแถบด้านข้าง + + + แหล่งที่มาสำรอง + ไม่มีเพลงที่กำลังเล่นอยู่ + กำลังค้นหาแหล่งที่มาสำรอง... + ไม่พบแหล่งที่มาสำรอง + ความแม่นยำ: %1$d% + แหล่งที่ใช้งานอยู่ + + + เตะออก + นำผู้ใช้นี้ออกจากเซสชัน + เตะออกและไม่ให้กลับเข้ามาอีก + ลบเพลงที่เสนอ + ลบทุกเพลงที่ผู้ใช้นี้เพิ่มลงในคิว + จัดลำดับใหม่ + นำออกจากคิว + %1$s • เพิ่มโดย %2$s + + + ไม่ทราบ + กำลังโหลดเพลย์ลิสต์... + เพิ่มเพลง + เพิ่มเพลงลงในเพลย์ลิสต์ + ค้นหาเพลง... + ค้นหา + ไม่พบเพลง + ค้นหาเพลงที่จะเพิ่ม + เสร็จสิ้น + เพิ่ม + เพิ่มแล้ว + + + กำลังโหลดอัลบั้ม... + + + เพิ่มลงในเพลย์ลิสต์ของคุณ + ค้นหา + ค้นหาเพลย์ลิสต์ของคุณ... + กำลังโหลดเพลย์ลิสต์ของคุณ... + คุณยังไม่มีเพลย์ลิสต์ของตัวเอง\nสร้างเพลย์ลิสต์เพื่อเพิ่มเพลง + สร้างเพลย์ลิสต์ + แก้ไขเพลย์ลิสต์ + สร้าง + ชื่อ + ชื่อเพลย์ลิสต์ + คำอธิบาย + คำอธิบาย (ไม่บังคับ) + สาธารณะ + ทุกคนสามารถเห็นเพลย์ลิสต์นี้ + ทำร่วมกัน + ผู้อื่นสามารถเพิ่มเพลงลงในเพลย์ลิสต์นี้ได้ + ภาพปก + ปกเพลย์ลิสต์ + ไม่มีภาพ + เปลี่ยนภาพ + เลือกภาพ + + + ติดตั้ง + แทนที่ + ไม่พบ plugin.json ในไฟล์ zip + ไม่พบ manifest.zipline.json ในไฟล์ zip + รูปแบบ plugin.json ไม่ถูกต้อง: %1$s + อ่านปลั๊กอินไม่สำเร็จ: %1$s + ติดตั้งปลั๊กอินไม่สำเร็จ: %1$s + มี %1$s เวอร์ชันใหม่กว่า โปรดยืนยันการอัปเดตก่อนติดตั้ง + %1$s ติดตั้งอยู่แล้ว โปรดยืนยันการแทนที่ก่อนติดตั้ง + ปลั๊กอินในตัวรวมอยู่แล้วและไม่สามารถเพิ่มซ้ำได้ + ไม่สามารถลบปลั๊กอินในตัวได้ + %1$s ระบุ apiVersion ไม่ถูกต้อง (%2$s) ต้องเป็น semantic versioning ที่เข้ากันได้กับ %3$s + เวอร์ชัน API ปลั๊กอินของ Spotube %1$s ไม่ถูกต้อง + %1$s ใช้ API ปลั๊กอิน %2$s แต่ Spotube รองรับ API ปลั๊กอินแบบ breaking %3$s โปรดติดตั้งปลั๊กอินที่สร้างสำหรับ API เวอร์ชัน breaking เดียวกัน + + + API ปลั๊กอินเข้ากันไม่ได้ + ติดตั้งปลั๊กอินหรือไม่? + %1$s จะถูกเพิ่มลงในปลั๊กอินที่ติดตั้งไว้ของคุณ + อัปเดตปลั๊กอินหรือไม่? + มีการติดตั้งเวอร์ชันเก่ากว่าอยู่ (%1$s) อัปเดตเป็น %2$s หรือไม่? + แทนที่ปลั๊กอินที่ติดตั้งไว้หรือไม่? + %1$s ใช้ semantic version ที่ไม่ถูกต้อง (%2$s) จึงไม่สามารถเปรียบเทียบเป็นการอัปเดตได้ ต้องการแทนที่ปลั๊กอินที่ติดตั้งไว้ต่อไปหรือไม่? + ไม่สามารถเปรียบเทียบเวอร์ชันที่ติดตั้งไว้ (%1$s) ด้วย semantic versioning ได้ แทนที่ด้วย %2$s หรือไม่? + เวอร์ชัน %1$s ติดตั้งอยู่แล้ว แทนที่ปลั๊กอินเดิมด้วยสำเนาที่ให้มาหรือไม่? + เวอร์ชันที่ติดตั้งไว้ %1$s ใหม่กว่าเวอร์ชันที่ให้มา %2$s ต้องการแทนที่ต่อไปหรือไม่? + แทนที่ปลั๊กอินที่ติดตั้งไว้ด้วยสำเนาที่ให้มาหรือไม่? + + + %1$s: + เวอร์ชัน + เวอร์ชัน API + ผู้สร้าง + สัญญาอนุญาต + สิทธิ์การเข้าถึง + ความสามารถ + รีโพซิทอรี + ติดต่อ + รายงานข้อบกพร่อง + สนับสนุน %1$s + + + ทางการ + ยืนยันแล้ว + ดาวบน GitHub + URL รีโพซิทอรี GitHub + + + ไม่พบรีลีส + เลือกรีลีส + เลือกรีลีส + รุ่นทดสอบก่อนเปิดตัว + บันทึกประจำรุ่น + + + หน้าแรก + ค้นหา + ในอุปกรณ์ + เชื่อมต่อ + อุปกรณ์ + + + ซ่อนหน้าต่าง + แสดงหน้าต่าง + เพลงถัดไป + เพลงก่อนหน้า + สุ่มเพลง + เล่นซ้ำ + เล่นซ้ำ: ปิด + เล่นซ้ำ: เพลงเดียว + เล่นซ้ำ: ทั้งหมด + ระดับเสียง: %1$d% + เพิ่มเสียง + + ลดเสียง - + ปิดเสียง + เปิดเสียง + ถูกใจเพลง + เลิกถูกใจเพลง + ออก + + + ย่อขนาด + ขยายใหญ่สุด + คืนขนาด + ปิด + + + การเล่น + ตัวควบคุมการเล่นสื่อ + + + เรียกดู + กำลังเล่น + แนะนำ + เพลงที่บันทึกไว้ + เพลงยอดนิยม + diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml new file mode 100644 index 00000000..fe357c8c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -0,0 +1,926 @@ + + + + Sıra + Sırayı temizle + Sırada filtrele... + Sıra boş + + Şarkı, sanatçı, albüm ara... + Tüm geçmişi temizle + Ara... + + + Kitaplığın + Kitaplık + Çalma listeleri + Albümler + Sanatçılar + Yerel parçalar + İndirilenler + Kayıtlı çalma listelerinde ara… + Kayıtlı albümlerde ara… + Kayıtlı sanatçılarda ara… + Klasör veya parça ara… + Kitaplığı filtrele + Filtreyi kapat + Çalma listesi oluştur + Yerel parçaları yenile + Izgara görünümüne geç + Liste görünümüne geç + Kayıtlı çalma listesi yok + “%1$s” ile eşleşen çalma listesi yok + Kayıtlı albüm yok + “%1$s” ile eşleşen albüm yok + Kayıtlı sanatçı yok + “%1$s” ile eşleşen sanatçı yok + + + Klasörler + Klasör + %1$s • %2$s + + %1$d parça + %1$d parça + + + %1$d klasör + %1$d klasör + + Klasörlere dön + Klasörü çal + Ses erişimi gerekli + Cihazındaki müziklere göz atmak için ses erişimine izin ver. + Erişim izni ver + Yerel klasör bulunamadı + Bu klasörde parça yok + Eşleşen klasör veya parça yok + Yerel medya yenilenemedi + + + %1$d etkin • %2$d tamamlandı + Tamamlananları temizle + Henüz indirme yok + İndiriliyor: %1$d% + Sırada + Tamamlandı + İptal edildi + Başarısız + Başarısız: %1$s + İndirmeyi iptal et + İndirmeyi yeniden dene + İndirmeyi kaldır + + + Sanatçı + Kara listede + Beğenilen Şarkılar + Kaydettiğin şarkılar + Bilinmeyen sanatçı + Parça adı + Sanatçı adı + + Seçili + + Ayarlar + + Eklentiler + Dil ve Bölge + Görünüm + Çalma + İndirmeler + Önbellek + Masaüstü + Güncellemeler + Destek + Hakkında + + Kapat + Kaydet + İptal + + Dil + Uygulama arayüzünün dili. Geçerli: %1$s + Sistem varsayılanı + Ülke + İçerik ve kullanılabilirlik için bölge ülkesi. Geçerli: %1$s + %1$s (%2$s) + + Tema + Spotube\'un sistem görünümüne nasıl uyacağını seç. Geçerli: %1$s + Açık + Koyu + Sistem + + Yüzey + Uygulamanın arka plan malzemesi. Geçerli: %1$s + Su + Buz + Krom + Akrilik + Cam + + Vurgu rengi + Geçerli palet: %1$s + Vurgu rengi + Bir vurgu paleti seç ve hem açık hem koyu temada önizle. + Açık + Koyu + Önizleme + Düğme + Yeşil Goblin + Elektrik Moru + Okyanus Camgöbeği + Gün Batımı Turuncusu + Gül Bahçesi + Gece Mavisi + Metalik Arduvaz + + Görünüm ve tarz + Yüzey malzemesi ve vurgu. Geçerli: %1$s · %2$s + Görünüm ve tarz + Önce bir yüzey malzemesi, ardından ona uyan bir vurgu seç. Her seçenek özenle hazırlanmış bir kombinasyon. + Yüzey + Vurgu + İkincil + + Palet + Millennium vurgu paleti. Geçerli: %1$s + Palet + Bir Millennium vurgu paleti seç ve açık ile koyu temada önizle. + Palet + Millennium paleti ve vurgu. Geçerli: %1$s + Mavi + Turuncu + Limon yeşili + + Özel indirme klasörü + Geçerli klasör: %1$s + Varsayılan müzik klasörünü kullanmak için boş bırak. + C:\Music\Spotube + Yerel medya klasörleri + Masaüstünde taranacak özel klasörler. Eklenen: %1$d + Henüz özel klasör eklenmedi + Yerel müzik kitaplığın için özel klasörler. + Yönet + Klasör ekle + Klasörü kaldır + Özel klasör eklenmedi. + %1$s klasörünü tarama listesinden kaldır + İndirme ve müzik önbelleği klasörleri otomatik olarak dahil edilir. + İndirme klasörün otomatik olarak dahil edilir. + Müzik önbelleği klasörü otomatik olarak dahil edilir. + Kaydediliyor… + Klasörler kaydedilemedi. Değişikliklerin duruyor; tekrar dene. + + %1$d klasör + %1$d klasör + + İndirme müzik biçimi + İndirmeler için tercih edilen kodek ve kapsayıcı. Geçerli: %1$s + İndirme müzik kalitesi + + Akış müzik biçimi + Müzik akışı için tercih edilen kodek ve kapsayıcı. Geçerli: %1$s + Akış müzik kalitesi + Geçerli: %1$s + Müzik önbelleğini etkinleştir + Tekrar çalmayı hızlandırmak için akışla gelen sesi cihazda sakla. + Önbellek klasörü + Geçerli: %1$s + Geçerli: varsayılan uygulama önbelleği + Önbellek boyutu sınırı + Sınır yok + Geçerli sınır: %1$d MB + Maksimum önbellek boyutunu MB cinsinden belirle. Sınır olmaması için 0 gir veya boş bırak. + 500 + Boyut 0 veya pozitif bir sayı olmalı. + Sonsuz çalmayı etkinleştir + Sıran bittiğinde önerilen parçalarla çalmaya devam et. + Çalma proxy sunucusu portu + Uzaktan kontrole izin ver + Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin. + Uzaktan kontrol cihaz adı + Diğer cihazların bu cihazı bulurken gördüğü ad. Geçerli: %1$s + Ağındaki diğer cihazlar bu Spotube\'u bulduğunda görünecek kolay bir ad seç. + örn. Salon Spotube + cihaz ana bilgisayar adı + Çalma proxy sunucusunun kullandığı port. Geçerli: %1$d + 1 ile 65535 arasında bir port seç. + 14769 + Geçerli bir tam sayı gir. + Port 1 ile 65535 arasında olmalı. + + Kara liste + Kara listedeki parçaları ve sanatçıları yönet + + Sistem tepsisine küçült + Pencere küçültüldüğünde Spotube sistem tepsisinde çalışmaya devam etsin. + Discord Rich Presence + Masaüstünde şu an çalanı Discord\'da göster. + + Güncellemeleri otomatik denetle + Yeni Spotube sürümlerini arka planda ara. + + Eklentileri Yönet + Eklentilerini yönet + Varsayılan %1$s eklentisi + Meta veri + Ses + Şarkı sözleri + Scrobble + Eklenti seçilmedi + Değiştir + Seç + Seçili + Seçimi temizle + Kullanılabilir eklenti yok + %1$s eklentisi + Temizle + + Yapılandır + Eklenti Yöneticisi + Lütfen bir URL gir + URL, http:// veya https:// ile başlamalı + Eklenti indirilemedi + Henüz yüklü eklenti yok + Bir indirme URL\'si yapıştır veya yukarıdan bir dosya seç. + + %1$d eklenti yüklü + %1$d eklenti yüklü + + Eklenti Yükle + https://example.com/plugin.smplug + İndir + Dosyadan yükle (.smplug) + Etkin + v%1$s + Yerleşik + Eklentiyi kaldır + Giriş yap + Çıkış yap + URL\'den indir + Dosyadan yükle + Yükle + + • %1$s + İstenen izinler + Bu eklenti özel bir izin gerektirmiyor. + Yüklü ve sağlanan + Yüklü + Sağlanan + API %1$s → %2$s + Kalıcı Depolama + Diske veri okuma ve yazma + Ağ İstekleri + İnternet üzerinden veri gönderme ve alma + WebView + Uygulama içinde web içeriği görüntüleme + Grup Jam + Bağlantıyı test et + Test ediliyor… + %1$d ms\'de bağlandı + Başarısız: %1$s + Bağlantı + Tanılama + + + Grup Jam + Oluştur + Katıl + Bir oda oluştur, sonra kodunu arkadaşlarınla paylaş. + Oda oluştur + Odaya katıl + Bağlanıyor… + Oda kodu + ABC123 + Bağlandı + Yeniden bağlanıyor… + Kodu kopyala + Kopyalandı! + Paylaş + Katılımcılar (%1$d) + Sahip + Dinleyici + Dinleyiciler bekleniyor… + Çıkar + Engelle + Yönet + Odadan ayrıl + Bağlanılamadı + Kapat + Jam oturumu oluşturulamadı + Jam oturumuna katılınamadı + Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir. + Spotube Jam\'ime şu kodla katıl: %1$s + Oda sunucusu + Sunucu URL’si + https:// ile başlayan bir URL gir. + Oda başlatmadan önce oda sunucusunu kontrol et. + Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için github.com/team-spotube/jam-server adresindeki sunucuyu kur ve URL’sini gir. + Sen + Sunucu: %1$s + Oda açmak veya bir odaya katılmak için Ayarlar’da bir oda sunucusu belirle. + Sunucu ayarları + %1$s (sen) + Adın + Jam’deki diğer kişiler seni %1$s olarak görür + Jam’deki herkese gösterilir. %1$s adını kullanmak için boş bırak. Yeni ad, bir sonraki oda açışında veya katılışında geçerli olur. + En fazla %1$d karakter kullan. + Jam sunucusu ayarlanmamış + Jam sunucusuna ulaşılamadı. Ağını veya Ayarlar → Grup Jam bölümündeki sunucu URL’sini kontrol et. + Jam sunucusu uyumsuz bir sürüm çalıştırıyor. + Bu koda sahip bir oda yok. Kodu kontrol edip tekrar dene. + Bu oda dolu. + Oda sahibi seni bu odadan engelledi. + Oda sahibi seni odadan çıkardı. + Bu oda bu cihazda yeniden açıldı, bu yüzden bu bağlantı kapatıldı. + + Albüme git + Uyku zamanlayıcısı + Parça ayrıntıları + Çal + Duraklat + Hiçbir şey çalmıyor + Sıraya ekle + + Yok say + Güncelle + Spotube %1$s kullanıma sunuldu + Bu sürümdeki yenilikleri Spotube indirme sayfasında gör. + %1$s %2$s kullanıma sunuldu + Şu an yüklü: %1$s + Bu güncellemedeki yenilikleri eklentinin sayfasında gör. + + Atla + İleri + Geri + Başla + Spotube + Müzik, hiçbir kısıtlama olmadan. + Koca bir müzik dünyası için açık kaynaklı bir oynatıcı.\nKeşfetmesi sana kalmış. Kendine göre şekillendirmesi de. + Bu projeyi yaşat. + Spotube\'u bir abonelik şirketi değil, insanlar geliştiriyor. Günlük hayatının bir parçası olduysa, küçük bir destek bile ona hak ettiği özeni göstermemize yardımcı olur.\n\nBağımsız yazılıma yer açtığın için teşekkürler. + Şarkı ve çalma listesi bulmak için bir katalog eklentisiyle başla. Onları çalmak için bir ses eklentisi seç. Şarkı sözleri ve dinleme geçmişi isteğe bağlı. Aşağıdan bir eklenti keşfet, izinlerini incele, yükle ve burada seç. + Eklentileri keşfet + Kaynakların + Tekrar dene + Daha fazla eklenti + Yeni eklenti bulunamadı. Yukarıdan bir bağlantı veya .smplug dosyasıyla da yükleyebilirsin. + Eklenti keşfi şu anda kullanılamıyor. Tekrar dene ya da bir bağlantı veya dosyadan yükle. + Hoş geldin + Destek + Eklentiler + Rehber + + Spotube nasıl kullanılır + Dört hızlı adım ve dinlemeye başlıyorsun. + Giriş yap + hesabını bağla veya kendi katalog eklentini seç. + Ara ve çal + Herhangi bir şarkıyı, albümü veya sanatçıyı bul, sonra çal\'a bas. + Çevrimdışı dinle + Bağlantı olmadan dinlemeye devam etmek için parçaları indir. + Sözleri takip et + Bir parça çalarken oynatıcıdan senkronize sözleri aç. + + + Senin müziğin. Senin kaynakların. Senin kuralların. + Müziği sadece seven bir topluluğun geliştirdiği ücretsiz, açık kaynaklı bir müzik oynatıcı. + Açık kaynak + Reklamsız + Telemetri yok + Eklenti destekli + Hadi Başlayalım + + + Destekçilerimiz bizi ayakta tutuyor + Tutkuyla yapıldı. Seninle yaşıyor. + Spotube %100 ücretsiz, özgür ve açık kaynak — reklam yok, abonelik yok, takip yok. Her özellik, kodun akmaya devam etmesine yardım etmeyi seçen senin gibi insanlar sayesinde var. + Patreon + Aylık Sponsor + Sürekli geliştirmeyi ve altyapıyı destekle + Buy Me a Coffee + Tek Seferlik Kahve + Küçük bir bahşişle gece yarısı kodlama seansına güç ver + Open Collective + Topluluk Fonu + Şeffaf, topluluk tarafından yönetilen finansal destek + Açık müziğe inandığın için tüm kalbimizle teşekkür ederiz. + Destek tamamen isteğe bağlı — Spotube her durumda ücretsiz kalır. + Sponsor ol + %1$s sayfasını aç + + + Spotube\'u destekle + Spotube\'u destekle + Spotube ücretsiz, özgür ve açık kaynak — reklam yok, abonelik yok, takip yok. Sana keyif veriyorsa projeyi desteklemeyi düşünebilirsin. + Bu hatırlatıcıları Ayarlar → Destek bölümünden kapatabilirsin. + Belki sonra + Destek hatırlatıcıları + Projeyi desteklemek için her 7 günde bir nazik bir hatırlatma göster. + + + Modüler Mimari + Müziğinin nereden geleceğini seç. + Spotube katalog keşfini, ses akışını ve senkronize sözleri birbirinden ayırır. Motorlarını aşağıdan yapılandır veya istediğin zaman değiştir. + Meta Veri Motoru + Müzik keşfini, aramayı, parça listelerini ve çalma listelerini sağlar. + Ses Motoru + Çalınan ses akışlarını getirir ve çözer. + Şarkı Sözü Motoru + Zamanla senkronize karaoke sözlerini gerçek zamanlı olarak sunar. + Scrobble Motoru + Dinleme geçmişini Last.fm veya ListenBrainz\'e kaydeder. + Etkin + Yerleşik + Hazır + İsteğe bağlı + Bir motor seç + Yüklü Motorlar + Varsayılan yap + NewPipe YouTube ve LRCLib Lyrics yerleşiktir ve kutudan çıktığı gibi akışa hazırdır. + Topluluk eklentilerini istediğin zaman Ayarlar → Eklentiler bölümünden yönetebilir veya yenilerini ekleyebilirsin. + + + Retro-Modern Mimari + Saf Hi-Fi, sıfır aracı + Spotube\'un merkeziyetsiz çalmayı doğrudan cihazında nasıl yönettiğini gör. + DOĞRUDAN EŞLER ARASI • 256 KBPS OPUS + Mavi saat + Spotube · oynatıcı tanıtımı + Aradaki anlar için biraz müzik. + 1. Merkeziyetsiz Keşif + Sorgular doğrudan seçtiğin katalog eklentisine gider. Spotube zevkinin profilini asla çıkarmaz. + 2. Doğrudan Akış Çözümleme + Ses, içerik sunucularından doğrudan cihazına akar. Proxy aktarması veya uzaktan veri toplama yok. + 3. Yerel DSP ve Çalma + Ekolayzer kontrolleri ve isteğe bağlı çevrimdışı indirme ile cihazında çözülür ve önbelleğe alınır. + 4. Senkronize Sözler ve Jam + Karaoke sözleri çalma zamanını takip eder; LAN üzerinden uzaktan kontrol ve Grup Jam de var. + Aracı sunucu yok. Çalma doğrudan cihazında gerçekleşir — tasarımı gereği gizli. + + + Bir cihaz seç + %1$s: “%2$s” + Çal + Sıraya ekle + Sonra çal + Bu Cihaz + Bu cihazda + Uzak Cihaz + Uzak Cihaz (%1$s) + Bağlı cihazda + + + Bağlantı başarısız + Bağlantı kesildi + Gönderilemedi + Keşif başarısız: %1$s + Bağlanılamadı: %1$s + + + Cihazlar + Yenile + Bağlantıyı kes + Bağlanıyor... + Ağda cihaz aranıyor... + Cihaz bulunamadı + Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol. + Bilinmeyen Cihaz + Çözümleniyor... + + + Uzaktan Kontrol + Bağlantı kesildi + Bağlantı hatası: %1$s + Bilinmeyen Parça + Bilinmeyen Sanatçı + Ses düzeyi + Sıradan kaldır + Diğer seçenekler + + + Nerede Çalınsın? + Etkin bir Jam oturumun var. Bu koleksiyonun nerede çalınacağını seç. + Burada çal + Jam\'e öner + + + Bağlantı başarısız + Bağlantı kesildi + + + + Jam sırasına eklendi + + %1$d parça Jam sırasına eklendi + %1$d parça Jam sırasına eklendi + + + + Uzaktan Kontrol İsteği + \"%1$s\" bu cihazda çalmayı kontrol etmek istiyor. + Reddet + İzin ver + Her Zaman İzin Ver + + + Arama sonuçları + Tümü + Parçalar + Kullanıcılar + Kullanılabilir arama türü yok + Aramak için yazmaya başla + Parça aramak için yazmaya başla + Sonuç bulunamadı + Tümünü gör + Aranıyor + Parçalar yükleniyor... + Albümler yükleniyor... + Sanatçılar yükleniyor... + Çalma listeleri yükleniyor... + Kullanıcılar yükleniyor... + Albüm bulunamadı + Sanatçı bulunamadı + Çalma listesi bulunamadı + Kullanıcı bulunamadı + Plak kasaları karıştırılıyor... + Evren taranıyor... + Hoparlörler ısınıyor... + Plakların tozu alınıyor... + Müzik tanrılarına danışılıyor... + Antenler ayarlanıyor... + Plaklar karıştırılıyor... + Arama özellikleri yüklenemedi + Arama yapılamadı + Parçalar aranamadı + Albümler aranamadı + Sanatçılar aranamadı + Çalma listeleri aranamadı + Kullanıcılar aranamadı + + + Göz at + + + Senkronize + Düz + Senkronize şarkı sözü yok + Düz şarkı sözü yok + + + %2$s içinde %1$s + %1$d kbps + %1$s kHz • %2$d kanal + Meta veri eklentisi seçilmedi + + + Geri + Tekrar dene + İletişim kutusunu kapat + Yükleniyor... + Bilinmiyor + Bilinmeyen hata + + + Kullanıcı + Çalma listesi + + + Gerçekten Üzgünüz + Şu anda bir şeyler düzgün çalışmıyor. Deneyimin bizim için önemli ve bunu düzeltmek için elimizden geleni yapıyoruz. Lütfen tekrar dene veya aşağıdaki hata ayrıntılarına göz at. + Hata Ayrıntıları + Panoya kopyala + Tam günlüğü görüntüle + Tam Hata Günlüğü + + + Karıştır + Karışık çal + Takip et + Takibi bırak + Çalma listesini düzenle + %1$s tarafından + + + Parça seçenekleri + Jam\'e ekle + Radyo başlat + Sonra çal + Sıradakine taşı + Sıradan kaldır + Favorilerden kaldır + Favorilere kaydet + Çalma listesine ekle + İndir + Paylaş + + + Orijinal + Başlık + Sanatçı + Albüm + Süre + Sırala + Parça bulunamadı + Filtrele + Toplu işlemler + Tümünü İndir + %1$d Parçayı İndir + Tümünü Sıraya Ekle + %1$d Parçayı Sıraya Ekle + Tümünü Sonra Çal + %1$d Parçayı Sonra Çal + Tümünü Çalma Listesine Ekle + %1$d Parçayı Çalma Listesine Ekle + Tümünü Jam\'e Ekle + %1$d Parçayı Jam\'e Ekle + +%1$d kişi daha + + + Yüklenecek URL yok + Tarayıcı geçmişinde geri git + Tarayıcı geçmişinde ileri git + + + Kara listeyi filtrele... + Kara listede öğe yok + Kara listede \'%1$s\' ile eşleşen öğe yok + Parçalar + Parça + Kara listeye ekle + Kara listeden kaldır + + + Parça meta verisi yok + Akış URL\'si çözümlenemedi + Akış URL\'si çözümlenemedi. Önce parçayı çalmayı dene. + + + Kayıtlı Parçalar + Sen + + + Takip ediliyor + Popüler Parçalar + Popüler parçaları çal + Popüler parçaları sıraya ekle + Benzer Sanatçılar + Öne Çıkan Çalma Listeleri + Sanatçı sayfası yüklenemedi + Takipçi sayısı kullanılamıyor + %1$s B takipçi + %1$s Mn takipçi + %1$s Mr takipçi + + %1$d takipçi + %1$d takipçi + + + + Beğen + Beğenmekten vazgeç + Yer tutucu parça adı + Yer tutucu sanatçılar + Bilinmeyen Albüm + Konum + Ses düzeyi + Karıştırmayı aç + Karıştırmayı kapat + Önceki + Sonraki + Çal veya duraklat + Tekrar kapalı + Bir parçayı tekrarla + Tümünü tekrarla + Sesi kapat + Sesi aç + Diğer seçenekler + Oynatıcı seçenekleri + Oynatıcıyı daralt + Sözleri genişlet + Şu An Çalıyor + Şarkı Sözleri + Alternatif Kaynak + Alternatif kaynak + İndir + İndirildi + İndiriliyor... + Zaten indirildi + İndirme yeniden deneniyor... + %1$s indiriliyor + Çalma %1$s sonra duraklatılacak. + Seçilen dakika sayısından sonra çalmayı duraklat. + Dakika + 1 ile 600 dakika arasında seç + Zamanlayıcıyı iptal et + Başlat + Çalma URL\'si farklı akış ayarlarıyla çözümlendi. Vurgulanan akış geçerli tercihinle eşleşiyor; etkin URL bir sonraki akış isteğinde yenilenir. + Eşleşen ses kaynağı + Eşleşen kaynak yükleniyor… + Önbellekteki yerel dosya çalınırken eşleşen bir uzak kaynağa gerek yok. + Eşleşen kaynak yok. + Bu parça için önbellekte eşleşen ses kaynağı yok. + Akış ayrıntıları henüz önbelleğe alınmadı. Yüklemek için önce parçayı çal. + Parça ayrıntıları yüklenemedi. + Kullanılabilir akışlar + Bu parça yerel önbellekten çalınıyor. Bu çalma için akış varyantları kullanılmıyor. + Akışlar yükleniyor… + Bu kaynak için kullanılabilir akış yok. + Şu an seçili akış + Akış biçimi ve kalite tercihinle eşleşiyor + Etkin çalma URL\'si + Yerel önbellekten + Meta veri parçası + Başlık + Kimlik + Albüm + Eşleşme güveni + Harici URI + Albüm kimliği + Albüm türü + Albüm çıkış tarihi + Albümdeki parça sayısı + Albüm türleri + Süre + Parça numarası + Disk numarası + Müstehcen + Popülerlik + ISRC + Küçük resim URL\'leri + + + Kenar çubuğunu aç/kapat + + + Alternatif Kaynaklar + Şu anda çalan parça yok + Alternatif kaynaklar aranıyor... + Alternatif kaynak bulunamadı + Eşleşme: %1$d% + Etkin kaynak + + + At + Oturumdan çıkar + At ve yeniden katılmasını engelle + Önerileri kaldır + Sıraya eklediği tüm parçaları kaldır + Yeniden sırala + Sıradan kaldır + %1$s • Ekleyen: %2$s + + + Bilinmiyor + Çalma listesi yükleniyor... + Parça Ekle + Çalma Listesine Parça Ekle + Parça ara... + Ara + Parça bulunamadı + Eklemek için parça ara + Bitti + Ekle + Eklendi + + + Albüm yükleniyor... + + + Çalma Listene Ekle + Ara + Çalma listelerinde ara... + Çalma listelerin yükleniyor... + Henüz sana ait bir çalma listesi yok.\nParça eklemek için bir tane oluştur. + Çalma Listesi Oluştur + Çalma Listesini Düzenle + Oluştur + Ad + Çalma listesi adı + Açıklama + Açıklama (isteğe bağlı) + Herkese açık + Bu çalma listesini herkes görebilir + Ortak + Başkaları bu çalma listesine parça ekleyebilir + Kapak Görseli + Çalma listesi kapağı + Görsel yok + Görseli Değiştir + Görsel Seç + + + Yükle + Değiştir + Zip dosyasında plugin.json bulunamadı + Zip dosyasında manifest.zipline.json bulunamadı + Geçersiz plugin.json biçimi: %1$s + Eklenti okunamadı: %1$s + Eklenti yüklenemedi: %1$s + %1$s için daha yeni bir sürüm var. Yüklemeden önce güncellemeyi onayla. + %1$s zaten yüklü. Yüklemeden önce değiştirmeyi onayla. + Yerleşik eklentiler zaten dahil ve tekrar eklenemez. + Yerleşik eklentiler kaldırılamaz. + %1$s geçersiz bir apiVersion (%2$s) bildiriyor. %3$s ile uyumlu anlamsal sürümleme bekleniyor. + Spotube eklenti API sürümü %1$s geçersiz. + %1$s, eklenti API\'si %2$s hedefliyor ancak Spotube uyumsuz eklenti API\'si %3$s destekliyor. Aynı ana API sürümü için derlenmiş bir eklenti yükle. + + + Eklenti API\'si uyumlu değil + Eklenti yüklensin mi? + %1$s yüklü eklentilerine eklenecek. + Eklenti güncellensin mi? + Daha eski bir sürüm yüklü (%1$s). %2$s sürümüne güncellensin mi? + Yüklü eklenti değiştirilsin mi? + %1$s geçersiz bir anlamsal sürüm (%2$s) kullanıyor ve güncelleme olarak karşılaştırılamıyor. Yüklü eklenti yine de değiştirilsin mi? + Yüklü sürüm (%1$s) anlamsal sürümlemeyle karşılaştırılamıyor. %2$s ile değiştirilsin mi? + %1$s sürümü zaten yüklü. Mevcut eklenti sağlanan kopyayla değiştirilsin mi? + Yüklü %1$s sürümü, sağlanan %2$s sürümünden daha yeni. Yine de değiştirilsin mi? + Yüklü eklenti sağlanan kopyayla değiştirilsin mi? + + + %1$s: + Sürüm + API Sürümü + Geliştirici + Lisans + Yetenekler + İşlevler + Depo + İletişim + Hata Bildir + %1$s eklentisini destekle + + + Resmi + Doğrulanmış + GitHub yıldızları + GitHub depo URL\'si + + + Sürüm bulunamadı + Sürüm Seç + Bir sürüm seç + Ön sürüm + Sürüm Notları + + + Ana sayfa + Ara + Cihazda + Bağlan + Cihazlar + + + Pencereyi Gizle + Pencereyi Göster + Sonraki Parça + Önceki Parça + Karıştır + Tekrarla + Tekrar: KAPALI + Tekrar: BİR + Tekrar: TÜMÜ + Ses düzeyi: %1$d% + Ses + + Ses - + Sesi Kapat + Sesi Aç + Parçayı Beğen + Beğenmekten Vazgeç + Çıkış + + + Simge durumuna küçült + Ekranı kapla + Önceki boyuta getir + Kapat + + + Çalma + Medya çalma kontrolleri + + + Göz at + Şu An Çalıyor + Öne Çıkanlar + Kayıtlı Parçalar + Popüler Parçalar + diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml new file mode 100644 index 00000000..966a429c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -0,0 +1,938 @@ + + + + Черга + Очистити чергу + Фільтр черги... + Черга порожня + + Треки, виконавці, альбоми... + Очистити історію + Пошук... + + + Моя бібліотека + Бібліотека + Плейлисти + Альбоми + Виконавці + Локальні треки + Завантаження + Пошук у збережених плейлистах… + Пошук у збережених альбомах… + Пошук серед збережених виконавців… + Пошук папок або треків… + Фільтр бібліотеки + Закрити фільтр + Створити плейлист + Оновити локальні треки + Показати сіткою + Показати списком + Немає збережених плейлистів + Немає плейлистів за запитом «%1$s» + Немає збережених альбомів + Немає альбомів за запитом «%1$s» + Немає збережених виконавців + Немає виконавців за запитом «%1$s» + + + Папки + Папка + %1$s • %2$s + + %1$d трек + %1$d треки + %1$d треків + %1$d трека + + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Назад до папок + Слухати папку + Потрібен доступ до аудіо + Дозвольте доступ до аудіо, щоб переглядати музику на пристрої. + Надати доступ + Локальних папок не знайдено + У цій папці немає треків + Немає відповідних папок або треків + Не вдалося оновити локальні файли + + + Активних: %1$d • Завершених: %2$d + Очистити завершені + Завантажень поки немає + Завантаження %1$d% + У черзі + Завершено + Скасовано + Помилка + Помилка: %1$s + Скасувати завантаження + Повторити завантаження + Видалити завантаження + + + Виконавець + У чорному списку + Улюблені треки + Ваші збережені треки + Невідомий виконавець + Назва треку + Ім’я виконавця + + Вибрано + + Налаштування + + Плагіни + Мова та регіон + Оформлення + Відтворення + Завантаження + Кешування + Комп’ютер + Оновлення + Підтримка + Про застосунок + + Закрити + Зберегти + Скасувати + + Мова + Мова інтерфейсу застосунку. Зараз: %1$s + Як у системі + Країна + Країна для контенту та доступності. Зараз: %1$s + %1$s (%2$s) + + Тема + Як Spotube підлаштовується під тему системи. Зараз: %1$s + Світла + Темна + Системна + + Поверхня + Фоновий матеріал застосунку. Зараз: %1$s + Вода + Лід + Хром + Акрил + Скло + + Акцентний колір + Поточна палітра: %1$s + Акцентний колір + Виберіть акцентну палітру й подивіться, як вона виглядає у світлій і темній темах. + Світла + Темна + Попередній перегляд + Кнопка + Зелений гоблін + Електрик-фіолетовий + Океанський ціан + Помаранчевий захід + Трояндовий сад + Опівнічний синій + Металік-графіт + + Зовнішній вигляд + Матеріал поверхні й акцент. Зараз: %1$s · %2$s + Зовнішній вигляд + Виберіть матеріал поверхні, а потім відповідний акцент. Кожен варіант — продумане поєднання. + Поверхня + Акцент + Другорядна + + Палітра + Акцентна палітра Millennium. Зараз: %1$s + Палітра + Виберіть акцентну палітру Millennium і перегляньте її у світлій і темній темах. + Палітра + Палітра Millennium і акцент. Зараз: %1$s + Синя + Помаранчева + Лаймова + + Власна папка для завантажень + Поточна папка: %1$s + Залиште порожнім, щоб використовувати стандартну папку «Музика». + C:\Music\Spotube + Папки з локальною музикою + Додаткові папки для сканування на комп’ютері. Додано: %1$d + Додаткових папок ще не додано + Додаткові папки для вашої локальної бібліотеки. + Керувати + Додати папку + Видалити папку + Додаткових папок не додано. + Прибрати %1$s зі списку сканування + Папки завантажень і музичного кешу додаються автоматично. + Папка завантажень додається автоматично. + Папка музичного кешу додається автоматично. + Збереження… + Не вдалося зберегти папки. Зміни не втрачено — спробуйте ще раз. + + %1$d папка + %1$d папки + %1$d папок + %1$d папки + + Формат завантаження музики + Бажаний кодек і контейнер для завантажень. Зараз: %1$s + Якість завантаження музики + + Формат стримінгу музики + Бажаний кодек і контейнер для стримінгу. Зараз: %1$s + Якість стримінгу музики + Зараз: %1$s + Кешувати музику + Зберігати прослухане аудіо на пристрої для швидшого повторного відтворення. + Папка кешу + Зараз: %1$s + Зараз: стандартний кеш застосунку + Ліміт розміру кешу + Без обмежень + Поточний ліміт: %1$d MB + Укажіть максимальний розмір кешу в MB. Введіть 0 або залиште порожнім, щоб зняти обмеження. + 500 + Розмір має бути 0 або додатним числом. + Безкінечне відтворення + Продовжувати відтворення рекомендованими треками, коли черга закінчиться. + Порт проксі-сервера відтворення + Дозволити віддалене керування + Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення. + Назва пристрою для віддаленого керування + Назва, яку бачать інші пристрої під час пошуку. Зараз: %1$s + Задайте зрозумілу назву, яку бачитимуть інші пристрої у вашій мережі, коли знайдуть цей Spotube. + наприклад, Spotube у вітальні + ім’я хоста пристрою + Порт проксі-сервера відтворення. Зараз: %1$d + Укажіть порт від 1 до 65535. + 14769 + Введіть ціле число. + Порт має бути від 1 до 65535. + + Чорний список + Керування заблокованими треками й виконавцями + + Згортати в трей + Залишати Spotube в системному треї, коли вікно згорнуто. + Статус у Discord + Показувати в Discord, що зараз грає (на комп’ютері). + + Автоматично перевіряти оновлення + Шукати нові версії Spotube у фоновому режимі. + + Керування плагінами + Керуйте своїми плагінами + Плагін за замовчуванням: %1$s + Метадані + Аудіо + Тексти пісень + Скроблінг + Плагін не вибрано + Змінити + Вибрати + Вибрано + Скинути вибір + Немає доступних плагінів + Плагін %1$s + Скинути + + Налаштувати + Менеджер плагінів + Введіть URL + URL має починатися з http:// або https:// + Не вдалося завантажити плагін + Плагіни ще не встановлено + Вставте посилання для завантаження або виберіть файл вище. + + %1$d плагін встановлено + %1$d плагіни встановлено + %1$d плагінів встановлено + %1$d плагіна встановлено + + Встановлення плагіна + https://example.com/plugin.smplug + Завантажити + Встановити з файлу (.smplug) + Активний + v%1$s + Вбудований + Видалити плагін + Увійти + Вийти + Завантажити за URL + Встановити з файлу + Встановити + + • %1$s + Запитувані дозволи + Цьому плагіну не потрібні спеціальні дозволи. + Встановлений і новий + Встановлений + Новий + API %1$s → %2$s + Постійне сховище + Читання й запис даних на диск + Мережеві запити + Надсилання й отримання даних через інтернет + WebView + Показ вебвмісту всередині застосунку + Груповий джем + Перевірити підключення + Перевірка… + Підключено за %1$d мс + Помилка: %1$s + Підключення + Діагностика + + + Груповий джем + Створити + Приєднатися + Створіть кімнату й поділіться її кодом із друзями. + Створити кімнату + Увійти в кімнату + Підключення… + Код кімнати + ABC123 + Підключено + Повторне підключення… + Копіювати код + Скопійовано! + Поділитися + Учасники (%1$d) + Організатор + Слухач + Чекаємо на слухачів… + Видалити + Заблокувати + Керувати + Вийти з кімнати + Не вдалося підключитися + Закрити + Не вдалося створити джем + Не вдалося приєднатися до джему + Введіть код кімнати з шести літер і цифр. + Приєднуйся до мого джему в Spotube за кодом: %1$s + Сервер кімнат + URL сервера + Введіть URL, що починається з https:// + Перевірте сервер кімнат перед створенням кімнати. + Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть сервер із github.com/team-spotube/jam-server і вкажіть його URL. + Ви + Сервер: %1$s + Укажіть сервер кімнат у налаштуваннях, щоб створити кімнату або приєднатися до неї. + Налаштування сервера + %1$s (ви) + Ваше ім’я + Інші учасники джему бачать вас як %1$s + Видно всім у джемі. Залиште порожнім, щоб використовувати %1$s. Нове ім’я застосується, коли ви наступного разу створите кімнату або приєднаєтеся до неї. + Не більше %1$d символів. + Сервер джему не налаштовано + Не вдалося підключитися до сервера джему. Перевірте мережу або URL сервера в розділі Налаштування → Груповий джем. + Сервер джему використовує несумісну версію. + Кімнати з таким кодом немає. Перевірте код і спробуйте ще раз. + Ця кімната заповнена. + Організатор заблокував вас у цій кімнаті. + Організатор видалив вас із кімнати. + Цю кімнату знову відкрито на цьому пристрої, тому це підключення закрито. + + Перейти до альбому + Таймер сну + Відомості про трек + Відтворити + Пауза + Зараз нічого не грає + Додати до черги + + Пропустити + Оновити + Доступний Spotube %1$s + Дізнайтеся, що нового в цій версії, на сторінці завантажень Spotube. + Доступний %1$s %2$s + Встановлена версія: %1$s + Дізнайтеся, що нового в цьому оновленні, на сторінці плагіна. + + Пропустити + Далі + Назад + Почати + Spotube + Музика без зайвих умов. + Плеєр з відкритим кодом для цілого світу музики.\nДосліджуйте його. Зробіть його своїм. + Допоможіть проєкту жити. + Spotube створюють люди, а не бізнес на підписках. Якщо він став частиною вашого життя, навіть невелика підтримка допоможе нам приділяти йому ту увагу, на яку він заслуговує.\n\nДякуємо, що знаходите місце для незалежного софту. + Почніть із плагіна каталогу, щоб знаходити треки й плейлисти. Виберіть аудіоплагін для їх відтворення. Тексти пісень та історія прослуховувань — за бажанням. Знайдіть плагін нижче, перегляньте його дозволи, встановіть і виберіть тут. + Огляд плагінів + Ваші джерела + Повторити + Інші плагіни + Нових плагінів не знайдено. Плагін також можна встановити за посиланням або з файлу .smplug вище. + Каталог плагінів зараз недоступний. Повторіть спробу або встановіть плагін за посиланням чи з файлу. + Вітаємо + Підтримка + Плагіни + Посібник + + Як користуватися Spotube + Чотири прості кроки — і музика вже грає. + Вхід + Підключіть акаунт або виберіть власний плагін каталогу. + Шукайте й слухайте + Знайдіть будь-який трек, альбом чи виконавця й натисніть «Відтворити». + Слухайте офлайн + Завантажуйте треки, щоб слухати їх без інтернету. + Стежте за текстом + Відкривайте синхронізований текст у плеєрі під час відтворення. + + + Ваша музика. Ваші джерела. Ваші правила. + Безкоштовний музичний плеєр з відкритим кодом від спільноти, яка просто любить музику. + Відкритий код + Без реклами + Без телеметрії + На плагінах + Поїхали + + + Нас підтримують слухачі + Створено з любов’ю. Живе завдяки вам. + Spotube на 100% безкоштовний, вільний і відкритий — без реклами, підписок і стеження. Кожна функція існує завдяки таким людям, як ви, які допомагають проєкту розвиватися. + Patreon + Щомісячна підтримка + Підтримайте постійну розробку та інфраструктуру + Buy Me a Coffee + Разова кава + Підбадьорте нічну сесію програмування невеликим донатом + Open Collective + Фонд спільноти + Прозоре фінансування під керуванням спільноти + Щиро дякуємо, що вірите у відкриту музику. + Підтримка повністю добровільна — Spotube у будь-якому разі залишиться безкоштовним. + Підтримати + Відкрити %1$s + + + Підтримати Spotube + Підтримайте Spotube + Spotube безкоштовний, вільний і відкритий — без реклами, підписок і стеження. Якщо він приносить вам радість, підтримайте проєкт. + Ці нагадування можна вимкнути в розділі «Налаштування → Підтримка». + Можливо, пізніше + Нагадування про підтримку + Раз на 7 днів ненав’язливо нагадувати про підтримку проєкту. + + + Модульна архітектура + Виберіть, звідки брати музику. + У Spotube каталог, стримінг аудіо й синхронізовані тексти працюють окремо. Налаштуйте рушії нижче або змінюйте їх будь-коли. + Рушій метаданих + Відповідає за пошук музики, списки треків і плейлисти. + Аудіорушій + Отримує й декодує аудіопотоки для відтворення. + Рушій текстів + Показує синхронізовані караоке-тексти в реальному часі. + Рушій скроблінгу + Записує історію прослуховувань у Last.fm або ListenBrainz. + Активний + Вбудований + Готовий + Необов’язково + Виберіть рушій + Встановлені рушії + Зробити основним + NewPipe YouTube і LRCLib Lyrics вбудовані й працюють одразу після встановлення. + Керувати плагінами спільноти й додавати нові можна будь-коли в розділі «Налаштування → Плагіни». + + + Ретромодерн-архітектура + Чистий Hi-Fi без посередників + Ось як Spotube організовує децентралізоване відтворення просто на вашому пристрої. + НАПРЯМУ, P2P • 256 KBPS OPUS + Синя година + Spotube · демонстрація плеєра + Трохи музики для миттєвостей поміж справ. + 1. Децентралізований пошук + Запити надходять безпосередньо до вибраного плагіна каталогу. Spotube ніколи не аналізує ваші смаки. + 2. Пряме отримання потоку + Аудіо надходить безпосередньо із серверів контенту на ваш пристрій. Жодних проксі та віддаленого збору даних. + 3. Локальна обробка й відтворення + Декодування й кешування на пристрої, еквалайзер і завантаження для офлайн-прослуховування за бажанням. + 4. Синхронні тексти й джем + Караоке-тексти стежать за відтворенням, а ще є віддалене керування через LAN і груповий джем. + Жодних серверів-посередників. Відтворення відбувається просто на вашому пристрої — приватність за замовчуванням. + + + Виберіть пристрій + %1$s: «%2$s» + Відтворити + Додати до черги + Відтворити наступним + Цей пристрій + На цьому пристрої + Віддалений пристрій + Віддалений пристрій (%1$s) + На підключеному пристрої + + + Не вдалося підключитися + З’єднання втрачено + Не вдалося надіслати + Помилка пошуку: %1$s + Не вдалося підключитися: %1$s + + + Пристрої + Оновити + Від’єднатися + Підключення... + Пошук пристроїв у мережі... + Пристроїв не знайдено + Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування». + Невідомий пристрій + Визначення адреси... + + + Віддалене керування + Від’єднано + Помилка підключення: %1$s + Невідомий трек + Невідомий виконавець + Гучність + Видалити з черги + Інші дії + + + Де відтворити? + У вас активний джем. Виберіть, де відтворити цю добірку. + Відтворити тут + Запропонувати в джем + + + Не вдалося підключитися + З’єднання втрачено + + + + Додано до черги джему + + %1$d трек додано до черги джему + %1$d треки додано до черги джему + %1$d треків додано до черги джему + %1$d трека додано до черги джему + + + + Запит на віддалене керування + «%1$s» хоче керувати відтворенням на цьому пристрої. + Відхилити + Дозволити + Дозволяти завжди + + + Результати пошуку + Усі + Треки + Користувачі + Немає доступних типів пошуку + Почніть вводити запит + Почніть вводити, щоб знайти треки + Нічого не знайдено + Показати всі + Пошук + Завантаження треків... + Завантаження альбомів... + Завантаження виконавців... + Завантаження плейлистів... + Завантаження користувачів... + Альбомів не знайдено + Виконавців не знайдено + Плейлистів не знайдено + Користувачів не знайдено + Риємося в ящиках із платівками... + Шукаємо по всьому всесвіту... + Розігріваємо колонки... + Здуваємо пил із вінілу... + Радимося з богами музики... + Налаштовуємо антени... + Перебираємо платівки... + Не вдалося завантажити можливості пошуку + Не вдалося виконати пошук + Не вдалося знайти треки + Не вдалося знайти альбоми + Не вдалося знайти виконавців + Не вдалося знайти плейлисти + Не вдалося знайти користувачів + + + Огляд + + + Синхронний + Простий + Синхронізований текст недоступний + Текст недоступний + + + %1$s у %2$s + %1$d kbps + %1$s kHz • %2$d кан. + Плагін метаданих не вибрано + + + Назад + Повторити + Закрити вікно + Завантаження... + Невідомо + Невідома помилка + + + Користувач + Плейлист + + + Нам дуже прикро + Щось пішло не так. Нам важливі ваші враження, і ми робимо все, щоб це виправити. Спробуйте ще раз або перегляньте подробиці помилки нижче. + Подробиці помилки + Копіювати в буфер обміну + Показати повний журнал + Повний журнал помилки + + + Перемішати + Перемішати й слухати + Підписатися + Відписатися + Редагувати плейлист + Автор: %1$s + + + Дії з треком + Додати в джем + Запустити радіо + Відтворити наступним + Зробити наступним + Видалити з черги + Видалити з улюблених + Додати в улюблені + Додати в плейлист + Завантажити + Поділитися + + + Початковий порядок + Назва + Виконавець + Альбом + Тривалість + Сортування + Треків не знайдено + Фільтр + Дії з вибраними + Завантажити всі + Завантажити (%1$d) + Додати всі до черги + Додати до черги (%1$d) + Відтворити всі наступними + Відтворити наступними (%1$d) + Додати всі в плейлист + Додати в плейлист (%1$d) + Додати всі в джем + Додати в джем (%1$d) + і ще %1$d + + + Немає URL для завантаження + Назад в історії браузера + Уперед в історії браузера + + + Фільтр чорного списку... + Чорний список порожній + У чорному списку немає збігів із «%1$s» + Треки + Трек + Додати до чорного списку + Видалити з чорного списку + + + Немає метаданих треку + Не вдалося отримати URL потоку + Не вдалося отримати URL потоку. Спершу спробуйте відтворити трек. + + + Збережені треки + Ви + + + Ви підписані + Популярні треки + Слухати популярні треки + Додати популярні треки до черги + Схожі виконавці + Рекомендовані плейлисти + Не вдалося завантажити сторінку виконавця + Кількість підписників недоступна + Підписників: %1$s тис. + Підписників: %1$s млн + Підписників: %1$s млрд + + %1$d підписник + %1$d підписники + %1$d підписників + %1$d підписника + + + + Подобається + Більше не подобається + Назва треку-заглушки + Виконавці-заглушки + Невідомий альбом + Перемотування + Гучність + Увімкнути перемішування + Вимкнути перемішування + Попередній + Наступний + Відтворити або призупинити + Повтор вимкнено + Повтор треку + Повтор усіх + Вимкнути звук + Увімкнути звук + Інші дії + Параметри плеєра + Згорнути плеєр + Розгорнути текст + Зараз грає + Текст + Альтернативне джерело + Альтернативне джерело + Завантажити + Завантажено + Завантаження... + Уже завантажено + Повторне завантаження... + Завантаження: %1$s + Відтворення призупиниться через %1$s. + Призупинити відтворення через вибрану кількість хвилин. + Хвилини + Від 1 до 600 хвилин + Скасувати таймер + Запустити + URL відтворення отримано з іншими налаштуваннями стримінгу. Виділений потік відповідає вашим поточним налаштуванням; активний URL оновиться під час наступного запиту потоку. + Підібране джерело аудіо + Завантаження підібраного джерела… + Віддалене джерело не потрібне, поки відтворюється локальний кешований файл. + Відповідне джерело недоступне. + Для цього треку немає кешованого підібраного джерела аудіо. + Відомості про потоки ще не кешовано. Спершу відтворіть трек, щоб завантажити їх. + Не вдалося завантажити відомості про трек. + Доступні потоки + Цей трек відтворюється з локального кешу. Варіанти потоків зараз не використовуються. + Завантаження потоків… + Для цього джерела немає доступних потоків. + Вибраний потік + Відповідає вашим налаштуванням формату та якості стримінгу + Активний URL відтворення + З локального кешу + Трек із метаданих + Назва + ID + Альбом + Точність збігу + Зовнішній URI + ID альбому + Тип альбому + Дата виходу альбому + Треків в альбомі + Жанри альбому + Тривалість + Номер треку + Номер диска + Відвертий контент + Популярність + ISRC + URL мініатюр + + + Показати або сховати бічну панель + + + Альтернативні джерела + Зараз нічого не грає + Пошук альтернативних джерел... + Альтернативних джерел не знайдено + Збіг: %1$d% + Поточне джерело + + + Вигнати + Видалити учасника із сесії + Вигнати й заборонити повертатися + Видалити пропозиції + Видалити всі треки, які учасник додав до черги + Змінити порядок + Видалити з черги + %1$s • Додав(-ла) %2$s + + + Невідомо + Завантаження плейлиста... + Додати треки + Додати треки в плейлист + Пошук треків... + Пошук + Треків не знайдено + Знайдіть треки, щоб додати їх + Готово + Додати + Додано + + + Завантаження альбому... + + + Додати у ваш плейлист + Пошук + Пошук у ваших плейлистах... + Завантаження ваших плейлистів... + У вас поки немає власних плейлистів.\nСтворіть плейлист, щоб додавати до нього треки. + Новий плейлист + Редагувати плейлист + Створити + Назва + Назва плейлиста + Опис + Опис (необов’язково) + Публічний + Цей плейлист бачать усі + Спільний + Інші можуть додавати треки до цього плейлиста + Обкладинка + Обкладинка плейлиста + Немає зображення + Змінити зображення + Вибрати зображення + + + Встановити + Замінити + У zip-архіві не знайдено plugin.json + У zip-архіві не знайдено manifest.zipline.json + Неправильний формат plugin.json: %1$s + Не вдалося прочитати плагін: %1$s + Не вдалося встановити плагін: %1$s + Доступна новіша версія %1$s. Підтвердьте оновлення перед встановленням. + %1$s уже встановлено. Підтвердьте заміну перед встановленням. + Вбудовані плагіни вже включено, їх не можна додати повторно. + Вбудовані плагіни не можна видалити. + %1$s вказує недійсну apiVersion (%2$s). Очікується семантична версія, сумісна з %3$s. + Недійсна версія API плагінів Spotube: %1$s. + %1$s розрахований на API плагінів %2$s, а Spotube підтримує несумісну версію API %3$s. Встановіть плагін, зібраний для тієї самої основної версії API. + + + Несумісний API плагіна + Встановити плагін? + %1$s буде додано до встановлених плагінів. + Оновити плагін? + Встановлено старішу версію (%1$s). Оновити до %2$s? + Замінити встановлений плагін? + %1$s використовує недійсну семантичну версію (%2$s), тому її не можна порівняти як оновлення. Усе одно замінити встановлений плагін? + Встановлену версію (%1$s) не можна порівняти за правилами семантичного версіонування. Замінити її на %2$s? + Версію %1$s уже встановлено. Замінити наявний плагін новою копією? + Встановлена версія %1$s новіша за запропоновану версію %2$s. Усе одно замінити? + Замінити встановлений плагін новою копією? + + + %1$s: + Версія + Версія API + Автор + Ліцензія + Дозволи + Функції + Репозиторій + Контакти + Повідомити про помилку + Підтримати %1$s + + + Офіційний + Перевірений + Зірки на GitHub + URL репозиторію на GitHub + + + Релізів не знайдено + Виберіть реліз + Виберіть реліз + Попередній реліз + Примітки до випуску + + + Головна + Пошук + На пристрої + Підключення + Пристрої + + + Сховати вікно + Показати вікно + Наступний трек + Попередній трек + Перемішати + Повтор + Повтор: ВИМК + Повтор: ТРЕК + Повтор: УСІ + Гучність: %1$d% + Гучність + + Гучність - + Вимкнути звук + Увімкнути звук + Подобається + Більше не подобається + Вийти + + + Згорнути + Розгорнути + Відновити + Закрити + + + Відтворення + Керування відтворенням + + + Огляд + Зараз грає + Рекомендоване + Збережені треки + Популярні треки + diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml new file mode 100644 index 00000000..7866e671 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -0,0 +1,920 @@ + + + + Danh sách chờ + Xóa danh sách chờ + Lọc danh sách chờ... + Danh sách chờ trống + + Tìm bài hát, nghệ sĩ, album... + Xóa toàn bộ lịch sử + Tìm kiếm... + + + Thư viện của bạn + Thư viện + Danh sách phát + Album + Nghệ sĩ + Bài hát trên máy + Đã tải xuống + Tìm danh sách phát đã lưu… + Tìm album đã lưu… + Tìm nghệ sĩ đã lưu… + Tìm thư mục hoặc bài hát… + Lọc thư viện + Đóng bộ lọc + Tạo danh sách phát + Làm mới bài hát trên máy + Chuyển sang dạng lưới + Chuyển sang dạng danh sách + Chưa có danh sách phát nào được lưu + Không có danh sách phát nào khớp với “%1$s” + Chưa có album nào được lưu + Không có album nào khớp với “%1$s” + Chưa có nghệ sĩ nào được lưu + Không có nghệ sĩ nào khớp với “%1$s” + + + Thư mục + Thư mục + %1$s • %2$s + + %1$d bài hát + + + %1$d thư mục + + Quay lại thư mục + Phát thư mục + Cần quyền truy cập âm thanh + Cho phép truy cập âm thanh để duyệt nhạc trên thiết bị của bạn. + Cấp quyền + Không tìm thấy thư mục nào trên máy + Thư mục này không có bài hát nào + Không có thư mục hoặc bài hát nào khớp + Không thể làm mới nội dung trên máy + + + %1$d đang tải • %2$d đã xong + Xóa mục đã xong + Chưa có nội dung tải xuống + Đang tải xuống %1$d% + Đang chờ + Đã xong + Đã hủy + Thất bại + Thất bại: %1$s + Hủy tải xuống + Thử tải lại + Xóa mục tải xuống + + + Nghệ sĩ + Đã chặn + Bài hát đã thích + Bài hát bạn đã lưu + Nghệ sĩ không xác định + Tên bài hát + Tên nghệ sĩ + + Đã chọn + + Cài đặt + + Plugin + Ngôn ngữ và khu vực + Giao diện + Phát lại + Tải xuống + Bộ nhớ đệm + Máy tính + Cập nhật + Ủng hộ + Giới thiệu + + Đóng + Lưu + Hủy + + Ngôn ngữ + Ngôn ngữ giao diện ứng dụng. Hiện tại: %1$s + Mặc định của hệ thống + Quốc gia + Quốc gia dùng cho nội dung và tính khả dụng. Hiện tại: %1$s + %1$s (%2$s) + + Chủ đề + Chọn cách Spotube khớp với giao diện hệ thống. Hiện tại: %1$s + Sáng + Tối + Theo hệ thống + + Bề mặt + Chất liệu nền của ứng dụng. Hiện tại: %1$s + Nước + Băng + Chrome + Acrylic + Kính + + Màu nhấn + Bảng màu hiện tại: %1$s + Màu nhấn + Chọn bảng màu nhấn và xem trước ở cả chủ đề sáng và tối. + Sáng + Tối + Xem trước + Nút + Xanh yêu tinh + Tím điện + Lam đại dương + Cam hoàng hôn + Vườn hồng + Xanh nửa đêm + Xám kim loại + + Giao diện & cảm nhận + Chất liệu bề mặt và màu nhấn. Hiện tại: %1$s · %2$s + Giao diện & cảm nhận + Chọn chất liệu bề mặt, sau đó chọn màu nhấn phù hợp. Mỗi lựa chọn đều là một sự kết hợp được tuyển chọn. + Bề mặt + Màu nhấn + Phụ + + Bảng màu + Bảng màu nhấn Millennium. Hiện tại: %1$s + Bảng màu + Chọn bảng màu nhấn Millennium và xem trước ở chế độ sáng và tối. + Bảng màu + Bảng màu Millennium và màu nhấn. Hiện tại: %1$s + Xanh lam + Cam + Xanh chanh + + Thư mục tải xuống tùy chỉnh + Thư mục hiện tại: %1$s + Để trống để dùng thư mục nhạc mặc định. + C:\Music\Spotube + Thư mục nhạc trên máy + Thư mục tùy chỉnh để quét trên máy tính. Đã thêm: %1$d + Chưa thêm thư mục tùy chỉnh nào + Thư mục tùy chỉnh cho thư viện nhạc trên máy của bạn. + Quản lý + Thêm thư mục + Xóa thư mục + Chưa thêm thư mục tùy chỉnh nào. + Xóa %1$s khỏi danh sách quét + Thư mục tải xuống và thư mục bộ nhớ đệm nhạc được tự động thêm vào. + Thư mục tải xuống của bạn được tự động thêm vào. + Thư mục bộ nhớ đệm nhạc được tự động thêm vào. + Đang lưu… + Không thể lưu thư mục. Các thay đổi của bạn vẫn còn đây; hãy thử lại. + + %1$d thư mục + + Định dạng nhạc tải xuống + Codec và container ưu tiên khi tải xuống. Hiện tại: %1$s + Chất lượng nhạc tải xuống + + Định dạng nhạc phát trực tuyến + Codec và container ưu tiên khi phát trực tuyến. Hiện tại: %1$s + Chất lượng nhạc phát trực tuyến + Hiện tại: %1$s + Bật bộ nhớ đệm nhạc + Lưu âm thanh đã phát trực tuyến trên máy để phát lại nhanh hơn. + Thư mục bộ nhớ đệm + Hiện tại: %1$s + Hiện tại: bộ nhớ đệm mặc định của ứng dụng + Giới hạn dung lượng bộ nhớ đệm + Không giới hạn + Giới hạn hiện tại: %1$d MB + Đặt dung lượng bộ nhớ đệm tối đa tính bằng MB. Nhập 0 hoặc để trống nếu không muốn giới hạn. + 500 + Dung lượng phải là 0 hoặc một số dương. + Bật phát liên tục + Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc. + Cổng máy chủ proxy phát lại + Cho phép điều khiển từ xa + Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối. + Tên thiết bị điều khiển từ xa + Tên mà các thiết bị khác thấy khi tìm thấy thiết bị này. Hiện tại: %1$s + Chọn một tên dễ nhận biết để hiển thị cho các thiết bị khác trong mạng khi chúng tìm thấy Spotube này. + VD: Spotube phòng khách + tên máy chủ của thiết bị + Cổng được máy chủ proxy phát lại sử dụng. Hiện tại: %1$d + Chọn một cổng từ 1 đến 65535. + 14769 + Nhập một số nguyên hợp lệ. + Cổng phải nằm trong khoảng từ 1 đến 65535. + + Danh sách chặn + Quản lý bài hát và nghệ sĩ bị chặn + + Thu nhỏ xuống khay hệ thống + Giữ Spotube chạy trong khay hệ thống khi thu nhỏ cửa sổ. + Discord Rich Presence + Hiển thị trạng thái đang phát trên Discord trên máy tính. + + Tự động kiểm tra cập nhật + Tìm các phiên bản Spotube mới trong nền. + + Quản lý plugin + Quản lý các plugin của bạn + Plugin %1$s mặc định + Siêu dữ liệu + Âm thanh + Lời bài hát + Scrobble + Chưa chọn plugin + Thay đổi + Chọn + Đã chọn + Bỏ chọn + Không có plugin nào + Plugin %1$s + Xóa + + Cấu hình + Trình quản lý plugin + Vui lòng nhập URL + URL phải bắt đầu bằng http:// hoặc https:// + Không thể tải plugin xuống + Chưa cài đặt plugin nào + Dán URL tải xuống hoặc chọn tệp ở trên. + + Đã cài đặt %1$d plugin + + Cài đặt plugin + https://example.com/plugin.smplug + Tải xuống + Cài đặt từ tệp (.smplug) + Đang dùng + v%1$s + Tích hợp sẵn + Gỡ plugin + Đăng nhập + Đăng xuất + Tải xuống từ URL + Cài đặt từ tệp + Cài đặt + + • %1$s + Quyền được yêu cầu + Plugin này không yêu cầu quyền đặc biệt nào. + Đã cài đặt và bản cung cấp + Đã cài đặt + Bản cung cấp + API %1$s → %2$s + Lưu trữ lâu dài + Đọc và ghi dữ liệu vào ổ đĩa + Yêu cầu mạng + Gửi và nhận dữ liệu qua internet + WebView + Hiển thị nội dung web trong ứng dụng + Jam nhóm + Kiểm tra kết nối + Đang kiểm tra… + Đã kết nối trong %1$d ms + Thất bại: %1$s + Kết nối + Chẩn đoán + + + Jam nhóm + Tạo + Tham gia + Tạo phòng, sau đó chia sẻ mã phòng với bạn bè. + Tạo phòng + Vào phòng + Đang kết nối… + Mã phòng + ABC123 + Đã kết nối + Đang kết nối lại… + Sao chép mã + Đã sao chép! + Chia sẻ + Người tham gia (%1$d) + Chủ phòng + Người nghe + Đang chờ người nghe… + Xóa + Cấm + Quản lý + Rời phòng + Không thể kết nối + Bỏ qua + Không thể tạo phiên Jam + Không thể tham gia phiên Jam + Nhập mã phòng gồm sáu ký tự chữ và số. + Tham gia Jam Spotube của mình với mã: %1$s + Máy chủ phòng + URL máy chủ + Nhập URL bắt đầu bằng https:// + Kiểm tra máy chủ phòng trước khi tạo phòng. + Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai máy chủ từ github.com/team-spotube/jam-server rồi nhập URL của nó. + Bạn + Máy chủ: %1$s + Đặt máy chủ phòng trong Cài đặt để tạo hoặc tham gia phòng. + Cài đặt máy chủ + %1$s (bạn) + Tên của bạn + Mọi người trong jam thấy bạn là %1$s + Hiển thị cho mọi người trong jam. Để trống để dùng %1$s. Tên mới sẽ áp dụng từ lần tới bạn tạo hoặc tham gia phòng. + Dùng tối đa %1$d ký tự. + Chưa cấu hình máy chủ jam + Không kết nối được máy chủ jam. Hãy kiểm tra mạng hoặc URL máy chủ trong Cài đặt → Jam nhóm. + Máy chủ jam đang chạy phiên bản không tương thích. + Không có phòng nào với mã này. Hãy kiểm tra và thử lại. + Phòng này đã đầy. + Chủ phòng đã cấm bạn khỏi phòng này. + Chủ phòng đã xóa bạn khỏi phòng. + Phòng này đã được mở lại trên thiết bị này nên kết nối này đã bị đóng. + + Đi tới album + Hẹn giờ tắt + Chi tiết bài hát + Phát + Tạm dừng + Không có gì đang phát + Thêm vào danh sách chờ + + Bỏ qua + Cập nhật + Đã có Spotube %1$s + Xem có gì mới trong bản phát hành này trên trang tải xuống Spotube. + Đã có %1$s %2$s + Đang cài đặt: %1$s + Xem có gì mới trong bản cập nhật này trên trang của plugin. + + Bỏ qua + Tiếp + Quay lại + Bắt đầu + Spotube + Âm nhạc, không ràng buộc. + Trình phát mã nguồn mở cho cả thế giới âm nhạc.\nTùy bạn khám phá. Tùy bạn biến nó thành của riêng mình. + Giúp dự án này tiếp tục sống. + Spotube được tạo ra bởi con người, không phải một doanh nghiệp thu phí thuê bao. Nếu Spotube trở thành một phần cuộc sống hằng ngày của bạn, một chút ủng hộ sẽ giúp chúng tôi chăm chút nó xứng đáng hơn.\n\nCảm ơn bạn đã dành chỗ cho phần mềm độc lập. + Bắt đầu với một plugin danh mục để tìm bài hát và danh sách phát. Chọn một plugin âm thanh để phát chúng. Lời bài hát và lịch sử nghe là tùy chọn. Hãy khám phá plugin bên dưới, xem lại quyền của nó, cài đặt rồi chọn nó tại đây. + Khám phá plugin + Nguồn của bạn + Thử lại + Thêm plugin + Không tìm thấy plugin mới. Bạn cũng có thể cài đặt từ liên kết hoặc tệp .smplug ở trên. + Hiện không thể khám phá plugin. Hãy thử lại hoặc cài đặt từ liên kết hay tệp. + Chào mừng + Ủng hộ + Plugin + Hướng dẫn + + Cách dùng Spotube + Bốn bước nhanh là bạn đã có thể nghe nhạc. + Đăng nhập + Kết nối tài khoản hoặc chọn plugin danh mục của riêng bạn. + Tìm kiếm và phát + Tìm bất kỳ bài hát, album hay nghệ sĩ nào rồi nhấn phát. + Nghe ngoại tuyến + Tải bài hát xuống để tiếp tục nghe khi không có kết nối. + Theo dõi lời bài hát + Mở lời bài hát đồng bộ từ trình phát khi bài hát đang phát. + + + Âm nhạc của bạn. Nguồn của bạn. Luật của bạn. + Trình phát nhạc miễn phí, mã nguồn mở, được xây dựng bởi một cộng đồng yêu âm nhạc. + Mã nguồn mở + Không quảng cáo + Không thu thập dữ liệu + Vận hành bằng plugin + Bắt đầu thôi + + + Người ủng hộ giúp chúng tôi tiếp bước + Tạo nên bằng đam mê. Duy trì nhờ bạn. + Spotube miễn phí 100%, tự do và mã nguồn mở — không quảng cáo, không thuê bao, không theo dõi. Mọi tính năng tồn tại là nhờ những người như bạn đã chọn giúp mã nguồn tiếp tục phát triển. + Patreon + Tài trợ hằng tháng + Hỗ trợ phát triển và hạ tầng lâu dài + Buy Me a Coffee + Một ly cà phê + Tiếp sức cho một buổi code đêm khuya bằng một khoản tip nhỏ + Open Collective + Quỹ cộng đồng + Hỗ trợ tài chính minh bạch, do cộng đồng quản lý + Từ tận đáy lòng, cảm ơn bạn đã tin vào âm nhạc mở. + Ủng hộ hoàn toàn là tùy chọn — Spotube vẫn luôn miễn phí. + Tài trợ + Mở %1$s + + + Ủng hộ Spotube + Ủng hộ Spotube + Spotube miễn phí, tự do và mã nguồn mở — không quảng cáo, không thuê bao, không theo dõi. Nếu Spotube mang lại niềm vui cho bạn, hãy cân nhắc ủng hộ dự án. + Bạn có thể tắt lời nhắc này trong Cài đặt → Ủng hộ. + Để sau + Lời nhắc ủng hộ + Hiển thị lời nhắc nhẹ nhàng về việc ủng hộ dự án mỗi 7 ngày. + + + Kiến trúc mô-đun + Chọn nơi lấy nhạc của bạn. + Spotube tách riêng việc khám phá danh mục, phát âm thanh trực tuyến và lời bài hát đồng bộ. Cấu hình các engine bên dưới hoặc chuyển đổi bất cứ lúc nào. + Engine siêu dữ liệu + Cung cấp khám phá nhạc, tìm kiếm, danh sách bài hát và danh sách phát. + Engine âm thanh + Lấy và giải mã luồng âm thanh để phát. + Engine lời bài hát + Truyền lời bài hát karaoke đồng bộ theo thời gian thực. + Engine scrobble + Ghi lại lịch sử nghe lên Last.fm hoặc ListenBrainz. + Đang dùng + Tích hợp sẵn + Sẵn sàng + Tùy chọn + Chọn một engine + Engine đã cài đặt + Đặt làm mặc định + NewPipe YouTube và LRCLib Lyrics được tích hợp sẵn và có thể phát ngay. + Quản lý hoặc thêm plugin cộng đồng bất cứ lúc nào trong Cài đặt → Plugin. + + + Kiến trúc cổ điển hiện đại + Hi-Fi thuần túy, không trung gian + Đây là cách Spotube điều phối việc phát nhạc phi tập trung ngay trên máy của bạn. + NGANG HÀNG TRỰC TIẾP • 256 KBPS OPUS + Giờ xanh + Spotube · minh họa trình phát + Một chút âm nhạc cho những khoảnh khắc xen giữa. + 1. Khám phá phi tập trung + Truy vấn được gửi thẳng tới plugin danh mục bạn chọn. Spotube không bao giờ lập hồ sơ sở thích của bạn. + 2. Phân giải luồng trực tiếp + Âm thanh được truyền trực tiếp từ máy chủ nội dung tới máy của bạn. Không qua proxy, không thu thập dữ liệu từ xa. + 3. DSP & phát lại cục bộ + Giải mã và lưu đệm cục bộ với bộ chỉnh âm và tùy chọn tải xuống ngoại tuyến. + 4. Lời bài hát đồng bộ & Jam + Lời karaoke bám theo thời điểm phát, cùng với điều khiển từ xa qua LAN và Jam nhóm. + Không có máy chủ trung gian. Việc phát nhạc diễn ra ngay trên thiết bị của bạn — riêng tư từ thiết kế. + + + Chọn thiết bị + %1$s: “%2$s” + Phát + Thêm vào danh sách chờ + Phát tiếp theo + Thiết bị này + Trên thiết bị này + Thiết bị từ xa + Thiết bị từ xa (%1$s) + Trên thiết bị đã kết nối + + + Kết nối thất bại + Mất kết nối + Gửi thất bại + Tìm thiết bị thất bại: %1$s + Không thể kết nối: %1$s + + + Thiết bị + Làm mới + Ngắt kết nối + Đang kết nối... + Đang tìm thiết bị trong mạng... + Không tìm thấy thiết bị nào + Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt. + Thiết bị không xác định + Đang phân giải... + + + Điều khiển từ xa + Đã ngắt kết nối + Lỗi kết nối: %1$s + Bài hát không xác định + Nghệ sĩ không xác định + Âm lượng + Xóa khỏi danh sách chờ + Tùy chọn khác + + + Phát ở đâu? + Bạn đang có một phiên Jam. Chọn nơi phát bộ sưu tập này. + Phát tại đây + Đề xuất cho Jam + + + Kết nối thất bại + Mất kết nối + + + + Đã thêm vào danh sách chờ của Jam + + Đã thêm %1$d bài hát vào danh sách chờ của Jam + + + + Yêu cầu điều khiển từ xa + \"%1$s\" muốn điều khiển việc phát trên thiết bị này. + Từ chối + Cho phép + Luôn cho phép + + + Kết quả tìm kiếm + Tất cả + Bài hát + Người dùng + Không có loại tìm kiếm nào + Nhập để bắt đầu tìm kiếm + Nhập để bắt đầu tìm bài hát + Không tìm thấy kết quả + Xem tất cả + Đang tìm kiếm + Đang tải bài hát... + Đang tải album... + Đang tải nghệ sĩ... + Đang tải danh sách phát... + Đang tải người dùng... + Không tìm thấy album nào + Không tìm thấy nghệ sĩ nào + Không tìm thấy danh sách phát nào + Không tìm thấy người dùng nào + Đang lục tìm kho đĩa... + Đang tìm khắp vũ trụ... + Đang làm nóng loa... + Đang phủi bụi đĩa than... + Đang hỏi ý các vị thần âm nhạc... + Đang dò ăng-ten... + Đang lật giở các đĩa nhạc... + Không thể tải khả năng tìm kiếm + Tìm kiếm thất bại + Không thể tìm bài hát + Không thể tìm album + Không thể tìm nghệ sĩ + Không thể tìm danh sách phát + Không thể tìm người dùng + + + Duyệt + + + Đồng bộ + Thường + Không có lời bài hát đồng bộ + Không có lời bài hát + + + %1$s trong %2$s + %1$d kbps + %1$s kHz • %2$d kênh + Chưa chọn plugin siêu dữ liệu + + + Quay lại + Thử lại + Đóng hộp thoại + Đang tải... + Không xác định + Lỗi không xác định + + + Người dùng + Danh sách phát + + + Chúng tôi thật sự xin lỗi + Đã có sự cố xảy ra. Trải nghiệm của bạn rất quan trọng với chúng tôi và chúng tôi đang cố gắng hết sức để khắc phục. Vui lòng thử lại hoặc xem chi tiết lỗi bên dưới. + Chi tiết lỗi + Sao chép vào bộ nhớ tạm + Xem nhật ký đầy đủ + Nhật ký lỗi đầy đủ + + + Phát ngẫu nhiên + Phát ngẫu nhiên + Theo dõi + Bỏ theo dõi + Sửa danh sách phát + Của %1$s + + + Tùy chọn bài hát + Thêm vào Jam + Phát radio + Phát tiếp theo + Chuyển lên phát tiếp theo + Xóa khỏi danh sách chờ + Xóa khỏi mục yêu thích + Lưu vào mục yêu thích + Thêm vào danh sách phát + Tải xuống + Chia sẻ + + + Mặc định + Tên + Nghệ sĩ + Album + Thời lượng + Sắp xếp + Không tìm thấy bài hát nào + Lọc + Thao tác hàng loạt + Tải xuống tất cả + Tải xuống %1$d + Thêm tất cả vào danh sách chờ + Thêm %1$d vào danh sách chờ + Phát tất cả tiếp theo + Phát %1$d bài tiếp theo + Thêm tất cả vào danh sách phát + Thêm %1$d vào danh sách phát + Thêm tất cả vào Jam + Thêm %1$d vào Jam + +%1$d người khác + + + Không có URL để tải + Quay lại trang trước + Đi tới trang sau + + + Lọc danh sách chặn... + Không có mục nào bị chặn + Không có mục bị chặn nào khớp với \'%1$s\' + Bài hát + Bài hát + Thêm vào danh sách chặn + Xóa khỏi danh sách chặn + + + Không có siêu dữ liệu bài hát + Không thể phân giải URL luồng + Không thể phân giải URL luồng. Hãy thử phát bài hát trước. + + + Bài hát đã lưu + Bạn + + + Đang theo dõi + Bài hát hàng đầu + Phát bài hát hàng đầu + Thêm bài hát hàng đầu vào danh sách chờ + Nghệ sĩ liên quan + Danh sách phát nổi bật + Không thể tải thông tin nghệ sĩ + Không có thông tin người theo dõi + %1$s N người theo dõi + %1$s Tr người theo dõi + %1$s T người theo dõi + + %1$d người theo dõi + + + + Thích + Bỏ thích + Tên bài hát trống + Nghệ sĩ trống + Album không xác định + Tua + Âm lượng + Bật phát ngẫu nhiên + Tắt phát ngẫu nhiên + Trước + Tiếp + Phát hoặc tạm dừng + Tắt lặp lại + Lặp lại một bài + Lặp lại tất cả + Tắt tiếng + Bật tiếng + Tùy chọn khác + Tùy chọn trình phát + Thu gọn trình phát + Mở rộng lời bài hát + Đang phát + Lời bài hát + Nguồn thay thế + Nguồn thay thế + Tải xuống + Đã tải xuống + Đang tải xuống... + Đã tải xuống trước đó + Đang thử tải lại... + Đang tải xuống %1$s + Nhạc sẽ tạm dừng sau %1$s. + Tạm dừng phát sau số phút đã chọn. + Phút + Chọn từ 1 đến 600 phút + Hủy hẹn giờ + Bắt đầu + URL phát được phân giải với cài đặt phát trực tuyến khác. Luồng được đánh dấu khớp với lựa chọn hiện tại của bạn; URL đang dùng sẽ được làm mới ở lần yêu cầu luồng tiếp theo. + Nguồn âm thanh khớp + Đang tải nguồn khớp… + Không cần nguồn từ xa khớp khi đang phát tệp đệm trên máy. + Không có nguồn khớp nào. + Chưa có nguồn âm thanh khớp nào được lưu đệm cho bài hát này. + Chi tiết luồng chưa được lưu đệm. Hãy phát bài hát trước để tải chúng. + Không thể tải chi tiết bài hát. + Các luồng khả dụng + Bài hát này đang được phát từ bộ nhớ đệm trên máy. Các biến thể luồng không được dùng cho lần phát này. + Đang tải luồng… + Không có luồng nào cho nguồn này. + Luồng đang được chọn + Khớp với định dạng & chất lượng phát trực tuyến bạn chọn + URL phát đang dùng + Phát từ bộ nhớ đệm trên máy + Bài hát theo siêu dữ liệu + Tên + ID + Album + Độ tin cậy + URI bên ngoài + ID album + Loại album + Ngày phát hành album + Số bài hát trong album + Thể loại album + Thời lượng + Số thứ tự bài hát + Số đĩa + Nội dung tục tĩu + Độ phổ biến + ISRC + URL ảnh thu nhỏ + + + Bật/tắt thanh bên + + + Nguồn thay thế + Hiện không có bài hát nào đang phát + Đang tìm nguồn thay thế... + Không tìm thấy nguồn thay thế nào + Độ tin cậy: %1$d% + Nguồn đang dùng + + + Mời ra + Xóa người này khỏi phiên + Mời ra và không cho tham gia lại + Xóa đề xuất + Xóa mọi bài hát họ đã thêm vào danh sách chờ + Sắp xếp lại + Xóa khỏi danh sách chờ + %1$s • Được thêm bởi %2$s + + + Không xác định + Đang tải danh sách phát... + Thêm bài hát + Thêm bài hát vào danh sách phát + Tìm bài hát... + Tìm kiếm + Không tìm thấy bài hát nào + Tìm bài hát để thêm + Xong + Thêm + Đã thêm + + + Đang tải album... + + + Thêm vào danh sách phát của bạn + Tìm kiếm + Tìm danh sách phát của bạn... + Đang tải danh sách phát của bạn... + Bạn chưa có danh sách phát nào.\nHãy tạo một danh sách để thêm bài hát. + Tạo danh sách phát + Sửa danh sách phát + Tạo + Tên + Tên danh sách phát + Mô tả + Mô tả (không bắt buộc) + Công khai + Ai cũng có thể xem danh sách phát này + Cộng tác + Người khác có thể thêm bài hát vào danh sách phát này + Ảnh bìa + Ảnh bìa danh sách phát + Không có ảnh + Đổi ảnh + Chọn ảnh + + + Cài đặt + Thay thế + Không tìm thấy plugin.json trong tệp zip + Không tìm thấy manifest.zipline.json trong tệp zip + Định dạng plugin.json không hợp lệ: %1$s + Không thể đọc plugin: %1$s + Không thể cài đặt plugin: %1$s + Đã có phiên bản mới hơn của %1$s. Hãy xác nhận cập nhật trước khi cài đặt. + %1$s đã được cài đặt. Hãy xác nhận thay thế trước khi cài đặt. + Plugin tích hợp sẵn đã có trong ứng dụng và không thể thêm lại. + Không thể gỡ plugin tích hợp sẵn. + %1$s khai báo apiVersion không hợp lệ (%2$s). Cần phiên bản theo chuẩn semantic versioning tương thích với %3$s. + Phiên bản API plugin Spotube %1$s không hợp lệ. + %1$s nhắm tới API plugin %2$s, nhưng Spotube hỗ trợ API plugin %3$s có thay đổi không tương thích. Hãy cài đặt plugin được xây dựng cho cùng phiên bản API chính. + + + API plugin không tương thích + Cài đặt plugin? + %1$s sẽ được thêm vào danh sách plugin đã cài đặt. + Cập nhật plugin? + Đang cài đặt phiên bản cũ hơn (%1$s). Cập nhật lên %2$s? + Thay thế plugin đã cài đặt? + %1$s dùng phiên bản semantic không hợp lệ (%2$s) nên không thể so sánh như một bản cập nhật. Vẫn thay thế plugin đã cài đặt? + Không thể so sánh phiên bản đã cài đặt (%1$s) theo semantic versioning. Thay thế bằng %2$s? + Phiên bản %1$s đã được cài đặt. Thay thế plugin hiện có bằng bản được cung cấp? + Phiên bản đã cài đặt %1$s mới hơn phiên bản được cung cấp %2$s. Vẫn thay thế? + Thay thế plugin đã cài đặt bằng bản được cung cấp? + + + %1$s: + Phiên bản + Phiên bản API + Tác giả + Giấy phép + Khả năng + Chức năng + Kho mã + Liên hệ + Báo lỗi + Ủng hộ %1$s + + + Chính thức + Đã xác minh + Sao GitHub + URL kho mã GitHub + + + Không tìm thấy bản phát hành nào + Chọn bản phát hành + Chọn một bản phát hành + Bản thử nghiệm + Ghi chú phát hành + + + Trang chủ + Tìm kiếm + Trên thiết bị + Kết nối + Thiết bị + + + Ẩn cửa sổ + Hiện cửa sổ + Bài tiếp theo + Bài trước + Phát ngẫu nhiên + Lặp lại + Lặp lại: TẮT + Lặp lại: MỘT BÀI + Lặp lại: TẤT CẢ + Âm lượng: %1$d% + Âm lượng + + Âm lượng - + Tắt tiếng + Bật tiếng + Thích bài hát + Bỏ thích bài hát + Thoát + + + Thu nhỏ + Phóng to + Khôi phục + Đóng + + + Phát nhạc + Điều khiển phát nội dung + + + Duyệt + Đang phát + Nổi bật + Bài hát đã lưu + Bài hát hàng đầu + diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml new file mode 100644 index 00000000..0bbee39f --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -0,0 +1,920 @@ + + + + 待播清單 + 清除待播清單 + 篩選待播清單... + 待播清單中沒有項目 + + 搜尋歌曲、藝人、專輯... + 清除所有紀錄 + 搜尋... + + + 你的音樂庫 + 音樂庫 + 播放清單 + 專輯 + 藝人 + 本機曲目 + 下載 + 搜尋已儲存的播放清單… + 搜尋已儲存的專輯… + 搜尋已儲存的藝人… + 搜尋資料夾或曲目… + 篩選音樂庫 + 關閉篩選 + 建立播放清單 + 重新整理本機曲目 + 切換為格狀檢視 + 切換為清單檢視 + 沒有已儲存的播放清單 + 沒有符合「%1$s」的播放清單 + 沒有已儲存的專輯 + 沒有符合「%1$s」的專輯 + 沒有已儲存的藝人 + 沒有符合「%1$s」的藝人 + + + 資料夾 + 資料夾 + %1$s • %2$s + + %1$d 首曲目 + + + %1$d 個資料夾 + + 返回資料夾 + 播放資料夾 + 需要音訊存取權限 + 允許存取音訊,即可瀏覽裝置上的音樂。 + 授予權限 + 找不到本機資料夾 + 此資料夾中沒有曲目 + 沒有符合的資料夾或曲目 + 無法重新整理本機媒體 + + + %1$d 個進行中 • %2$d 個已完成 + 清除已完成項目 + 尚無下載項目 + 下載中 %1$d% + 等待中 + 已完成 + 已取消 + 失敗 + 失敗:%1$s + 取消下載 + 重試下載 + 移除下載項目 + + + 藝人 + 已封鎖 + 喜歡的曲目 + 你已儲存的歌曲 + 未知藝人 + 曲目名稱 + 藝人名稱 + + 已選取 + + 設定 + + 外掛程式 + 語言與地區 + 外觀 + 播放 + 下載 + 快取 + 桌面版 + 更新 + 支持 + 關於 + + 關閉 + 儲存 + 取消 + + 語言 + 應用程式介面的語言。目前:%1$s + 跟隨系統 + 國家/地區 + 用於內容與可用性的國家/地區。目前:%1$s + %1$s(%2$s) + + 主題 + 選擇 Spotube 如何配合系統外觀。目前:%1$s + 淺色 + 深色 + 跟隨系統 + + 表面材質 + 應用程式的背景材質。目前:%1$s + 水波 + 冰晶 + 金屬鉻 + 壓克力 + 玻璃 + + 強調色 + 目前色盤:%1$s + 強調色 + 選擇強調色盤,並在淺色與深色主題中預覽效果。 + 淺色 + 深色 + 預覽 + 按鈕 + 妖精綠 + 電光紫 + 海洋青 + 夕陽橘 + 玫瑰園 + 午夜藍 + 金屬石板灰 + + 外觀與風格 + 表面材質與強調色。目前:%1$s · %2$s + 外觀與風格 + 先選擇表面材質,再挑選與之搭配的強調色。每個選項都是精心調配的組合。 + 表面材質 + 強調色 + 次要 + + 色盤 + Millennium 強調色盤。目前:%1$s + 色盤 + 選擇 Millennium 強調色盤,並在淺色與深色模式中預覽。 + 色盤 + Millennium 色盤與強調色。目前:%1$s + 藍色 + 橘色 + 萊姆綠 + + 自訂下載資料夾 + 目前資料夾:%1$s + 留空即使用預設音樂資料夾。 + C:\Music\Spotube + 本機媒體資料夾 + 桌面版要掃描的自訂資料夾。已新增:%1$d + 尚未新增自訂資料夾 + 本機音樂庫的自訂資料夾。 + 管理 + 新增資料夾 + 移除資料夾 + 尚未新增自訂資料夾。 + 從掃描清單中移除 %1$s + 下載資料夾與音樂快取資料夾會自動納入。 + 你的下載資料夾會自動納入。 + 音樂快取資料夾會自動納入。 + 儲存中… + 無法儲存資料夾。你的變更仍保留在此,請再試一次。 + + %1$d 個資料夾 + + 下載音樂格式 + 下載時偏好的編碼與封裝格式。目前:%1$s + 下載音樂品質 + + 串流音樂格式 + 串流音樂時偏好的編碼與封裝格式。目前:%1$s + 串流音樂品質 + 目前:%1$s + 啟用音樂快取 + 將串流音訊儲存在本機,加快重複播放的速度。 + 快取資料夾 + 目前:%1$s + 目前:預設應用程式快取 + 快取大小上限 + 無上限 + 目前上限:%1$d MB + 設定快取大小上限(MB)。輸入 0 或留空表示無上限。 + 500 + 大小必須為 0 或正數。 + 啟用無限播放 + 待播清單播完後,以推薦曲目繼續播放。 + 播放代理伺服器連接埠 + 允許遠端控制 + 讓同一網路中的其他裝置在連線後控制播放。 + 遠端控制裝置名稱 + 其他裝置搜尋到此裝置時所顯示的名稱。目前:%1$s + 選擇一個好記的名稱,讓網路上的其他裝置找到這個 Spotube 時顯示。 + 例如:客廳的 Spotube + 裝置主機名稱 + 播放代理伺服器使用的連接埠。目前:%1$d + 請選擇 1 到 65535 之間的連接埠。 + 14769 + 請輸入有效的整數。 + 連接埠必須介於 1 到 65535 之間。 + + 封鎖清單 + 管理已封鎖的曲目與藝人 + + 最小化至系統匣 + 視窗最小化時,讓 Spotube 在系統匣中繼續執行。 + Discord 動態狀態 + 在桌面版的 Discord 中顯示目前播放狀態。 + + 自動檢查更新 + 在背景檢查 Spotube 的新版本。 + + 管理外掛程式 + 管理你的外掛程式 + 預設%1$s外掛程式 + 中繼資料 + 音訊 + 歌詞 + 收聽紀錄 + 未選取外掛程式 + 變更 + 選取 + 已選取 + 清除選取 + 沒有可用的外掛程式 + %1$s 外掛程式 + 清除 + + 設定 + 外掛程式管理員 + 請輸入 URL + URL 必須以 http:// 或 https:// 開頭 + 無法下載外掛程式 + 尚未安裝任何外掛程式 + 請貼上下載 URL,或從上方選擇檔案。 + + 已安裝 %1$d 個外掛程式 + + 安裝外掛程式 + https://example.com/plugin.smplug + 下載 + 從檔案安裝(.smplug) + 使用中 + v%1$s + 內建 + 移除外掛程式 + 登入 + 登出 + 從 URL 下載 + 從檔案安裝 + 安裝 + + • %1$s + 要求的權限 + 此外掛程式不需要任何特殊權限。 + 已安裝與提供版本比較 + 已安裝 + 提供的版本 + API %1$s → %2$s + 永久儲存空間 + 讀取及寫入磁碟資料 + 網路要求 + 透過網際網路傳送及接收資料 + WebView + 在應用程式內顯示網頁內容 + 群組 Jam + 測試連線 + 測試中… + 已連線,耗時 %1$d 毫秒 + 失敗:%1$s + 連線 + 診斷 + + + 群組 Jam + 建立 + 加入 + 建立房間,然後將代碼分享給朋友。 + 建立房間 + 加入房間 + 連線中… + 房間代碼 + ABC123 + 已連線 + 重新連線中… + 複製代碼 + 已複製! + 分享 + 參與者(%1$d) + 主持人 + 聽眾 + 正在等待聽眾加入… + 移除 + 封鎖 + 管理 + 離開房間 + 無法連線 + 關閉 + 無法建立 Jam + 無法加入 Jam + 請輸入由英文字母和數字組成的六位房間代碼。 + 用代碼 %1$s 加入我的 Spotube Jam + 房間伺服器 + 伺服器網址 + 請輸入以 https:// 開頭的網址 + 建立房間前先檢查房間伺服器。 + Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 github.com/team-spotube/jam-server 中的伺服器並輸入其網址。 + 你 + 伺服器:%1$s + 請在設定中指定房間伺服器,以建立或加入房間。 + 伺服器設定 + %1$s(你) + 你的名稱 + Jam 裡的其他人會看到你是 %1$s + Jam 裡的每個人都看得到。留空即使用 %1$s。新名稱會在你下次建立或加入房間時生效。 + 最多 %1$d 個字元。 + 尚未設定 Jam 伺服器 + 無法連線到 Jam 伺服器。請檢查網路,或 設定 → 群組 Jam 中的伺服器網址。 + Jam 伺服器的版本不相容。 + 沒有使用此代碼的房間。請檢查後再試一次。 + 此房間已滿。 + 主持人已將你從此房間封鎖。 + 主持人已將你移出房間。 + 此房間已在這部裝置上再次開啟,因此這個連線已關閉。 + + 前往專輯 + 睡眠定時器 + 曲目詳細資料 + 播放 + 暫停 + 目前沒有播放內容 + 加入待播清單 + + 略過 + 更新 + Spotube %1$s 已推出 + 前往 Spotube 下載頁面查看此版本的新功能。 + %1$s %2$s 已推出 + 目前安裝版本:%1$s + 前往外掛程式頁面查看此更新的新功能。 + + 略過 + 下一步 + 上一步 + 開始使用 + Spotube + 音樂,無拘無束。 + 一款開放原始碼的播放器,帶你走進音樂的世界。\n任你探索,由你打造。 + 讓這個專案延續下去。 + Spotube 是由一群人打造的,而不是靠訂閱賺錢的企業。如果它已成為你日常生活的一部分,一點點支持就能幫助我們給予它應有的照顧。\n\n感謝你為獨立軟體留出一席之地。 + 先從目錄外掛程式開始,用來尋找歌曲與播放清單。接著選擇音訊外掛程式來播放。歌詞與收聽紀錄為選用項目。在下方探索外掛程式,檢視其權限並安裝,然後在這裡選取。 + 探索外掛程式 + 你的來源 + 再試一次 + 更多外掛程式 + 找不到新的外掛程式。你也可以從上方透過連結或 .smplug 檔案安裝。 + 目前無法探索外掛程式。請再試一次,或透過連結或檔案安裝。 + 歡迎 + 支持 + 外掛程式 + 指南 + + 如何使用 Spotube + 只要四個簡單步驟,就能開始聆聽。 + 登入 + 連結你的帳戶,或選擇你自己的目錄外掛程式。 + 搜尋並播放 + 找到任何歌曲、專輯或藝人,然後按下播放。 + 離線聆聽 + 下載曲目,沒有網路也能繼續播放。 + 跟著歌詞唱 + 播放曲目時,從播放器開啟同步歌詞。 + + + 你的音樂,你的來源,你的規則。 + 一款免費、開放原始碼的音樂播放器,由熱愛音樂的社群打造。 + 開放原始碼 + 無廣告 + 無遙測 + 外掛程式驅動 + 出發吧 + + + 支持者讓我們走下去 + 以熱情打造,因你而延續。 + Spotube 百分之百免費、自由且開放原始碼 — 沒有廣告、沒有訂閱、沒有追蹤。每一項功能的存在,都是因為像你這樣的人選擇支持程式碼持續前進。 + Patreon + 每月贊助 + 支持持續的開發與基礎設施 + Buy Me a Coffee + 請喝一杯咖啡 + 小小打賞,為深夜寫程式加點油 + Open Collective + 社群基金 + 透明、由社群治理的資金支持 + 衷心感謝你相信開放的音樂。 + 支持完全是自願的 — 無論如何,Spotube 都會保持免費。 + 贊助 + 開啟 %1$s + + + 支持 Spotube + 支持 Spotube + Spotube 免費、自由且開放原始碼 — 沒有廣告、沒有訂閱、沒有追蹤。如果它為你帶來快樂,歡迎考慮支持這個專案。 + 你可以在「設定 → 支持」中關閉這些提醒。 + 稍後再說 + 支持提醒 + 每 7 天顯示一次溫和的提醒,邀請你支持這個專案。 + + + 模組化架構 + 選擇你的音樂來源。 + Spotube 將目錄探索、音訊串流與同步歌詞分開處理。在下方設定你的引擎,或隨時切換。 + 中繼資料引擎 + 負責音樂探索、搜尋、曲目清單與播放清單。 + 音訊引擎 + 取得並解碼要播放的音訊串流。 + 歌詞引擎 + 即時串流與時間同步的卡拉 OK 歌詞。 + 收聽紀錄引擎 + 將收聽紀錄傳送至 Last.fm 或 ListenBrainz。 + 使用中 + 內建 + 就緒 + 選用 + 選擇引擎 + 已安裝的引擎 + 設為預設 + NewPipe YouTube 與 LRCLib Lyrics 皆為內建,安裝後即可直接串流。 + 隨時可在「設定 → 外掛程式」中管理或新增社群外掛程式。 + + + 復古摩登架構 + 純粹 Hi-Fi,沒有中間人 + 以下說明 Spotube 如何直接在你的裝置上協調去中心化的播放。 + 直接點對點 • 256 KBPS OPUS + 藍調時刻 + Spotube · 播放器示範 + 為片刻之間的時光,送上一點音樂。 + 1. 去中心化探索 + 查詢會直接送到你選擇的目錄外掛程式。Spotube 絕不分析你的喜好。 + 2. 直接解析串流 + 音訊直接從內容伺服器串流到你的裝置。沒有代理轉送,也沒有遠端資料蒐集。 + 3. 本機 DSP 與播放 + 在本機解碼並快取,提供等化器控制及選用的離線下載。 + 4. 同步歌詞與 Jam + 卡拉 OK 歌詞跟隨播放時間軸,並支援 LAN 遠端控制與群組 Jam。 + 沒有中間伺服器。播放直接在你的裝置上進行 — 隱私從設計開始。 + + + 選擇裝置 + %1$s:「%2$s」 + 播放 + 加入待播清單 + 下一首播放 + 這部裝置 + 在這部裝置上 + 遠端裝置 + 遠端裝置(%1$s) + 在已連線的裝置上 + + + 連線失敗 + 連線中斷 + 傳送失敗 + 搜尋失敗:%1$s + 無法連線:%1$s + + + 裝置 + 重新整理 + 中斷連線 + 連線中... + 正在搜尋網路上的裝置... + 找不到裝置 + 請確認另一部裝置已在設定中啟用「允許遠端控制」。 + 未知裝置 + 解析中... + + + 遠端控制 + 已中斷連線 + 連線錯誤:%1$s + 未知曲目 + 未知藝人 + 音量 + 從待播清單移除 + 更多選項 + + + 要在哪裡播放? + 你有一個進行中的 Jam。請選擇要在哪裡播放這個合輯。 + 在這裡播放 + 推薦到 Jam + + + 連線失敗 + 連線中斷 + + + + 已加入 Jam 待播清單 + + 已將 %1$d 首加入 Jam 待播清單 + + + + 遠端控制要求 + 「%1$s」想要控制這部裝置上的播放。 + 拒絕 + 允許 + 一律允許 + + + 搜尋結果 + 全部 + 曲目 + 使用者 + 沒有可用的搜尋類型 + 輸入文字開始搜尋 + 輸入文字開始搜尋曲目 + 找不到結果 + 查看全部 + 搜尋中 + 正在載入曲目... + 正在載入專輯... + 正在載入藝人... + 正在載入播放清單... + 正在載入使用者... + 找不到專輯 + 找不到藝人 + 找不到播放清單 + 找不到使用者 + 正在翻找唱片箱... + 正在搜尋宇宙... + 正在暖機喇叭... + 正在擦拭黑膠唱片... + 正在請教音樂之神... + 正在調整天線... + 正在翻閱唱片... + 無法載入搜尋功能 + 搜尋失敗 + 無法搜尋曲目 + 無法搜尋專輯 + 無法搜尋藝人 + 無法搜尋播放清單 + 無法搜尋使用者 + + + 瀏覽 + + + 同步 + 純文字 + 沒有可用的同步歌詞 + 沒有可用的純文字歌詞 + + + %1$s(%2$s) + %1$d kbps + %1$s kHz • %2$d 聲道 + 未選取中繼資料外掛程式 + + + 返回 + 重試 + 關閉對話框 + 載入中... + 未知 + 未知錯誤 + + + 使用者 + 播放清單 + + + 我們深感抱歉 + 目前有些功能無法正常運作。你的使用體驗對我們很重要,我們正盡全力修正。請再試一次,或查看下方的錯誤詳細資料。 + 錯誤詳細資料 + 複製到剪貼簿 + 檢視完整記錄 + 完整錯誤記錄 + + + 隨機播放 + 隨機播放 + 追蹤 + 取消追蹤 + 編輯播放清單 + 由 %1$s 建立 + + + 曲目選項 + 加入 Jam + 開始播放電台 + 下一首播放 + 移至下一首 + 從待播清單移除 + 從最愛中移除 + 加入最愛 + 加入播放清單 + 下載 + 分享 + + + 原始順序 + 標題 + 藝人 + 專輯 + 長度 + 排序 + 找不到曲目 + 篩選 + 批次操作 + 全部下載 + 下載 %1$d 首 + 全部加入待播清單 + 將 %1$d 首加入待播清單 + 全部設為下一首播放 + 將 %1$d 首設為下一首播放 + 全部加入播放清單 + 將 %1$d 首加入播放清單 + 全部加入 Jam + 將 %1$d 首加入 Jam + +%1$d 位 + + + 沒有可載入的 URL + 返回上一頁 + 前往下一頁 + + + 篩選封鎖清單... + 沒有已封鎖的項目 + 沒有符合「%1$s」的已封鎖項目 + 曲目 + 曲目 + 加入封鎖清單 + 從封鎖清單移除 + + + 沒有曲目中繼資料 + 無法解析串流 URL + 無法解析串流 URL。請先嘗試播放此曲目。 + + + 已儲存的曲目 + 你 + + + 追蹤中 + 熱門曲目 + 播放熱門曲目 + 將熱門曲目加入待播清單 + 相關藝人 + 精選播放清單 + 無法載入藝人總覽 + 無法取得追蹤者人數 + %1$sK 位追蹤者 + %1$sM 位追蹤者 + %1$sB 位追蹤者 + + %1$d 位追蹤者 + + + + 喜歡 + 取消喜歡 + 無曲目標題 + 無藝人 + 未知專輯 + 播放進度 + 音量 + 開啟隨機播放 + 關閉隨機播放 + 上一首 + 下一首 + 播放或暫停 + 重複播放:關閉 + 重複播放:單曲 + 重複播放:全部 + 靜音 + 取消靜音 + 更多選項 + 播放器選項 + 收合播放器 + 展開歌詞 + 現正播放 + 歌詞 + 替代來源 + 替代來源 + 下載 + 已下載 + 下載中... + 已經下載過了 + 正在重試下載... + 正在下載 %1$s + 播放將在 %1$s 後暫停。 + 在所選的分鐘數後暫停播放。 + 分鐘 + 請選擇 1 到 600 分鐘 + 取消定時器 + 開始 + 播放 URL 是以不同的串流設定解析的。醒目標示的串流符合你目前的偏好;使用中的 URL 會在下次要求串流時更新。 + 配對的音訊來源 + 正在載入配對的來源… + 播放本機快取檔案時,不需要配對的遠端來源。 + 沒有可用的配對來源。 + 此曲目沒有已快取的配對音訊來源。 + 串流詳細資料尚未快取。請先播放此曲目以載入資料。 + 無法載入曲目詳細資料。 + 可用的串流 + 此曲目正從本機快取播放,本次播放不會使用串流變體。 + 正在載入串流… + 此來源沒有可用的串流。 + 目前選取的串流 + 符合你的串流格式與品質偏好 + 使用中的播放 URL + 從本機快取播放 + 中繼資料曲目 + 標題 + ID + 專輯 + 信賴度 + 外部 URI + 專輯 ID + 專輯類型 + 專輯發行日期 + 專輯曲目數 + 專輯類型風格 + 長度 + 曲目編號 + 光碟編號 + 兒少不宜 + 熱門度 + ISRC + 縮圖 URL + + + 切換側邊欄 + + + 替代來源 + 目前沒有播放中的曲目 + 正在搜尋替代來源... + 找不到替代來源 + 信賴度:%1$d% + 使用中的來源 + + + 踢出 + 將對方移出這個 Jam + 踢出並禁止對方再次加入 + 移除推薦曲目 + 移除對方加入待播清單的所有曲目 + 重新排序 + 從待播清單移除 + %1$s • 由 %2$s 加入 + + + 未知 + 正在載入播放清單... + 新增曲目 + 將曲目加入播放清單 + 搜尋曲目... + 搜尋 + 找不到曲目 + 搜尋要加入的曲目 + 完成 + 新增 + 已新增 + + + 正在載入專輯... + + + 加入你的播放清單 + 搜尋 + 搜尋你的播放清單... + 正在載入你的播放清單... + 你還沒有自己的播放清單。\n建立一個來加入曲目吧。 + 建立播放清單 + 編輯播放清單 + 建立 + 名稱 + 播放清單名稱 + 說明 + 說明(選填) + 公開 + 所有人都能看到這個播放清單 + 協作 + 其他人可以將曲目加入這個播放清單 + 封面圖片 + 播放清單封面 + 沒有圖片 + 更換圖片 + 選擇圖片 + + + 安裝 + 取代 + 在 zip 檔案中找不到 plugin.json + 在 zip 檔案中找不到 manifest.zipline.json + plugin.json 格式無效:%1$s + 無法讀取外掛程式:%1$s + 無法安裝外掛程式:%1$s + %1$s 有較新的版本。安裝前請先確認更新。 + %1$s 已經安裝。安裝前請先確認取代。 + 內建外掛程式已包含在內,無法再次新增。 + 無法移除內建外掛程式。 + %1$s 宣告的 apiVersion 無效(%2$s)。應為與 %3$s 相容的語意化版本。 + Spotube 外掛程式 API 版本 %1$s 無效。 + %1$s 的目標為外掛程式 API %2$s,但 Spotube 支援的是不相容變更後的外掛程式 API %3$s。請安裝針對相同主要 API 版本建置的外掛程式。 + + + 外掛程式 API 不相容 + 要安裝外掛程式嗎? + %1$s 將加入你已安裝的外掛程式。 + 要更新外掛程式嗎? + 已安裝舊版(%1$s)。要更新至 %2$s 嗎? + 要取代已安裝的外掛程式嗎? + %1$s 使用了無效的語意化版本(%2$s),無法作為更新進行比較。仍要取代已安裝的外掛程式嗎? + 已安裝的版本(%1$s)無法以語意化版本進行比較。要以 %2$s 取代嗎? + 版本 %1$s 已經安裝。要以提供的版本取代現有的外掛程式嗎? + 已安裝的版本 %1$s 比提供的版本 %2$s 新。仍要取代嗎? + 要以提供的版本取代已安裝的外掛程式嗎? + + + %1$s: + 版本 + API 版本 + 作者 + 授權條款 + 權限 + 功能 + 儲存庫 + 聯絡方式 + 回報錯誤 + 支持 %1$s + + + 官方 + 已驗證 + GitHub 星星數 + GitHub 儲存庫 URL + + + 找不到發行版本 + 選擇發行版本 + 選擇一個發行版本 + 預先發行版 + 版本資訊 + + + 首頁 + 搜尋 + 本機 + 連線 + 裝置 + + + 隱藏視窗 + 顯示視窗 + 下一首 + 上一首 + 隨機播放 + 重複播放 + 重複播放:關閉 + 重複播放:單曲 + 重複播放:全部 + 音量:%1$d% + 音量 + + 音量 - + 靜音 + 取消靜音 + 喜歡此曲目 + 取消喜歡此曲目 + 結束 + + + 最小化 + 最大化 + 還原 + 關閉 + + + 播放 + 媒體播放控制項 + + + 瀏覽 + 現正播放 + 精選 + 已儲存的曲目 + 熱門曲目 + diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml new file mode 100644 index 00000000..c357b046 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -0,0 +1,920 @@ + + + + 播放队列 + 清空队列 + 筛选队列... + 队列为空 + + 搜索歌曲、艺人、专辑... + 清除全部历史记录 + 搜索... + + + 我的音乐库 + 音乐库 + 歌单 + 专辑 + 艺人 + 本地歌曲 + 下载 + 搜索已收藏的歌单… + 搜索已收藏的专辑… + 搜索已关注的艺人… + 搜索文件夹或歌曲… + 筛选音乐库 + 关闭筛选 + 创建歌单 + 刷新本地歌曲 + 切换到网格视图 + 切换到列表视图 + 暂无收藏的歌单 + 没有与“%1$s”匹配的歌单 + 暂无收藏的专辑 + 没有与“%1$s”匹配的专辑 + 暂无关注的艺人 + 没有与“%1$s”匹配的艺人 + + + 文件夹 + 文件夹 + %1$s • %2$s + + %1$d 首歌曲 + + + %1$d 个文件夹 + + 返回文件夹 + 播放文件夹 + 需要音频访问权限 + 允许访问音频,以浏览设备上的音乐。 + 授予权限 + 未找到本地文件夹 + 此文件夹中没有歌曲 + 没有匹配的文件夹或歌曲 + 刷新本地媒体失败 + + + %1$d 个进行中 • %2$d 个已完成 + 清除已完成 + 暂无下载 + 正在下载 %1$d% + 排队中 + 已完成 + 已取消 + 失败 + 失败:%1$s + 取消下载 + 重试下载 + 移除下载 + + + 艺人 + 已拉黑 + 喜欢的歌曲 + 你收藏的歌曲 + 未知艺人 + 歌曲标题 + 艺人名称 + + 已选择 + + 设置 + + 插件 + 语言和地区 + 外观 + 播放 + 下载 + 缓存 + 桌面 + 更新 + 支持 + 关于 + + 关闭 + 保存 + 取消 + + 语言 + 应用界面语言。当前:%1$s + 跟随系统 + 国家/地区 + 用于内容和可用性的国家/地区。当前:%1$s + %1$s (%2$s) + + 主题 + 选择 Spotube 如何匹配系统外观。当前:%1$s + 浅色 + 深色 + 跟随系统 + + 材质 + 应用的背景材质。当前:%1$s + 水润 + 冰晶 + 铬金属 + 亚克力 + 玻璃 + + 强调色 + 当前配色:%1$s + 强调色 + 选择一种强调配色,并在浅色和深色主题下预览。 + 浅色 + 深色 + 预览 + 按钮 + 妖精绿 + 电光紫 + 海洋青 + 日落橙 + 玫瑰园 + 午夜蓝 + 金属灰 + + 外观与风格 + 材质和强调色。当前:%1$s · %2$s + 外观与风格 + 先选择一种材质,再选择与之搭配的强调色。每个选项都是精心挑选的组合。 + 材质 + 强调色 + 次要 + + 配色 + Millennium 强调配色。当前:%1$s + 配色 + 选择一种 Millennium 强调配色,并在浅色和深色下预览。 + 配色 + Millennium 配色和强调色。当前:%1$s + 蓝色 + 橙色 + 青柠 + + 自定义下载文件夹 + 当前文件夹:%1$s + 留空则使用默认音乐文件夹。 + C:\Music\Spotube + 本地媒体文件夹 + 桌面端要扫描的自定义文件夹。已添加:%1$d + 尚未添加自定义文件夹 + 用于本地音乐库的自定义文件夹。 + 管理 + 添加文件夹 + 移除文件夹 + 未添加自定义文件夹。 + 从扫描列表中移除 %1$s + 下载文件夹和音乐缓存文件夹会自动包含在内。 + 你的下载文件夹会自动包含在内。 + 音乐缓存文件夹会自动包含在内。 + 正在保存… + 无法保存文件夹。你的更改仍然保留,请重试。 + + %1$d 个文件夹 + + 下载音乐格式 + 下载时首选的编解码器和封装格式。当前:%1$s + 下载音质 + + 流媒体音乐格式 + 在线播放时首选的编解码器和封装格式。当前:%1$s + 在线播放音质 + 当前:%1$s + 启用音乐缓存 + 将在线播放的音频保存到本地,加快重复播放速度。 + 缓存文件夹 + 当前:%1$s + 当前:默认应用缓存 + 缓存大小上限 + 无限制 + 当前上限:%1$d MB + 设置缓存的最大大小(MB)。输入 0 或留空表示无限制。 + 500 + 大小必须为 0 或正数。 + 启用无尽播放 + 队列播放完毕后,继续播放推荐的歌曲。 + 播放代理服务器端口 + 允许远程控制 + 允许同一网络中的其他设备在连接后控制播放。 + 远程控制设备名称 + 其他设备发现本设备时看到的名称。当前:%1$s + 设置一个易于识别的名称,网络中的其他设备发现此 Spotube 实例时会显示该名称。 + 例如:客厅的 Spotube + 设备主机名 + 播放代理服务器使用的端口。当前:%1$d + 请选择 1 到 65535 之间的端口。 + 14769 + 请输入有效的整数。 + 端口必须在 1 到 65535 之间。 + + 黑名单 + 管理已拉黑的歌曲和艺人 + + 最小化到托盘 + 窗口最小化时,让 Spotube 在系统托盘中继续运行。 + Discord 动态状态 + 在桌面端的 Discord 中显示当前播放状态。 + + 自动检查更新 + 在后台检查 Spotube 新版本。 + + 管理插件 + 管理你的插件 + 默认%1$s插件 + 元数据 + 音频 + 歌词 + 听歌记录 + 未选择插件 + 更改 + 选择 + 已选择 + 清除选择 + 没有可用的插件 + %1$s 插件 + 清除 + + 配置 + 插件管理器 + 请输入 URL + URL 必须以 http:// 或 https:// 开头 + 插件下载失败 + 尚未安装任何插件 + 在上方粘贴下载 URL 或选择文件。 + + 已安装 %1$d 个插件 + + 安装插件 + https://example.com/plugin.smplug + 下载 + 从文件安装 (.smplug) + 已启用 + v%1$s + 内置 + 移除插件 + 登录 + 退出登录 + 从 URL 下载 + 从文件安装 + 安装 + + • %1$s + 请求的权限 + 此插件不需要任何特殊权限。 + 已安装与待安装对比 + 已安装 + 待安装 + API %1$s → %2$s + 持久存储 + 在磁盘上读写数据 + 网络请求 + 通过互联网收发数据 + WebView + 在应用内显示网页内容 + 一起听 + 测试连接 + 正在测试… + 已连接,耗时 %1$d 毫秒 + 失败:%1$s + 连接 + 诊断 + + + 一起听 + 创建 + 加入 + 创建一个房间,然后把房间码分享给朋友。 + 创建房间 + 加入房间 + 正在连接… + 房间码 + ABC123 + 已连接 + 正在重新连接… + 复制房间码 + 已复制! + 分享 + 参与者(%1$d) + 房主 + 听众 + 正在等待听众加入… + 移除 + 封禁 + 管理 + 离开房间 + 无法连接 + 关闭 + 创建一起听会话失败 + 加入一起听会话失败 + 请输入由字母和数字组成的六位房间码。 + 用房间码加入我的 Spotube 一起听:%1$s + 房间服务器 + 服务器网址 + 请输入以 https:// 开头的网址 + 创建房间前先检查房间服务器。 + 一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 github.com/team-spotube/jam-server 中的服务器并填写其网址。 + 你 + 服务器:%1$s + 请在设置中指定房间服务器,以创建或加入房间。 + 服务器设置 + %1$s(你) + 你的名字 + 房间里的其他人看到你的名字是 %1$s + 房间里的所有人都能看到。留空则使用 %1$s。新名字将在你下次创建或加入房间时生效。 + 最多 %1$d 个字符。 + 未配置一起听服务器 + 无法连接到一起听服务器。请检查网络,或在 设置 → 一起听 中检查服务器网址。 + 一起听服务器的版本不兼容。 + 没有使用该代码的房间。请检查后重试。 + 该房间已满。 + 房主已将你从此房间封禁。 + 房主已将你移出房间。 + 此房间已在本设备上再次打开,因此此连接已关闭。 + + 转到专辑 + 睡眠定时器 + 歌曲详情 + 播放 + 暂停 + 当前没有播放内容 + 添加到队列 + + 忽略 + 更新 + Spotube %1$s 现已推出 + 前往 Spotube 下载页面查看此版本的新功能。 + %1$s %2$s 现已推出 + 当前已安装:%1$s + 前往插件页面查看此更新的新功能。 + + 跳过 + 下一步 + 返回 + 开始使用 + Spotube + 音乐,无拘无束。 + 一款开源播放器,带你探索音乐世界。\n由你探索,由你定义。 + 让这个项目延续下去。 + Spotube 由一群热爱它的人打造,而不是一门订阅生意。如果它已成为你日常生活的一部分,一点点支持就能帮助我们给予它应有的用心呵护。\n\n感谢你为独立软件留出一席之地。 + 先安装一个曲库插件来查找歌曲和歌单,再选择一个音频插件来播放它们。歌词和听歌记录是可选的。在下方发现插件,查看其权限并安装,然后在此处选择它。 + 发现插件 + 你的音源 + 重试 + 更多插件 + 未发现新插件。你也可以通过上方的链接或 .smplug 文件安装插件。 + 插件发现功能暂不可用。请重试,或通过链接或文件安装。 + 欢迎 + 支持 + 插件 + 指南 + + 如何使用 Spotube + 只需四个简单步骤,即可开始聆听。 + 登录 + 连接你的帐号,或选择你自己的曲库插件。 + 搜索并播放 + 查找任意歌曲、专辑或艺人,然后点击播放。 + 离线收听 + 下载歌曲,无网络时也能继续播放。 + 跟随歌词 + 播放歌曲时,从播放器中打开同步歌词。 + + + 你的音乐。你的音源。你做主。 + 一款免费、开源的音乐播放器,由一群热爱音乐的社区成员打造。 + 开源 + 无广告 + 无遥测 + 插件驱动 + 出发吧 + + + 支持者让我们走得更远 + 用热情打造,因你而延续。 + Spotube 100% 免费、自由且开源 — 无广告、无订阅、无追踪。每一项功能的存在,都是因为像你这样的人选择帮助代码持续前行。 + Patreon + 每月赞助 + 支持持续的开发和基础设施 + Buy Me a Coffee + 请杯咖啡 + 用一笔小额打赏,为深夜编码加油 + Open Collective + 社区基金 + 透明、由社区管理的资金支持 + 衷心感谢你对开放音乐的信任。 + 支持完全自愿 — 无论如何 Spotube 都将保持免费。 + 赞助 + 打开 %1$s + + + 支持 Spotube + 支持 Spotube + Spotube 免费、自由且开源 — 无广告、无订阅、无追踪。如果它为你带来了快乐,请考虑支持这个项目。 + 你可以在“设置 → 支持”中关闭这些提醒。 + 以后再说 + 支持提醒 + 每 7 天温馨提醒一次支持本项目。 + + + 模块化架构 + 选择你的音乐来源。 + Spotube 将曲库发现、音频流和同步歌词分开处理。在下方配置各个引擎,或随时切换。 + 元数据引擎 + 为音乐发现、搜索、歌曲列表和歌单提供支持。 + 音频引擎 + 获取并解码要播放的音频流。 + 歌词引擎 + 实时传输逐句同步的卡拉 OK 歌词。 + 听歌记录引擎 + 将听歌记录同步到 Last.fm 或 ListenBrainz。 + 已启用 + 内置 + 就绪 + 可选 + 选择一个引擎 + 已安装的引擎 + 设为默认 + NewPipe YouTube 和 LRCLib 歌词已内置,开箱即可使用。 + 随时可以在“设置 → 插件”中管理或添加新的社区插件。 + + + 复古与现代融合的架构 + 纯粹高保真,零中间商 + 看看 Spotube 如何直接在你的设备上协调去中心化播放。 + 直接点对点 • 256 KBPS OPUS + 蓝调时刻 + Spotube · 播放器演示 + 一点音乐,献给那些间隙时光。 + 1. 去中心化发现 + 查询直接发送到你选择的曲库插件。Spotube 绝不分析你的喜好。 + 2. 直接解析音频流 + 音频直接从内容服务器传输到你的设备。没有代理中转,也没有远程数据收集。 + 3. 本地 DSP 与播放 + 在本地解码和缓存,提供均衡器控制和可选的离线下载。 + 4. 同步歌词与一起听 + 卡拉 OK 歌词跟随播放进度,并支持 LAN 远程控制和一起听。 + 没有中间服务器。播放直接在你的设备上进行 — 隐私从设计开始。 + + + 选择设备 + %1$s:“%2$s” + 播放 + 添加到队列 + 下一首播放 + 本设备 + 在本设备上 + 远程设备 + 远程设备(%1$s) + 在已连接的设备上 + + + 连接失败 + 连接已断开 + 发送失败 + 发现设备失败:%1$s + 连接失败:%1$s + + + 设备 + 刷新 + 断开连接 + 正在连接... + 正在搜索网络中的设备... + 未找到设备 + 请确保另一台设备已在设置中启用“允许远程控制”。 + 未知设备 + 正在解析... + + + 远程控制 + 已断开连接 + 连接错误:%1$s + 未知歌曲 + 未知艺人 + 音量 + 从队列中移除 + 更多选项 + + + 在哪里播放? + 你正在进行一起听会话。请选择在哪里播放此合集。 + 在此播放 + 推荐到一起听 + + + 连接失败 + 连接已断开 + + + + 已添加到一起听队列 + + 已将 %1$d 首歌曲添加到一起听队列 + + + + 远程控制请求 + “%1$s”请求控制此设备上的播放。 + 拒绝 + 允许 + 始终允许 + + + 搜索结果 + 全部 + 歌曲 + 用户 + 没有可用的搜索类型 + 输入内容开始搜索 + 输入内容开始搜索歌曲 + 未找到结果 + 查看全部 + 正在搜索 + 正在加载歌曲... + 正在加载专辑... + 正在加载艺人... + 正在加载歌单... + 正在加载用户... + 未找到专辑 + 未找到艺人 + 未找到歌单 + 未找到用户 + 正在翻箱倒柜找唱片... + 正在探索音乐宇宙... + 正在预热音箱... + 正在擦拭黑胶唱片... + 正在请教音乐之神... + 正在调试天线... + 正在翻阅唱片... + 加载搜索功能失败 + 搜索失败 + 搜索歌曲失败 + 搜索专辑失败 + 搜索艺人失败 + 搜索歌单失败 + 搜索用户失败 + + + 浏览 + + + 同步 + 纯文本 + 暂无同步歌词 + 暂无纯文本歌词 + + + %1$s(%2$s 封装) + %1$d kbps + %1$s kHz • %2$d 声道 + 未选择元数据插件 + + + 返回 + 重试 + 关闭对话框 + 正在加载... + 未知 + 未知错误 + + + 用户 + 歌单 + + + 非常抱歉 + 出了点问题。你的体验对我们很重要,我们正在全力修复。请重试,或查看下方的错误详情。 + 错误详情 + 复制到剪贴板 + 查看完整日志 + 完整错误日志 + + + 随机播放 + 随机播放 + 关注 + 取消关注 + 编辑歌单 + 创建者:%1$s + + + 歌曲选项 + 添加到一起听 + 开启电台 + 下一首播放 + 移到下一首 + 从队列中移除 + 从收藏中移除 + 收藏 + 添加到歌单 + 下载 + 分享 + + + 默认 + 标题 + 艺人 + 专辑 + 时长 + 排序 + 未找到歌曲 + 筛选 + 批量操作 + 全部下载 + 下载 %1$d 首 + 全部添加到队列 + 将 %1$d 首添加到队列 + 全部下一首播放 + 下一首播放 %1$d 首 + 全部添加到歌单 + 将 %1$d 首添加到歌单 + 全部添加到一起听 + 将 %1$d 首添加到一起听 + 另外 %1$d 位 + + + 没有要加载的 URL + 在浏览历史中后退 + 在浏览历史中前进 + + + 筛选黑名单... + 黑名单为空 + 没有与“%1$s”匹配的黑名单项目 + 歌曲 + 歌曲 + 加入黑名单 + 从黑名单中移除 + + + 没有歌曲元数据 + 无法解析音频流 URL + 无法解析音频流 URL。请先尝试播放这首歌曲。 + + + 收藏的歌曲 + 你 + + + 已关注 + 热门歌曲 + 播放热门歌曲 + 将热门歌曲添加到队列 + 相关艺人 + 精选歌单 + 加载艺人概览失败 + 粉丝数不可用 + %1$sK 位粉丝 + %1$sM 位粉丝 + %1$sB 位粉丝 + + %1$d 位粉丝 + + + + 喜欢 + 取消喜欢 + 无歌曲标题 + 无艺人 + 未知专辑 + 进度 + 音量 + 开启随机播放 + 关闭随机播放 + 上一首 + 下一首 + 播放或暂停 + 循环模式:关闭 + 循环模式:单曲循环 + 循环模式:全部循环 + 静音 + 取消静音 + 更多选项 + 播放器选项 + 收起播放器 + 展开歌词 + 正在播放 + 歌词 + 备用音源 + 备用音源 + 下载 + 已下载 + 正在下载... + 已下载过 + 正在重试下载... + 正在下载 %1$s + 将在 %1$s后暂停播放。 + 在选定的分钟数后暂停播放。 + 分钟 + 请选择 1 到 600 分钟 + 取消定时 + 开始 + 播放 URL 是使用不同的流媒体设置解析的。高亮显示的音频流符合你当前的偏好;当前使用的 URL 将在下次请求音频流时刷新。 + 匹配的音源 + 正在加载匹配的音源… + 播放本地缓存文件时,无需匹配远程音源。 + 没有可用的匹配音源。 + 此歌曲没有缓存的匹配音源。 + 音频流详情尚未缓存。请先播放这首歌曲以加载详情。 + 加载歌曲详情失败。 + 可用的音频流 + 此歌曲正从本地缓存播放,本次播放不使用音频流变体。 + 正在加载音频流… + 此音源没有可用的音频流。 + 当前选择的音频流 + 符合你的流媒体格式和音质偏好 + 当前播放 URL + 从本地缓存播放 + 元数据歌曲 + 标题 + ID + 专辑 + 匹配度 + 外部 URI + 专辑 ID + 专辑类型 + 专辑发行日期 + 专辑歌曲数 + 专辑流派 + 时长 + 曲目编号 + 碟片编号 + 含露骨内容 + 热度 + ISRC + 缩略图 URL + + + 切换侧边栏 + + + 备用音源 + 当前没有正在播放的歌曲 + 正在搜索备用音源... + 未找到备用音源 + 匹配度:%1$d% + 当前音源 + + + 踢出 + 将其移出会话 + 踢出并禁止其重新加入 + 移除推荐 + 移除其添加到队列中的所有歌曲 + 调整顺序 + 从队列中移除 + %1$s • 由 %2$s 添加 + + + 未知 + 正在加载歌单... + 添加歌曲 + 添加歌曲到歌单 + 搜索歌曲... + 搜索 + 未找到歌曲 + 搜索要添加的歌曲 + 完成 + 添加 + 已添加 + + + 正在加载专辑... + + + 添加到你的歌单 + 搜索 + 搜索你的歌单... + 正在加载你的歌单... + 你还没有创建任何歌单。\n创建一个歌单来添加歌曲吧。 + 创建歌单 + 编辑歌单 + 创建 + 名称 + 歌单名称 + 简介 + 简介(可选) + 公开 + 任何人都可以看到此歌单 + 协作 + 其他人可以向此歌单添加歌曲 + 封面图片 + 歌单封面 + 无图片 + 更换图片 + 选择图片 + + + 安装 + 替换 + 在 zip 文件中未找到 plugin.json + 在 zip 文件中未找到 manifest.zipline.json + plugin.json 格式无效:%1$s + 读取插件失败:%1$s + 安装插件失败:%1$s + %1$s 有新版本可用。请在安装前确认更新。 + %1$s 已安装。请在安装前确认替换。 + 内置插件已包含在应用中,无法再次添加。 + 内置插件无法移除。 + %1$s 声明了无效的 apiVersion(%2$s)。应为与 %3$s 兼容的语义化版本。 + Spotube 插件 API 版本 %1$s 无效。 + %1$s 面向插件 API %2$s,但 Spotube 支持的是存在破坏性变更的插件 API %3$s。请安装针对相同主版本 API 构建的插件。 + + + 插件 API 不兼容 + 安装插件? + %1$s 将被添加到你已安装的插件中。 + 更新插件? + 已安装旧版本(%1$s)。要更新到 %2$s 吗? + 替换已安装的插件? + %1$s 使用了无效的语义化版本(%2$s),无法作为更新进行比较。仍要替换已安装的插件吗? + 已安装的版本(%1$s)无法按语义化版本进行比较。要将其替换为 %2$s 吗? + 版本 %1$s 已安装。要用提供的副本替换现有插件吗? + 已安装的版本 %1$s 比提供的版本 %2$s 更新。仍要替换吗? + 要用提供的副本替换已安装的插件吗? + + + %1$s: + 版本 + API 版本 + 作者 + 许可证 + 权限 + 功能 + 代码仓库 + 联系方式 + 报告问题 + 支持 %1$s + + + 官方 + 已验证 + GitHub 星标 + GitHub 仓库 URL + + + 未找到发布版本 + 选择版本 + 选择一个发布版本 + 预发布版 + 更新说明 + + + 首页 + 搜索 + 本机 + 连接 + 设备 + + + 隐藏窗口 + 显示窗口 + 下一首 + 上一首 + 随机播放 + 循环 + 循环:关闭 + 循环:单曲 + 循环:全部 + 音量:%1$d% + 音量 + + 音量 - + 静音 + 取消静音 + 喜欢这首歌 + 取消喜欢这首歌 + 退出 + + + 最小化 + 最大化 + 还原 + 关闭 + + + 播放 + 媒体播放控制 + + + 浏览 + 正在播放 + 精选 + 收藏的歌曲 + 热门歌曲 + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 010d4259..7784db8d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -16,6 +16,90 @@ --> + Queue + Clear Queue + Filter queue... + No queue entries + + Search songs, artists, albums... + Clear all history + Search... + + + Your Library + Library + Playlists + Albums + Artists + Local Tracks + Downloads + Search saved playlists… + Search saved albums… + Search saved artists… + Search folders or tracks… + Filter library + Close filter + Create playlist + Refresh local tracks + Switch to grid view + Switch to list view + No saved playlists + No playlists match “%1$s” + No saved albums + No albums match “%1$s” + No saved artists + No artists match “%1$s” + + + Folders + Folder + %1$s • %2$s + + %1$d track + %1$d tracks + + + %1$d folder + %1$d folders + + Back to folders + Play folder + Audio access required + Allow audio access to browse music on your device. + Grant access + No local folders found + Add a folder with music to play it right here. No plugin needed. + Add folder + No tracks in this folder + No matching folders or tracks + Failed to refresh local media + + + %1$d active • %2$d completed + Saved to %1$s + Clear completed + No downloads yet + Downloading %1$d% + Queued + Completed + Cancelled + Failed + Failed: %1$s + Cancel download + Retry download + Remove download + + + Artist + Blacklisted + Liked Tracks + Your saved songs + Unknown artist + Track title + Artist name + + Selected + Settings Plugins @@ -26,13 +110,16 @@ Caching Desktop Updates + Support + About - Close - Save - Cancel + Close + Save + Cancel Language Language for the app interface. Current: %1$s + System default Country Regional country for content and availability. Current: %1$s %1$s (%2$s) @@ -43,6 +130,14 @@ Dark System + Surface + Background material of the app. Current: %1$s + Watery + Icy + Chrome + Acrylic + Glass + Accent color Current palette: %1$s Accent color @@ -59,19 +154,47 @@ Midnight Blue Metallic Slate + Look & feel + Surface material and accent. Current: %1$s · %2$s + Look & feel + Pick a surface material, then an accent that pairs with it. Every option is a curated combination. + Surface + Accent + Secondary + + Palette + Millennium accent palette. Current: %1$s + Palette + Choose a Millennium accent palette and preview it in light and dark. + Palette + Millennium palette and accent. Current: %1$s + Blue + Orange + Lime + Download folder override Current folder: %1$s - Current folder: default music folder + Current folder: %1$s (default) Leave this empty to use the default music folder. C:\Music\Spotube Local media folders Custom folders to scan on desktop. Added: %1$d No custom folder added yet - Use the picker to add folders. Download folder override is included automatically. + Custom folders for your local music library. Manage Add folder Remove folder No custom folders added. + Remove %1$s from the scan list + Download and music cache folders are included automatically. + Your download folder is included automatically. + The music cache folder is included automatically. + Saving… + Couldn’t save folders. Your changes are still here; try again. + + %1$d folder + %1$d folders + Download music format Preferred codec and container for downloads. Current: %1$s Download music quality @@ -93,9 +216,14 @@ Size must be 0 or a positive number. Enable endless playback Keep playback going with recommended tracks when your queue ends. - Enable Connect - Expose remote playback controls through the Spotube Connect feature. Playback proxy server port + Allow remote control + Let other devices on the same network control playback when they connect. + Remote control device name + The name other devices see when discovering this one. Current: %1$s + Choose a friendly name to show to other devices on your network when they discover this Spotube instance. + e.g. Living Room Spotube + device hostname Port used by the playback proxy server. Current: %1$d Choose a port between 1 and 65535. 14769 @@ -136,9 +264,24 @@ Failed to download plugin No plugins installed yet Paste a download URL or pick a file above. - %1$d %2$s installed - plugin - plugins + No metadata plugin selected + Spotube needs a metadata plugin to browse, search and show your music. Install one to get started. + You already have plugins installed, but none is set as your metadata source. Pick one to start browsing. + Discover a metadata plugin + Review its permissions and install it + Select it as your metadata source + Install a plugin + Choose a plugin + Play local music + Your local tracks and downloads work without a plugin. + + %1$d plugin installed + %1$d plugins installed + + Add a plugin from a link or a file on your device. + Pick a .smplug file from your device + or + Plugin details Install a Plugin https://example.com/plugin.smplug Download @@ -166,5 +309,804 @@ Send and receive data over the internet WebView Display web content inside the app - + Group Jam + Room server + Server URL + Enter a URL starting with https:// + Check the room server before starting a room. + Jam uses Spotube’s free room server. To self-host, deploy the server from github.com/team-spotube/jam-server and enter its URL. + Test connection + Testing… + Connected in %1$d ms + Failed: %1$s + Connection + Diagnostics + You + + Group Jam + Create + Join + Create a room, then share its code with your friends. + Create room + Join room + Connecting… + Room code + ABC123 + Server: %1$s + Set a room server in Settings to host or join a room. + Server settings + Connected + Reconnecting… + Copy code + Copied! + Share + Participants (%1$d) + Host + Listener + %1$s (you) + Waiting for listeners… + Remove + Ban + Manage + Leave room + Couldn’t connect + Dismiss + Failed to create jam session + Failed to join jam session + Enter a six-character room code using letters and digits. + Join my Spotube Jam with code: %1$s + Your name + Others in the jam see you as %1$s + Shown to everyone in the jam. Leave it empty to use %1$s. A new name applies the next time you start or join a room. + Use at most %1$d characters. + + %1$s %2$s + + + Brave + Bright + Calm + Cheerful + Chill + Clever + Cosmic + Cozy + Curious + Dapper + Dreamy + Eager + Electric + Fancy + Fearless + Fizzy + Fluffy + Funky + Gentle + Giddy + Glowing + Golden + Groovy + Happy + Humble + Jazzy + Jolly + Jumpy + Kind + Lively + Lucky + Lunar + Mellow + Merry + Mighty + Misty + Nimble + Peppy + Plucky + Polite + Quick + Quiet + Rapid + Rhythmic + Rosy + Silly + Sleepy + Smooth + Snappy + Soulful + Sparkly + Speedy + Spicy + Sunny + Swift + Tidy + Tiny + Vivid + Wavy + Whistling + Witty + Zany + Zesty + Zippy + + + Alpaca + Badger + Beaver + Bison + Bunny + Capybara + Cheetah + Chipmunk + Cobra + Corgi + Coyote + Crane + Dingo + Dolphin + Duckling + Eagle + Falcon + Ferret + Finch + Flamingo + Fox + Gazelle + Gecko + Giraffe + Hedgehog + Heron + Hippo + Ibis + Jaguar + Koala + Lemur + Llama + Lynx + Magpie + Manatee + Marmot + Meerkat + Mongoose + Moose + Narwhal + Ocelot + Octopus + Orca + Otter + Owl + Panda + Pangolin + Parrot + Pelican + Penguin + Puffin + Quokka + Raccoon + Raven + Robin + Seal + Sloth + Sparrow + Squirrel + Tapir + Tiger + Toucan + Walrus + Wombat + + + Go to album + Sleep timer + Track details + Play + Pause + Nothing playing + Add to queue + + Ignore + Update + Spotube %1$s is available + See what’s new in this release on the Spotube downloads page. + %1$s %2$s is available + Currently installed: %1$s + See what’s new in this update from the plugin’s page. + + Skip + Next + Back + Get Started + Spotube + Music, without the strings. + An open-source player for a world of music.\nYours to explore. Yours to make your own. + Keep this project alive. + Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software. + Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here. + Discover plugins + Your sources + Try again + More plugins + No new plugins found. You can also install one from a link or a .smplug file above. + Plugin discovery is unavailable right now. Try again, or install from a link or file. + Welcome + Support + Plugins + Guide + + How to use Spotube + Four quick steps and you’re listening. + Sign in + Connect your account, or pick your own catalog plugin. + Search and play + Find any song, album, or artist, then press play. + Listen offline + Download tracks to keep playing without a connection. + Follow the lyrics + Open synced lyrics from the player while a track plays. + + + Your music. Your sources. Your rules. + A free, open-source music player built by a community that simply loves music. + Open source + No ads + No telemetry + Plugin powered + Let’s Go + + + Supporters keep us going + Built with passion. Kept alive by you. + Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing. + Patreon + Monthly Sponsor + Support recurring development and infrastructure + Buy Me a Coffee + One-Time Coffee + Fuel a late-night coding session with a quick tip + Open Collective + Community Fund + Transparent, community-governed financial backing + Thank you from the bottom of our hearts for believing in open music. + Supporting is completely optional — Spotube stays free either way. + Sponsor + Open %1$s + + + Support Spotube + Support Spotube + Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project. + You can turn these reminders off in Settings → Support. + Maybe later + Support reminders + Show a gentle reminder to support the project every 7 days. + + + Modular Architecture + Choose where your music comes from. + Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime. + Metadata Engine + Powers music discovery, search, track listings, and playlists. + Audio Engine + Fetches and decodes the audio streams that play. + Lyrics Engine + Streams time-synced karaoke lyrics in real time. + Scrobble Engine + Records listening history to Last.fm or ListenBrainz. + Active + Built-in + Ready + Optional + Select an engine + Installed Engines + Set as default + NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box. + Manage or add new community plugins anytime in Settings → Plugins. + + + Retro-Modern Architecture + Pure Hi-Fi, zero middlemen + Here’s how Spotube orchestrates decentralized playback directly on your machine. + DIRECT PEER-TO-PEER • 256 KBPS OPUS + Blue hour + Spotube · player demonstration + A little music for the moments in between. + 1. Decentralized Discovery + Queries go straight to your chosen catalog plugin. Spotube never profiles your taste. + 2. Direct Stream Resolution + Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting. + 3. Local DSP & Playback + Decoded and cached locally with equalizer controls and optional offline download. + 4. Synced Lyrics & Jam + Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam. + No middleman servers. Playback happens right on your device — private by design. + + + Choose a device + %1$s: \"%2$s\" + Play + Add to queue + Play next + This Device + On this device + Remote Device + Remote Device (%1$s) + On the connected device + + + Connection failed + Connection lost + The other device declined the request, or has remote control turned off. + Send failed + Discovery failed: %1$s + Failed to connect: %1$s + + + Devices + Refresh + Disconnect + Connecting... + Searching for devices on the network... + No devices found + Make sure the other device has \"Allow remote control\" enabled in settings. + Unknown Device + Resolving... + Connected + Available devices + This device + Connected + Open remote + Search again + Still looking for more devices... + + + Remote Control + Disconnected + Connection error: %1$s + Unknown Track + Unknown Artist + Volume + Remove from queue + More options + Playing on %1$s + If asked, allow this device on the other Spotube first. + You\'re no longer controlling the other device. Pick it again to reconnect. + Back to devices + Nothing playing + Start something on the other device, or pick a track from its queue. + + + Play Where? + You have an active jam session. Choose where to play this collection. + Play here + Suggest to Jam + + + No jam server configured + Couldn’t reach the jam server. Check your network or the server URL in Settings → Jam. + The jam server runs an incompatible version. + No room with that code. Check it and try again. + This room is full. + The host banned you from this room. + The host removed you from the room. + This room was opened again on this device, so this connection was closed. + Connection failed + Connection lost + + + + Added to the jam queue + + Added %1$d to the jam queue + Added %1$d to the jam queue + + + + Remote Control Request + \"%1$s\" wants to control playback on this device. + Deny + Allow + Allow Always + + + Search results + All + Tracks + Users + No search types available + Start typing to search + Start typing to search tracks + No results found + No results + See all + Searching + Loading tracks... + Loading albums... + Loading artists... + Loading playlists... + Loading users... + No albums found + No artists found + No playlists found + No users found + Digging through the crates... + Searching the cosmos... + Warming up the speakers... + Dusting off the vinyl... + Consulting the music gods... + Tuning the antennas... + Flipping through the records... + Failed to load search capabilities + Failed to search + Failed to search tracks + Failed to search albums + Failed to search artists + Failed to search playlists + Failed to search users + + + Browse + + + Synced + Plain + No synced lyrics available + No plain lyrics available + + + %1$s in %2$s + %1$d kbps + %1$s kHz • %2$d ch + No metadata plugin selected + + + Back + Retry + Close dialog + Loading... + Unknown + Unknown error + + + User + Playlist + + + Sign in to continue + This part of Spotube needs you to be signed in with your music provider. Sign in once and everything will show up here. + %1$s needs you to sign in before it can show this. Sign in once and everything will show up here. + Sign in + + + We\'re Truly Sorry + Something isn\'t working right now. Your experience matters to us, and we\'re doing our best to fix this. Please try again or check the error details below. + Error Details + Copy to clipboard + View full log + Full Error Log + + + Shuffle + Shuffle play + Follow + Unfollow + Edit playlist + By %1$s + + + Track options + Add to Jam + Start radio + Play next + Move to next + Remove from queue + Remove from favorites + Save as favorite + Add to playlist + Download + Share + + + Original + Title + Artist + Album + Duration + Sort + No tracks found + Filter + Bulk actions + Download All + Download %1$d + Add All to Queue + Add %1$d to Queue + Play All Next + Play %1$d Next + Add All to Playlist + Add %1$d to Playlist + Add All to Jam + Add %1$d to Jam + +%1$d more + + + No URL to load + Go back to browser history + Go forward to browser history + + + Filter blacklist... + No blacklisted items + No blacklisted items match \'%1$s\' + Tracks + Track + Add to blacklist + Remove from blacklist + + + No track metadata + Could not resolve stream URL + Could not resolve stream URL. Try playing the track first. + The server refused the download (HTTP %1$d) + The server sent an incomplete download. Try again. + Only a video is available for this track, so it wasn\'t downloaded + + + Saved Tracks + You + + + Following + Top Tracks + Play top tracks + Add top tracks to queue + Related Artists + Featured Playlists + Failed to load artist overview + Followers unavailable + %1$sK followers + %1$sM followers + %1$sB followers + + %1$d follower + %1$d followers + + + + Like + Unlike + No-op track title + No-op artists + Unknown Album + Seek + Volume + Enable shuffle + Disable shuffle + Previous + Next + Play or pause + Loop mode off + Loop mode one + Loop mode all + Mute + Unmute + More options + Player options + Collapse player + Expand lyrics + Now Playing + Lyrics + Alternative Source + Alternative source + Download + Downloaded + Downloading... + Already downloaded + Retrying download... + Downloading %1$s + Playback will pause in %1$s. + Pause playback after the selected number of minutes. + Minutes + Choose between 1 and 600 minutes + Cancel timer + Start + The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request. + Matched audio source + Loading matched source… + A matched remote source is not needed while playing the local cached file. + No matched source is available. + No matched audio source is cached for this track. + Stream details are not cached yet. Play the track first to load them. + Failed to load track details. + Available streams + This track is being served from the local cache. Stream variants are not used for this playback. + Loading streams… + No streams are available for this source. + Currently selected stream + Matches your streaming format & quality preference + Active playback URL + Served from local cache + Metadata track + Title + ID + Album + Confidence + External URI + Album ID + Album type + Album release date + Album track count + Album genres + Duration + Track number + Disc number + Explicit + Popularity + ISRC + Thumbnail URLs + + + Toggle Sidebar + + + Alternative Sources + No track currently playing + Searching for alternative sources... + No alternative sources found + Confidence: %1$d% + Active source + + + Kick + Remove them from the session + Kick and prevent them from rejoining + Remove suggestions + Remove every track they added to the queue + Reorder + Remove from queue + %1$s • Added by %2$s + + + Unknown + Loading playlist... + Add Tracks + Add Tracks to Playlist + Search tracks... + Search + No tracks found + Search for tracks to add + Done + Add + Added + + + Loading album... + + + Add to Your Playlist + Search + Search your playlists... + Loading your playlists... + You don\'t own any playlists yet.\nCreate one to add tracks. + Create Playlist + Edit Playlist + Create + Name + Playlist name + Description + Description (optional) + Public + Anyone can see this playlist + Collaborative + Others can add tracks to this playlist + Cover Image + Playlist cover + No image + Change Image + Pick Image + + + Install + Replace + plugin.json not found in the zip file + manifest.zipline.json not found in the zip file + Invalid plugin.json format: %1$s + Failed to read plugin: %1$s + Failed to install plugin: %1$s + A newer version of %1$s is available. Confirm the update before installing. + %1$s is already installed. Confirm replacing it before installing. + Built-in plugins are already included and can\'t be added again. + Built-in plugins can\'t be removed. + %1$s declares an invalid apiVersion (%2$s). Expected semantic versioning compatible with %3$s. + Spotube plugin API version %1$s is invalid. + %1$s targets plugin API %2$s, but Spotube supports breaking plugin API %3$s. Install a plugin built for the same breaking API version. + + + Plugin API not compatible + Install plugin? + %1$s will be added to your installed plugins. + Update plugin? + An older version is installed (%1$s). Update to %2$s? + Replace installed plugin? + %1$s uses an invalid semantic version (%2$s) and can\'t be compared as an update. Replace the installed plugin anyway? + The installed version (%1$s) can\'t be compared using semantic versioning. Replace it with %2$s? + Version %1$s is already installed. Replace the existing plugin with the supplied copy? + Installed version %1$s is newer than the supplied version %2$s. Replace it anyway? + Replace the installed plugin with the supplied copy? + + + %1$s: + Version + API Version + Author + License + Capabilities + Abilities + Repository + Contact + Report Bugs + Support %1$s + + + Official + Verified + GitHub stars + GitHub repository URL + github.com + + + No releases found + Select Release + Select a release + Pre-release + Release Notes + + + Home + Search + On Device + Connect + Devices + + Hosting · %1$d listener + Hosting · %1$d listeners + + + In a Jam · %1$d listener + In a Jam · %1$d listeners + + + + Hide Window + Show Window + Next Track + Previous Track + Shuffle + Loop + Loop: OFF + Loop: ONE + Loop: ALL + Volume: %1$d% + Volume + + Volume - + Mute + Unmute + Like Track + Unlike Track + Exit + + + Minimize + Maximize + Restore + Close + + + Playback + Media playback controls + + + Browse + Now Playing + Featured + Saved Tracks + Top Tracks + diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt deleted file mode 100644 index 66d14649..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ /dev/null @@ -1,145 +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 . - */ - -package dev.krtirtho.spotube - -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.ui.NavDisplay -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES -import dev.krtirtho.spotube.core.navigation.rememberNavigationState -import dev.krtirtho.spotube.core.navigation.toEntries -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme -import dev.krtirtho.spotube.modules.settings.SettingsRepository -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.shell.AppShell -import dev.krtirtho.spotube.modules.webview.WebViewScreen -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHome -import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.koin.compose.koinInject -import org.koin.compose.navigation3.koinEntryProvider -import org.koin.core.annotation.KoinExperimentalAPI - -data class TabItem( - val title: String, - val icon: ImageVector, - val activeIcon: ImageVector, - val route: Routes -) - -val tabs = listOf( - TabItem( - "Home", - Iconsax.IconsaxHomeBroken, - Iconsax.IconsaxHome, - Routes.Home - ), - TabItem( - "Search", - Iconsax.IconsaxSearchBroken, - Iconsax.IconsaxSearch, - Routes.Search - ), - TabItem( - "Library", - Iconsax.IconsaxMusicLibraryOutline, - Iconsax.IconsaxMusicLibrary, - Routes.Library - ), - TabItem( - "Settings", - Iconsax.IconsaxSettingTwotone, - Iconsax.IconsaxSetting2, - Routes.Settings - ) -) - -@OptIn( - KoinExperimentalAPI::class, - ExperimentalCoroutinesApi::class, - ExperimentalSharedTransitionApi::class -) -@Composable -fun App( - content: @Composable () -> Unit = {} -) { - val settingsRepository: SettingsRepository = koinInject() - val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) - - val navigationState = rememberNavigationState( - startRoute = Routes.Home, - topLevelRoutes = TOP_LEVEL_ROUTES - ) - val navigator = remember { - Navigator(navigationState) - } - - SpotubeTheme(settings = userSettings) { - val baseUITheme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { - Box(modifier = Modifier.fillMaxSize()) { - val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last() - - Box(Modifier.fillMaxSize()) { - AppShell(navigator, navigationState) { - SharedTransitionLayout { - CompositionLocalProvider( - LocalSharedTransitionScope provides this@SharedTransitionLayout, - ) { - Column { - NavDisplay( - modifier = Modifier.fillMaxSize(), - onBack = navigator::pop, - entries = navigationState.toEntries(koinEntryProvider()) - ) - } - } - } - } - when (currentRoute) { - Routes.WebView -> WebViewScreen(koinInject()) - else -> {} - } - } - } - content() - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt deleted file mode 100644 index 665f351b..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ /dev/null @@ -1,223 +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 . - */ - -package dev.krtirtho.spotube.core.di - -import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository -import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.discord.DiscordRpcService -import dev.krtirtho.spotube.core.navigation.navigationModule -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.server.AlternativeTracksRepository -import dev.krtirtho.spotube.core.server.CacheManager -import dev.krtirtho.spotube.core.server.LocalServer -import dev.krtirtho.spotube.core.server.MatchedTracksRepository -import dev.krtirtho.spotube.core.server.StreamingUrlRepository -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.modules.album.AlbumRepository -import dev.krtirtho.spotube.modules.album.AlbumViewModel -import dev.krtirtho.spotube.modules.artist.ArtistRepository -import dev.krtirtho.spotube.modules.artist.ArtistViewModel -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.home.HomeScreenRepository -import dev.krtirtho.spotube.modules.home.HomeScreenViewModel -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel -import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsViewModel -import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator -import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel -import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel -import dev.krtirtho.spotube.modules.playlist.PlaylistRepository -import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel -import dev.krtirtho.spotube.modules.plugin.PluginManager -import dev.krtirtho.spotube.modules.plugin.PluginProvider -import dev.krtirtho.spotube.modules.plugin.PluginViewModel -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.modules.search.SearchRepository -import dev.krtirtho.spotube.modules.search.SearchScreenViewModel -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.krtirtho.spotube.modules.settings.SettingsRepository -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.shell.AppShellViewModel -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel -import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel -import org.koin.core.module.Module -import org.koin.core.module.dsl.bind -import org.koin.core.module.dsl.createdAtStart -import org.koin.core.module.dsl.singleOf -import org.koin.core.module.dsl.viewModel -import org.koin.core.module.dsl.viewModelOf -import org.koin.core.module.dsl.withOptions -import org.koin.dsl.module - -expect val platformModules: Module - -val sharedModules = module { - includes(navigationModule) - singleOf(::Database) - - singleOf(::WebViewController) - - // Shell - viewModelOf(::AppShellViewModel) - viewModelOf(::PlayerQueueContentViewModel) - viewModelOf(::AlternativeTrackContentViewModel) - - // Home - singleOf(::HomeScreenRepository) - viewModelOf(::HomeScreenViewModel) - - // Search - singleOf(::SearchRepository) - viewModelOf(::SearchScreenViewModel) - - // Library - singleOf(::LibraryState) - singleOf(::LibraryRepository) - singleOf(::LocalMediaCacheRepository) - singleOf(::LocalMediaFoldersConfig) - singleOf(::LocalMediaLibraryCoordinator) - viewModelOf(::LibraryPlaylistsViewModel) - viewModelOf(::LibraryAlbumsViewModel) - viewModelOf(::LibraryArtistsViewModel) - viewModelOf(::LibraryLocalTracksViewModel) - - // Plugin system - singleOf(::PluginManager) { bind() } - viewModelOf(::PluginViewModel) - - // Settings - singleOf(::SettingsRepository) - viewModelOf(::SettingsViewModel) { bind() } - - // Downloads - singleOf(::DownloadManager) - viewModelOf(::DownloadsViewModel) - - // Playlist - singleOf(::PlaylistRepository) - viewModel { (playlistId: String) -> - PlaylistViewModel( - playlistId = playlistId, - repository = get(), - savedTracksRepository = get(), - libraryRepository = get(), - playbackHelper = get(), - audioPlayerQueue = get(), - blacklistRepository = get(), - shareService = get(), - downloadManager = get(), - ) - } - - // Saved Tracks - singleOf(::SavedTracksRepository) - viewModel { - SavedTracksViewModel( - repository = get(), - playbackHelper = get(), - audioPlayerQueue = get(), - blacklistRepository = get(), - libraryRepository = get(), - shareService = get(), - downloadManager = get(), - ) - } - - // Artist - singleOf(::ArtistRepository) - viewModel { (artistId: String) -> - ArtistViewModel( - artistId = artistId, - repository = get(), - libraryRepository = get(), - savedTracksRepository = get(), - audioPlayerQueue = get(), - blacklistRepository = get(), - shareService = get(), - downloadManager = get(), - ) - } - - // Blacklist - singleOf(::BlacklistRepository) - viewModelOf(::BlacklistViewModel) - - // Album - singleOf(::AlbumRepository) - viewModel { (albumId: String) -> - AlbumViewModel( - albumId = albumId, - repository = get(), - savedTracksRepository = get(), - playbackHelper = get(), - libraryRepository = get(), - audioPlayerQueue = get(), - blacklistRepository = get(), - shareService = get(), - downloadManager = get(), - ) - } - - // Lyrics - viewModelOf(::LyricsViewModel) - - // Local playback proxy server - single { - CollectionPlaybackHelper( - albumRepository = get(), - playlistRepository = get(), - savedTracksRepository = get(), - audioPlayerQueue = get(), - blacklistRepository = get(), - ) - } - singleOf(::MatchedTracksRepository) - singleOf(::StreamingUrlRepository) - singleOf(::CacheManager) - singleOf(::AlternativeTracksRepository) - singleOf(::LocalServer) withOptions { - createdAtStart() - } - singleOf(::AudioPlayerQueueRepository) { bind() } - single { - DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) - } - - single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } - - factory { (tag: String?) -> - if (tag != null) { - Logger.withTag(tag) - } else { - // Default logger if no tag is provided - Logger - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt deleted file mode 100644 index d048c1d8..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ /dev/null @@ -1,152 +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 . - */ - -package dev.krtirtho.spotube.core.navigation - -import androidx.navigation3.runtime.NavKey -import dev.krtirtho.spotube.modules.album.AlbumScreen -import dev.krtirtho.spotube.modules.artist.ArtistScreen -import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen -import dev.krtirtho.spotube.modules.home.HomeScreen -import dev.krtirtho.spotube.modules.library.LibraryScreen -import dev.krtirtho.spotube.modules.lyrics.LyricsScreen -import dev.krtirtho.spotube.modules.playlist.PlaylistScreen -import dev.krtirtho.spotube.modules.plugin.PluginScreen -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen -import dev.krtirtho.spotube.modules.search.SearchScreen -import dev.krtirtho.spotube.modules.settings.SettingsScreen -import kotlinx.serialization.Serializable -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.KoinExperimentalAPI -import org.koin.core.module.dsl.singleOf -import org.koin.core.parameter.parametersOf -import org.koin.dsl.module -import org.koin.dsl.navigation3.navigation - -@Serializable -sealed interface Routes : NavKey { - @Serializable - data object Home : Routes - - @Serializable - data object Library : Routes - - @Serializable - data object Search : Routes - - @Serializable - data object Settings : Routes - - @Serializable - data object Plugins : Routes - - @Serializable - data object WebView : Routes - - @Serializable - data object Lyrics : Routes - - @Serializable - data class Playlist(val playlistId: String) : Routes - - @Serializable - data class Artist(val artistId: String) : Routes - - @Serializable - data class Album(val albumId: String) : Routes - - @Serializable - data object SavedTracks : Routes - - @Serializable - data object Blacklist : Routes -} - -@OptIn(KoinExperimentalAPI::class) -val navigationModule = module { - singleOf(::NavigationCommands) - navigation { - HomeScreen(viewModel = koinViewModel()) - } - navigation { - SearchScreen() - } - navigation { - LibraryScreen() - } - navigation { - SettingsScreen(settingsViewModel = koinViewModel()) - } - navigation { - PluginScreen(viewModel = koinViewModel()) - } - navigation {} - navigation { - PlaylistScreen( - playlistId = it.playlistId, - viewModel = koinViewModel( - key = it.playlistId, - parameters = { parametersOf(it.playlistId) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get(), - ) - } - navigation { - ArtistScreen( - viewModel = koinViewModel( - key = it.artistId, - parameters = { parametersOf(it.artistId) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get(), - ) - } - navigation { - AlbumScreen( - albumId = it.albumId, - viewModel = koinViewModel( - key = it.albumId, - parameters = { parametersOf(it.albumId) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get(), - ) - } - navigation { - LyricsScreen(viewModel = koinViewModel()) - } - navigation { - SavedTracksScreen( - viewModel = koinViewModel( - key = SAVED_TRACKS_COLLECTION_ID, - parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) } - ), - audioPlayerQueue = get(), - audioPlayer = get(), - navigationCommands = get(), - ) - } - navigation { - BlacklistScreen() - } -} - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt deleted file mode 100644 index 3dcda56c..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt +++ /dev/null @@ -1,46 +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 . - */ - -package dev.krtirtho.spotube.core.server - -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.db.Database -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.serialization.json.Json - -class MatchedTracksRepository(private val database: Database) { - suspend fun getTrackSource(track: MetadataTrack): AudioSource.Basic? { - return database.matchedTracksDataStore.data.map { prefs -> - val json = prefs[stringPreferencesKey(track.id)] - if (json != null) { - Json.decodeFromString(json as String) - } else { - return@map null - } - }.first() - } - - suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) { - database.matchedTracksDataStore.edit { prefs -> - prefs[stringPreferencesKey(track.id)] = Json.encodeToString(source) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt deleted file mode 100644 index c3af11d4..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ /dev/null @@ -1,255 +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 . - */ - -package dev.krtirtho.spotube.core.server - -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.PluginManager -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koin.core.component.KoinComponent -import kotlin.time.Clock -import kotlin.time.Duration.Companion.seconds - -data class CachedStreamUrl( - val url: String, - val protocol: StreamProtocol, - val expiresAtMs: Long, - val container: String, - val codec: String, -) - -data class StreamInfo( - val url: String, - val protocol: StreamProtocol, - val codec: String, - val container: String, -) - - -class StreamingUrlRepository( - private val pluginManager: PluginManager, - private val audioPlayerQueue: AudioPlayerQueue, - private val matchedTracksRepository: MatchedTracksRepository, -) : KoinComponent { - - companion object { - private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds - } - - private val logger by injectLogger() - private val streamUrlCacheMutex = Mutex() - private val streamUrlCache = mutableMapOf() - private val alternativesCacheMutex = Mutex() - private val alternativesCache = mutableMapOf>() - - private fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { - if (url.startsWith("http")) return url - val base = when (protocol) { - StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com" - StreamProtocol.PROGRESSIVE -> return url - } - return "$base$url" - } - - suspend fun resolveStreamInfo( - trackId: String, - forceRefresh: Boolean = false - ): StreamInfo? { - val queueEntry = audioPlayerQueue.queueFlow.value.firstOrNull { - it is QueueEntry.StreamingTrack && it.track.id == trackId - } as QueueEntry.StreamingTrack? - - if (queueEntry == null) { - logger.v { "Track $trackId is not present in current queue" } - return null - } - return resolveStreamInfo(queueEntry.track, forceRefresh) - } - - suspend fun resolveStreamInfo( - track: MetadataTrack, - forceRefresh: Boolean = false - ): StreamInfo? { - val trackId = track.id - if (!forceRefresh) { - getCachedStreamUrl(trackId)?.let { cached -> - logger.v { "Using cached stream URL for track $trackId" } - return cached - } - } - - val audioPlugin = pluginManager.selectedAudioPlugin.value - if (audioPlugin == null) { - logger.w { "No audio plugin selected while resolving stream for track $trackId" } - return null - } - - val source = matchedTracksRepository.getTrackSource(track) - - if (source != null) { - logger.d { "Attempting stream resolution for track $trackId using cached source" } - val stream = runCatching { - audioPlugin.use { - audioAPI.getStreamsOfAudioSource(source) - .firstOrNull() - } - }.getOrElse { throwable -> - logger.w(throwable) { "Failed to resolve audio stream for track $trackId using matched source" } - null - } - - if (stream != null) { - logger.d { "Resolved stream for track $trackId using cached source" } - val resolvedStream = stream.streams.firstOrNull() - if (resolvedStream != null) { - val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) - val info = StreamInfo( - url, - resolvedStream.protocol, - resolvedStream.codec, - resolvedStream.container - ) - cacheStreamInfo(trackId, info) - return info - } - } - - logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" } - } - - logger.d { "Attempting stream resolution for track $trackId using track lookup" } - val sources = runCatching { - audioPlugin.use { - audioAPI.getStreamsByTrack(track) - } - }.getOrElse { throwable -> - logger.w(throwable) { "Failed to resolve audio stream for track $trackId" } - return null - } - - if (sources.isEmpty()) { - logger.w { "No stream candidates found for track $trackId" } - return null - } - - cacheAlternatives(trackId, sources) - - val stream = runCatching { - audioPlugin.use { - sources.firstNotNullOfOrNull { src -> - when (src) { - is AudioSource.Streamed -> { - matchedTracksRepository.saveTrackSource(track, src.toBasic()) - src - } - - is AudioSource.Basic -> { - matchedTracksRepository.saveTrackSource(track, src) - audioAPI.getStreamsOfAudioSource(src) - .firstOrNull() - } - } - } - } - }.getOrElse { throwable -> - logger.w(throwable) { "Failed to resolve audio stream for track $trackId" } - return null - } - - if (stream == null) { - logger.w { "No stream candidates found for track $trackId" } - return null - } - - logger.d { "Resolved stream for track $trackId via track lookup" } - val resolvedStream = stream.streams.firstOrNull() - if (resolvedStream != null) { - val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol) - val info = StreamInfo( - url, - resolvedStream.protocol, - resolvedStream.codec, - resolvedStream.container - ) - cacheStreamInfo(trackId, info) - return info - } - return null - } - - suspend fun getCachedStreamUrl(trackId: String): StreamInfo? { - val now = Clock.System.now().toEpochMilliseconds() - return streamUrlCacheMutex.withLock { - val cached = streamUrlCache[trackId] ?: return@withLock null - if (cached.expiresAtMs <= now) { - streamUrlCache.remove(trackId) - logger.v { "Cached stream URL expired for track $trackId" } - return@withLock null - } - StreamInfo(cached.url, cached.protocol, cached.container, cached.codec) - } - } - - suspend fun cacheStreamInfo(trackId: String, info: StreamInfo) { - val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS - streamUrlCacheMutex.withLock { - streamUrlCache[trackId] = - CachedStreamUrl( - url = info.url, - protocol = info.protocol, - expiresAtMs = expiresAtMs, - container = info.container, - codec = info.codec - ) - } - } - - suspend fun invalidateCachedStreamUrl(trackId: String, url: String? = null) { - streamUrlCacheMutex.withLock { - val cached = streamUrlCache[trackId] ?: return@withLock - if (url == null || cached.url == url) { - streamUrlCache.remove(trackId) - logger.v { "Invalidated cached stream URL for track $trackId" } - } - } - } - - suspend fun getCachedAlternatives(trackId: String): List? { - return alternativesCacheMutex.withLock { - alternativesCache[trackId] - } - } - - suspend fun cacheAlternatives(trackId: String, sources: List) { - alternativesCacheMutex.withLock { - alternativesCache[trackId] = sources - logger.v { "Cached ${sources.size} alternative sources for track $trackId" } - } - } - - suspend fun invalidateCachedAlternatives(trackId: String) { - alternativesCacheMutex.withLock { - alternativesCache.remove(trackId) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt deleted file mode 100644 index a537a8a1..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/tools/user_agents/UserAgents.kt +++ /dev/null @@ -1,34 +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 . - */ - -package dev.krtirtho.spotube.core.tools.user_agents - -object UserAgents { - private val agents = listOf( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 15; ALI-NX1 Build/HONORALI-N21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.92 Mobile Safari/537.36", - "Mozilla/5.0 (Linux; Android 14; SM-A528B Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.108 Mobile Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.7; rv:68.0) Gecko/20100101 Firefox/68.0 Waterfox/56.6.2021.2816", - "Mozilla/5.0 (Macintosh; Intel Mac OS X10.14; rv:65.0) Gecko/20100101 Firefox/65.0 Waterfox/56.2.7", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Vivaldi/7.6.3797.63", - "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.91 Safari/537.36 Vivaldi/1.92.917.39", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.5.3735.47" - ) - - fun random(): String = agents.random() -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt deleted file mode 100644 index 21a73c0f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt +++ /dev/null @@ -1,528 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.focusable -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.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -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.rememberLazyListState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken - -private val AutocompleteTextFieldMinHeight = 44.dp -private val AutocompleteMenuDefaultMaxHeight = 300.dp -private val AutocompleteMenuDefaultOffset = 4.dp - -private data class ResolvedAutoCompleteTextFieldState( - val background: Brush, - val highlight: Color, - val shape: Shape, - val border: BaseUITheme.Border, - val shadow: BaseUITheme.Shadow, - val foreground: Color, -) - -@Composable -private fun resolveTextFieldState( - theme: BaseUITheme.TextFieldTheme, - isFocused: Boolean, - isHovered: Boolean, -): ResolvedAutoCompleteTextFieldState { - return when { - isFocused -> ResolvedAutoCompleteTextFieldState( - theme.background.focused, - theme.highlight.focused, - theme.shape.focused, - theme.border.focused, - theme.shadow.focused, - theme.foreground.focused - ) - - isHovered -> ResolvedAutoCompleteTextFieldState( - theme.background.hovered, - theme.highlight.hovered, - theme.shape.hovered, - theme.border.hovered, - theme.shadow.hovered, - theme.foreground.hovered - ) - - else -> ResolvedAutoCompleteTextFieldState( - theme.background.normal, - theme.highlight.normal, - theme.shape.normal, - theme.border.normal, - theme.shadow.normal, - theme.foreground.normal - ) - } -} - -@Composable -fun AutocompleteTextField( - value: String, - onValueChange: (String) -> Unit, - items: List, - itemContent: @Composable (item: T, isSelected: Boolean) -> Unit, - modifier: Modifier = Modifier, - onItemSelected: (T) -> Unit = {}, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - label: @Composable (() -> Unit)? = null, - singleLine: Boolean = true, - maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, - enabled: Boolean = true, - readOnly: Boolean = false, - isError: Boolean = false, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - visualTransformation: VisualTransformation = VisualTransformation.None, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - textStyle: TextStyle = TextStyle.Default, - cursorBrush: Color? = null, - onKeyEvent: ((KeyEvent) -> Boolean)? = null, - menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight, - menuOffset: Dp = AutocompleteMenuDefaultOffset, - textFieldTheme: BaseUITheme.TextFieldTheme? = null, - menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null, -) { - val density = LocalDensity.current - val baseTextFieldTheme = LocalBaseUITheme.current.textField - val baseMenuTheme = LocalBaseUITheme.current.autoCompleteMenuTheme - val resolvedTextFieldTheme = textFieldTheme ?: baseTextFieldTheme - val resolvedMenuTheme = menuTheme ?: baseMenuTheme - val isFocused by interactionSource.collectIsFocusedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - - var isMenuOpen by remember { mutableStateOf(false) } - var isMovingFocusToPopup by remember { mutableStateOf(false) } - var selectedIndex by remember { mutableIntStateOf(-1) } - var textFieldSize by remember { mutableStateOf(IntSize.Zero) } - val menuListState = rememberLazyListState() - val popupFocusRequester = remember { FocusRequester() } - val textFieldFocusRequester = remember { FocusRequester() } - - val menuExpanded = isMenuOpen && items.isNotEmpty() - - LaunchedEffect(isFocused) { - if (isFocused) { - isMenuOpen = true - isMovingFocusToPopup = false - } - } - - LaunchedEffect(value) { - if (selectedIndex != -1) selectedIndex = -1 - } - - LaunchedEffect(selectedIndex) { - if (selectedIndex >= 0 && menuExpanded) { - menuListState.scrollToItem(selectedIndex) - } - } - - val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) { - KeyboardActions( - onSearch = { - if (menuExpanded && selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - } else { - keyboardActions.onSearch?.invoke(this) - } - }, - onDone = keyboardActions.onDone, - onGo = keyboardActions.onGo, - onNext = keyboardActions.onNext, - onPrevious = keyboardActions.onPrevious, - onSend = keyboardActions.onSend, - ) - } - - val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered) - - val resolvedBorder = if (isError) { - BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width) - } else { - fieldState.border - } - - val contentColor = when { - !enabled -> fieldState.foreground.copy(alpha = 0.38f) - else -> fieldState.foreground - } - - val cursor = cursorBrush?.let { SolidColor(it) } ?: resolvedTextFieldTheme.cursor - - fun navigate(delta: Int) { - if (items.isEmpty()) return - selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size - } - - Box(modifier = modifier) { - Column { - AnimatedVisibility( - visible = label != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - label?.invoke() - } - - Box( - modifier = Modifier - .fillMaxWidth() - .onSizeChanged { textFieldSize = it } - .defaultMinSize(minHeight = AutocompleteTextFieldMinHeight) - .then( - if (fieldState.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = fieldState.shadow.elevation, - shape = fieldState.shape, - ambientColor = fieldState.shadow.ambientColor, - spotColor = fieldState.shadow.spotColor, - ) - } else Modifier - ) - .clip(fieldState.shape) - .background(fieldState.background, fieldState.shape) - .border( - BorderStroke(resolvedBorder.width, resolvedBorder.color), - fieldState.shape - ) - .highlight(fieldState.highlight) - .padding(resolvedTextFieldTheme.padding), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (leadingIcon != null) { - leadingIcon() - } - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .hoverable( - interactionSource = interactionSource, - enabled = enabled, - ) - .focusRequester(textFieldFocusRequester) - .onFocusChanged { state -> - if (!state.isFocused && !isMovingFocusToPopup) { - isMenuOpen = false - selectedIndex = -1 - } - } - .onPreviewKeyEvent { event -> - if (onKeyEvent?.invoke(event) == true) { - return@onPreviewKeyEvent true - } - if (!menuExpanded) return@onPreviewKeyEvent false - when (event.key) { - Key.DirectionDown -> { - isMovingFocusToPopup = true - popupFocusRequester.requestFocus() - navigate(1) - true - } - - Key.DirectionUp -> { - if (selectedIndex >= 0) { - navigate(-1) - true - } else false - } - - Key.Enter -> { - if (selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - return@onPreviewKeyEvent true - } - false - } - - Key.Escape -> { - selectedIndex = -1 - isMenuOpen = false - true - } - - else -> false - } - }, - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle.copy(color = contentColor), - cursorBrush = cursor, - keyboardOptions = keyboardOptions, - keyboardActions = wrappedKeyboardActions, - singleLine = singleLine, - maxLines = maxLines, - visualTransformation = visualTransformation, - interactionSource = interactionSource, - decorationBox = { innerTextField -> - if (value.isEmpty() && placeholder != null && !isFocused) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Box(contentAlignment = Alignment.CenterStart) { - placeholder() - } - } - } else { - innerTextField() - } - }, - ) - } - - if (trailingIcon != null) { - trailingIcon() - } - } - } - } - - if (menuExpanded && textFieldSize.width > 0) { - val textFieldHeightPx = textFieldSize.height - val menuOffsetPx = with(density) { menuOffset.roundToPx() } - val textFieldWidthDp = with(density) { textFieldSize.width.toDp() } - - Popup( - alignment = Alignment.TopStart, - offset = IntOffset(0, textFieldHeightPx + menuOffsetPx), - properties = PopupProperties(focusable = true), - onDismissRequest = { - selectedIndex = -1 - isMenuOpen = false - }, - ) { - Box( - modifier = Modifier - .width(textFieldWidthDp) - .heightIn(max = menuMaxHeight) - .shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape) - .background(resolvedMenuTheme.background, resolvedMenuTheme.shape) - .clip(resolvedMenuTheme.shape) - .focusRequester(popupFocusRequester) - .focusable() - .onFocusChanged { state -> - if (state.isFocused) { - isMovingFocusToPopup = false - } - } - .onKeyEvent { event -> - when (event.key) { - Key.DirectionDown -> { - navigate(1); true - } - - Key.DirectionUp -> { - navigate(-1); true - } - - Key.Enter -> { - if (selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - textFieldFocusRequester.requestFocus() - return@onKeyEvent true - } - false - } - - Key.Escape -> { - selectedIndex = -1 - isMenuOpen = false - textFieldFocusRequester.requestFocus() - true - } - - else -> false - } - }, - ) { - LazyColumn( - state = menuListState, - modifier = Modifier - .heightIn(max = menuMaxHeight), - ) { - items(items.size) { index -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { - onItemSelected(items[index]) - selectedIndex = -1 - isMenuOpen = false - textFieldFocusRequester.requestFocus() - }, - ) { - itemContent(items[index], selectedIndex == index) - } - } - } - } - } - } - } -} - -@Preview -@Composable -private fun AutocompleteTextFieldPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - val artists = remember { - listOf( - "Twenty One Pilots", - "The Beatles", - "Adele", - "Drake", - "Taylor Swift", - "Arctic Monkeys", - "Billie Eilish", - ) - } - var value by remember { mutableStateOf("") } - - AutocompleteTextField( - value = value, - onValueChange = { - value = it - }, - items = artists.filter { - it.contains(value, ignoreCase = true) && value.isNotEmpty() - }, - onItemSelected = { selected -> - value = selected - }, - placeholder = { Text("Search artists...") }, - leadingIcon = { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - }, - modifier = Modifier.fillMaxWidth(), - itemContent = { item, isSelected -> - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = if (isSelected) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) - } else { - Color.Transparent - }, - ) - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = item, - style = MaterialTheme.typography.bodyMedium, - ) - } - }, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt deleted file mode 100644 index edc24ebb..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ /dev/null @@ -1,830 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare -import dev.krtirtho.spotube.resources.iconsax.User - -private val BadgeShape = RoundedCornerShape(11.dp) -private val ButtonMinHeight = 40.dp -private val SquareButtonSize = 40.dp - -private data class ResolvedButtonState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, - val padding: PaddingValues, -) - -@Composable -private fun resolveButtonState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedButtonState { - return when { - isPressed -> ResolvedButtonState( - style.colors.pressed, - style.shape.pressed, - style.shadow.pressed, - style.border.pressed, - style.padding.pressed - ) - - isHovered -> ResolvedButtonState( - style.colors.hovered, - style.shape.hovered, - style.shadow.hovered, - style.border.hovered, - style.padding.hovered - ) - - else -> ResolvedButtonState( - style.colors.normal, - style.shape.normal, - style.shadow.normal, - style.border.normal, - style.padding.normal - ) - } -} - -private fun Modifier.applyShadow( - shadow: BaseUITheme.Shadow, - shape: Shape, -): Modifier { - return if (shadow.elevation > 0.dp) { - this.shadow( - elevation = shadow.elevation, - shape = shape, - ambientColor = shadow.ambientColor, - spotColor = shadow.spotColor, - ) - } else { - this - } -} - -@Composable -fun OutlineButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - hoverOnly: Boolean = false, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .then( - if (hoverOnly && !isHovered) Modifier else Modifier.applyShadow( - state.shadow, - state.shape - ) - ) - .clip(state.shape) - .then( - if (hoverOnly && !isHovered) Modifier else Modifier.background( - state.colors.background, - state.shape - ) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - ) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .then(if (hoverOnly && !isHovered) Modifier else Modifier.highlight(state.colors.highlight)) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - content() - } - }, - ) - } -} - -@Composable -fun PrimaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.primary - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .applyShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .highlight(state.colors.highlight) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } - } -} - -@Composable -fun SecondaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.secondary - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .applyShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .highlight(state.colors.highlight) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } - } -} - -@Composable -fun IconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.outline - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun GhostButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.ghost - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - hoverOnly = true, - theme = style, - content = content, - ) -} - -@Composable -fun GhostIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.ghost - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - hoverOnly = true, - theme = style, - ) { - content() - } -} - -@Composable -fun PrimaryIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.primary - PrimaryButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun SecondaryIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.secondary - SecondaryButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun ButtonBadge( - count: Int, - modifier: Modifier = Modifier, - theme: BaseUITheme.ButtonStyle? = null, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - Box( - modifier = modifier - .heightIn(min = 22.dp) - .defaultMinSize(minWidth = 22.dp) - .background(state.colors.background, BadgeShape) - .border(1.dp, state.border.color, BadgeShape) - .padding(horizontal = 7.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = count.toString(), - fontSize = 12.sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - maxLines = 1, - softWrap = false, - color = state.colors.foreground.copy(alpha = 0.7f), - ) - } -} - -@Composable -fun GroupButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val overlay = when { - isPressed -> state.colors.background - isHovered -> state.colors.background - else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) - } - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .graphicsLayer { translationY = lift.toPx() } - .background(overlay) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } -} - -@Composable -fun GroupIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val overlay = when { - isPressed -> state.colors.background - isHovered -> state.colors.background - else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) - } - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .size(SquareButtonSize) - .graphicsLayer { translationY = lift.toPx() } - .background(overlay) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - content() - } -} - -@Composable -fun ButtonGroup( - modifier: Modifier = Modifier, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - val groupShape = state.shape - Surface( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .applyShadow(state.shadow, groupShape), - shape = groupShape, - color = Color.Transparent, - border = BorderStroke(state.border.width, state.border.color), - ) { - Box( - modifier = Modifier.background(state.colors.background, groupShape), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - content() - } - } - } -} - -@Composable -fun ButtonGroupDivider( - theme: BaseUITheme.ButtonStyle? = null, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - Box( - modifier = Modifier - .width(1.dp) - .heightIn(min = 20.dp) - .background(state.border.color), - ) -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow1Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text( - "Copy link", - maxLines = 1, - softWrap = false, - ) - } - OutlineButton(onClick = {}) { - Icon( - imageVector = Iconsax.User, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Login", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text( - "Sign Up", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow2Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - ButtonGroup { - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxDocumentText, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Documents", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Export", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - ButtonGroup { - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.ArrowLeft3, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow3Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}) { - Text("Cancel", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Text( - "Done", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - IconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxMagic, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - OutlineButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxHeart, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Like", maxLines = 1, softWrap = false) - ButtonBadge(count = 2) - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonsRow4Preview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - ButtonGroup { - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.ArrowLeft3, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - OutlineButton(onClick = {}) { - Text("Forward", maxLines = 1, softWrap = false) - Icon( - imageVector = Iconsax.IconsaxArrowSquareUp, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ButtonStylesPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}) { - Text("Outline", maxLines = 1, softWrap = false) - } - SecondaryButton(onClick = {}) { - Text("Secondary", maxLines = 1, softWrap = false) - } - PrimaryButton(onClick = {}) { - Text( - "Primary", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - } - } - } - } -} - -enum class VariableIconButtonVariant { - Outline, - Ghost, - Primary, - Secondary, -} - -@Composable -fun VariableIconButton( - variant: VariableIconButtonVariant = VariableIconButtonVariant.Outline, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - when (variant) { - VariableIconButtonVariant.Outline -> IconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Ghost -> GhostIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Primary -> PrimaryIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Secondary -> SecondaryIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt deleted file mode 100644 index 61e23930..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt +++ /dev/null @@ -1,81 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp - -@Composable -fun Card( - modifier: Modifier = Modifier, - theme: BaseUITheme.CardTheme? = null, - content: @Composable () -> Unit, -) { - val cardTheme = theme ?: LocalBaseUITheme.current.card - - Box( - modifier = modifier - .then( - if (cardTheme.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = cardTheme.shadow.elevation, - shape = cardTheme.shape, - ambientColor = cardTheme.shadow.ambientColor, - spotColor = cardTheme.shadow.spotColor, - ) - } else Modifier - ) - .clip(cardTheme.shape) - .background(cardTheme.background, cardTheme.shape) - .border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape) - .padding(cardTheme.padding), - ) { - content() - } -} - -@Composable -@androidx.compose.ui.tooling.preview.Preview -private fun CardPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Card { - Box(modifier = Modifier.padding(16.dp)) - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt deleted file mode 100644 index 23d66e55..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt +++ /dev/null @@ -1,253 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -enum class CheckBoxState { - SELECTED, - UNSELECTED, - INDETERMINATE, - @Deprecated("Use INDETERMINATE", ReplaceWith("CheckBoxState.INDETERMINATE")) - CLEAR; -} - -private val CheckBoxSize = 22.dp - -private data class ResolvedCheckBoxState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveCheckBoxState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedCheckBoxState { - return when { - isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) - isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered) - else -> ResolvedCheckBoxState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal) - } -} - -@Composable -fun CheckBox( - state: CheckBoxState = CheckBoxState.UNSELECTED, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - theme: BaseUITheme.CheckBoxTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.checkBox - val checkBoxTheme = theme ?: baseTheme - val source = interactionSource ?: remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val isSelected = state == CheckBoxState.SELECTED - val isIndeterminate = state == CheckBoxState.INDETERMINATE - val isFilled = isSelected || isIndeterminate - - val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected - val resolved = resolveCheckBoxState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp - - val borderColor = when { - !enabled -> resolved.colors.foreground.copy(alpha = 0.38f) - else -> resolved.border.color - } - - val checkProgress by animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, - animationSpec = tween(durationMillis = 180), - label = "checkProgress", - ) - val dashProgress by animateFloatAsState( - targetValue = if (isIndeterminate) 1f else 0f, - animationSpec = tween(durationMillis = 150), - label = "dashProgress", - ) - - Box( - modifier = modifier - .size(CheckBoxSize) - .graphicsLayer { translationY = lift.toPx() } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = resolved.shape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(resolved.shape) - .background(resolved.colors.background, resolved.shape) - .border(BorderStroke(0.5.dp, borderColor), resolved.shape) - .highlight(resolved.colors.highlight) - .then( - if (onClick != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - interactionSource = source, - indication = ripple(), - enabled = enabled, - onClick = onClick, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center, - ) { - if (isSelected) { - Box( - modifier = Modifier - .size(14.dp) - .graphicsLayer { alpha = checkProgress }, - ) { - androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) { - val stroke = 2.dp.toPx() - val path = Path().apply { - val w = size.width - val h = size.height - moveTo(w * 0.2f, h * 0.52f) - lineTo(w * 0.42f, h * 0.74f) - lineTo(w * 0.82f, h * 0.28f) - } - drawPath( - path = path, - color = checkBoxTheme.checkmarkColor, - style = Stroke( - width = stroke, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) - } - } - } else if (isIndeterminate) { - Box( - modifier = Modifier - .size(width = 10.dp, height = 2.dp) - .graphicsLayer { alpha = dashProgress } - .clip(RoundedCornerShape(1.dp)) - .background( - brush = Brush.horizontalGradient( - colors = listOf( - checkBoxTheme.checkmarkColor, - checkBoxTheme.checkmarkColor.copy(alpha = 0.92f), - ) - ), - ), - ) - } - } -} - -@Preview -@Composable -fun CheckBoxPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.SELECTED, onClick = {}) - Text("Selected", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.UNSELECTED, onClick = {}) - Text("Unselected", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {}) - Text("Indeterminate", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false) - Text("Disabled", style = MaterialTheme.typography.bodyMedium) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt deleted file mode 100644 index 38963781..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt +++ /dev/null @@ -1,171 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -private val ChipTabMinHeight = 36.dp - -private data class ResolvedChipState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, - val padding: androidx.compose.foundation.layout.PaddingValues, -) - -@Composable -private fun resolveChipState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedChipState { - return when { - isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed) - isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered) - else -> ResolvedChipState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal, style.padding.normal) - } -} - -private fun Modifier.applyChipShadow( - shadow: BaseUITheme.Shadow, - shape: Shape, -): Modifier { - return if (shadow.elevation > 0.dp) { - this.shadow( - elevation = shadow.elevation, - shape = shape, - ambientColor = shadow.ambientColor, - spotColor = shadow.spotColor, - ) - } else { - this - } -} - -@Composable -fun ChipTab( - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ChipTabTheme? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current.chipTab - val chipTheme = theme ?: baseTheme - val style = if (selected) chipTheme.selected else chipTheme.unselected - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveChipState(style, isPressed, isHovered) - - Box( - modifier = modifier - .defaultMinSize(minHeight = ChipTabMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .applyChipShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .highlight(state.colors.highlight) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider( - LocalContentColor provides state.colors.foreground, - LocalTextStyle provides LocalTextStyle.current.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - content = content, - ) - } - } -} - -@Composable -fun ChipTab( - text: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: @Composable (() -> Unit)? = null, - theme: BaseUITheme.ChipTabTheme? = null, -) { - ChipTab( - selected = selected, - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - ) { - if (leadingIcon != null) { - leadingIcon() - } - Text( - text = text, - maxLines = 1, - softWrap = false, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt deleted file mode 100644 index 67dd0abe..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt +++ /dev/null @@ -1,186 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -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.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -private val DefaultDialogMaxWidth: Dp = 440.dp - -@Composable -fun ThemedDialog( - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - title: @Composable (() -> Unit)? = null, - actions: @Composable (() -> Unit)? = null, - theme: BaseUITheme.DialogTheme? = null, - properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), - content: @Composable () -> Unit, -) { - val dialogTheme = theme ?: LocalBaseUITheme.current.dialog - - Dialog( - onDismissRequest = onDismissRequest, - properties = properties, - ) { - AnimatedVisibility( - visible = true, - enter = scaleIn( - initialScale = 0.92f, - animationSpec = tween(280), - ) + fadeIn(animationSpec = tween(200)), - exit = scaleOut( - targetScale = 0.92f, - animationSpec = tween(180), - ) + fadeOut(animationSpec = tween(150)), - ) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .widthIn(max = DefaultDialogMaxWidth) - .shadow( - elevation = dialogTheme.shadow.elevation, - shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, - ambientColor = dialogTheme.shadow.ambientColor, - spotColor = dialogTheme.shadow.spotColor, - ) - .clip(dialogTheme.shape) - .background(dialogTheme.background, dialogTheme.shape) - .border( - BorderStroke(dialogTheme.border.width, dialogTheme.border.color), - dialogTheme.shape - ) - .highlight(Color.White.copy(alpha = 0.08f)), - ) { - Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { - if (title != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp, top = 24.dp), - ) { - title() - } - Spacer(Modifier.height(8.dp)) - } - - Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()) - .padding( - start = 24.dp, - end = 24.dp, - top = if (title == null) 24.dp else 0.dp, - ), - ) { - content() - } - - if (actions != null) { - Spacer(Modifier.height(16.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - actions() - } - } - } else { - Spacer(Modifier.height(24.dp)) - } - } - } - } - } - } -} - -@Preview -@Composable -private fun ThemedDialogPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - var show by remember { mutableStateOf(true) } - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, - ) { - PrimaryButton(onClick = { show = true }) { - Text("Open Dialog") - } - if (show) { - ThemedDialog(onDismissRequest = { show = false }) { - Text( - "This is a themed dialog with glass-like gradient, shadow, and scale animation.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt deleted file mode 100644 index 3ac8cc56..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt +++ /dev/null @@ -1,172 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -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.widthIn -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp - -@Composable -fun DropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 4.dp), - theme: BaseUITheme.DropdownMenuTheme? = null, - content: @Composable ColumnScope.() -> Unit, -) { - val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu - - androidx.compose.material3.DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - offset = offset, - modifier = modifier - .widthIn(min = 180.dp) - .shadow( - elevation = menuTheme.shadow.elevation, - shape = menuTheme.shape, - ambientColor = menuTheme.shadow.ambientColor, - spotColor = menuTheme.shadow.spotColor, - ) - .clip(menuTheme.shape) - .background(menuTheme.background, menuTheme.shape) - .border( - BorderStroke(menuTheme.border.width, menuTheme.border.color), - menuTheme.shape, - ), - ) { - content() - } -} - -@Composable -fun DropdownMenuItem( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: ImageVector? = null, - selected: Boolean = false, - theme: BaseUITheme.DropdownMenuTheme? = null, -) { - val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - - Row( - modifier = modifier - .fillMaxWidth() - .clip(menuTheme.shape) - .hoverable(interactionSource) - .then( - if (isHovered && enabled) { - Modifier.background(menuTheme.itemHoverBackground) - } else { - Modifier - } - ) - .then( - if (isHovered && enabled) { - Modifier.highlight(menuTheme.itemHighlight) - } else { - Modifier - } - ) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - .padding(menuTheme.itemPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (leadingIcon != null) { - Icon( - imageVector = leadingIcon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (enabled) { - menuTheme.itemForeground - } else { - menuTheme.itemForeground.copy(alpha = 0.38f) - }, - ) - } - - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = if (enabled) { - menuTheme.itemForeground - } else { - menuTheme.itemForeground.copy(alpha = 0.38f) - }, - modifier = Modifier.weight(1f), - maxLines = 1, - softWrap = false, - ) - } -} - -@Composable -fun DropdownMenuDivider( - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt deleted file mode 100644 index 4969bc4b..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt +++ /dev/null @@ -1,132 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.unit.dp - -@Composable -fun ListRowTile( - selected: Boolean = false, - enabled: Boolean = true, - onClick: (() -> Unit)? = null, - modifier: Modifier = Modifier, - leading: @Composable (() -> Unit)? = null, - title: @Composable (() -> Unit)? = null, - subtitle: @Composable (() -> Unit)? = null, - trailing: @Composable (() -> Unit)? = null, -) { - val rowTheme = LocalBaseUITheme.current.listRowTile - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val isFocused by interactionSource.collectIsFocusedAsState() - - val background = when { - isPressed -> rowTheme.background.pressed - isHovered -> rowTheme.background.hovered - isFocused -> rowTheme.background.focused - selected -> rowTheme.background.selected - else -> rowTheme.background.normal - } - val shape = when { - isPressed -> rowTheme.shape.pressed - isHovered -> rowTheme.shape.hovered - isFocused -> rowTheme.shape.focused - selected -> rowTheme.shape.selected - else -> rowTheme.shape.normal - } - val borderDef = when { - isPressed -> rowTheme.border.pressed - isHovered -> rowTheme.border.hovered - isFocused -> rowTheme.border.focused - selected -> rowTheme.border.selected - else -> rowTheme.border.normal - } - val shadowDef = when { - isPressed -> rowTheme.shadow.pressed - isHovered -> rowTheme.shadow.hovered - isFocused -> rowTheme.shadow.focused - selected -> rowTheme.shadow.selected - else -> rowTheme.shadow.normal - } - - val mod = modifier - .fillMaxWidth() - .shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor) - .clip(shape) - .border(borderDef.width, borderDef.color, shape) - .background(background, shape) - .then( - if (onClick != null) { - Modifier.clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - } else Modifier - ) - .padding(rowTheme.padding) - - Row( - modifier = mod, - verticalAlignment = Alignment.CenterVertically, - ) { - if (leading != null) { - leading() - Spacer(Modifier.width(12.dp)) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - if (title != null) { - title() - } - if (subtitle != null) { - Spacer(Modifier.width(2.dp)) - subtitle() - } - } - if (trailing != null) { - Spacer(Modifier.width(12.dp)) - trailing() - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt deleted file mode 100644 index 5007db43..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt +++ /dev/null @@ -1,177 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -private val RadioSize = 22.dp -private val RadioDotSize = 10.dp - -private data class ResolvedRadioState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveRadioState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedRadioState { - return when { - isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) - isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered) - else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal) - } -} - -@Composable -fun Radio( - selected: Boolean, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.CheckBoxTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.checkBox - val radioTheme = theme ?: baseTheme - val source = remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val style = if (selected) radioTheme.selected else radioTheme.unselected - val resolved = resolveRadioState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp - - val dotProgress by animateFloatAsState( - targetValue = if (selected) 1f else 0f, - animationSpec = tween(durationMillis = 180), - label = "radioDot", - ) - - Box( - modifier = modifier - .size(RadioSize) - .graphicsLayer { - translationY = lift.toPx() - shape = CircleShape - clip = true - } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = CircleShape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(CircleShape) - .background(resolved.colors.background, CircleShape) - .border(BorderStroke(0.5.dp, resolved.border.color), CircleShape) - .highlight(resolved.colors.highlight) - .then( - if (onClick != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - interactionSource = source, - indication = ripple(bounded = true, radius = RadioSize / 2), - enabled = enabled, - onClick = onClick, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .size(RadioDotSize * dotProgress) - .clip(CircleShape) - .background(radioTheme.checkmarkColor, CircleShape), - ) - } -} - -@Preview -@Composable -private fun RadioPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = true, onClick = {}) - Text("Selected", style = MaterialTheme.typography.bodyMedium) - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = false, onClick = {}) - Text("Unselected", style = MaterialTheme.typography.bodyMedium) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt deleted file mode 100644 index 63de1990..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt +++ /dev/null @@ -1,313 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -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.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import kotlin.math.roundToInt - -private val SliderTrackHeight = 7.dp -private val SliderThumbSize = 24.dp -private val SliderMinHeight = 32.dp - -@Composable -fun Slider( - value: Float, - onValueChange: (Float) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - valueRange: ClosedFloatingPointRange = 0f..1f, - steps: Int = 0, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - onValueChangeFinished: () -> Unit = {}, - theme: BaseUITheme.SliderTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.slider - val sliderTheme = theme ?: baseTheme - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - val density = LocalDensity.current - var sliderWidth by remember { mutableIntStateOf(0) } - - val range = valueRange.endInclusive - valueRange.start - val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f - - val stateColor = when { - isPressed -> sliderTheme.trackActiveColor.pressed - isHovered -> sliderTheme.trackActiveColor.hovered - else -> sliderTheme.trackActiveColor.normal - } - val inactiveColor = when { - isPressed -> sliderTheme.trackInactiveColor.pressed - isHovered -> sliderTheme.trackInactiveColor.hovered - else -> sliderTheme.trackInactiveColor.normal - } - val thumbColor = when { - isPressed -> sliderTheme.thumbColor.pressed - isHovered -> sliderTheme.thumbColor.hovered - else -> sliderTheme.thumbColor.normal - } - val thumbShadowState = when { - isPressed -> sliderTheme.thumbShadow.pressed - isHovered -> sliderTheme.thumbShadow.hovered - else -> sliderTheme.thumbShadow.normal - } - - val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f) - val trackInactiveColor = if (enabled) inactiveColor else inactiveColor.copy(alpha = 0.38f) - val resolvedThumbColor = if (enabled) thumbColor else thumbColor.copy(alpha = 0.38f) - - val thumbScale by animateFloatAsState( - targetValue = when { - isPressed -> 1.15f - isHovered -> 1.08f - else -> 1f - }, - animationSpec = tween(durationMillis = 150), - label = "thumbScale", - ) - - Box( - modifier = modifier - .fillMaxWidth() - .heightIn(min = SliderMinHeight) - .onSizeChanged { sliderWidth = it.width } - .hoverable(interactionSource = interactionSource, enabled = enabled) - .pointerInput(enabled, valueRange, steps) { - if (!enabled) return@pointerInput - - val stepSize = if (steps > 0) range / (steps + 1) else 0f - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - - fun valueFromX(x: Float): Float { - val width = size.width.toFloat() - val trackWidth = width - 2 * thumbHalfPx - if (trackWidth <= 0f) return valueRange.start - val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start - val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) - return if (steps > 0) { - val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() - (valueRange.start + stepIndex * stepSize).coerceIn( - valueRange.start, - valueRange.endInclusive, - ) - } else { - coerced - } - } - - detectTapGestures { offset -> - onValueChange(valueFromX(offset.x)) - onValueChangeFinished() - } - } - .pointerInput(enabled, valueRange, steps) { - if (!enabled) return@pointerInput - - val stepSize = if (steps > 0) range / (steps + 1) else 0f - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - - fun valueFromX(x: Float): Float { - val width = size.width.toFloat() - val trackWidth = width - 2 * thumbHalfPx - if (trackWidth <= 0f) return valueRange.start - val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start - val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) - return if (steps > 0) { - val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() - (valueRange.start + stepIndex * stepSize).coerceIn( - valueRange.start, - valueRange.endInclusive, - ) - } else { - coerced - } - } - - detectHorizontalDragGestures( - onDragEnd = { onValueChangeFinished() }, - ) { change, _ -> - change.consume() - onValueChange(valueFromX(change.position.x)) - } - }, - contentAlignment = Alignment.CenterStart, - ) { - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(SliderTrackHeight) - .align(Alignment.CenterStart), - ) { - val thumbHalfPx = SliderThumbSize.toPx() / 2f - val trackY = size.height / 2f - val trackStart = thumbHalfPx - val trackEnd = size.width - thumbHalfPx - val trackWidth = trackEnd - trackStart - - if (trackWidth > 0f) { - val trackRadius = CornerRadius(size.height / 2f) - - drawRoundRect( - brush = Brush.verticalGradient( - colors = listOf( - trackInactiveColor.copy(alpha = 0.45f), - trackInactiveColor, - trackInactiveColor.copy(alpha = 0.7f), - ), - ), - topLeft = Offset(trackStart, 0f), - size = Size(trackWidth, size.height), - cornerRadius = trackRadius, - ) - drawLine( - color = Color.White.copy(alpha = 0.15f), - start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), - end = Offset(trackEnd - size.height / 2f, 1.dp.toPx()), - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - ) - - val activeEnd = trackStart + trackWidth * fraction - drawRoundRect( - brush = Brush.verticalGradient( - colors = listOf( - trackActiveColor.copy(alpha = 0.75f), - trackActiveColor, - trackActiveColor.copy(alpha = 0.9f), - ), - ), - topLeft = Offset(trackStart, 0f), - size = Size((activeEnd - trackStart).coerceAtLeast(0f), size.height), - cornerRadius = trackRadius, - ) - drawLine( - color = Color.White.copy(alpha = 0.22f), - start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), - end = Offset(activeEnd - size.height / 2f, 1.dp.toPx()), - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - ) - - if (steps > 0 && enabled) { - val stepSpacing = trackWidth / (steps + 1) - for (i in 1 downTo steps) { - val x = trackStart + stepSpacing * i - drawCircle( - color = if (x <= activeEnd) { - sliderTheme.stepActiveColor - } else { - sliderTheme.stepInactiveColor - }, - radius = 2.dp.toPx(), - center = Offset(x, trackY), - ) - } - } - } - } - - Box( - modifier = Modifier - .size(SliderThumbSize) - .offset { - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - val trackWidth = sliderWidth.toFloat() - 2 * thumbHalfPx - val x = thumbHalfPx + trackWidth * fraction - thumbHalfPx - IntOffset(x.roundToInt(), 0) - } - .graphicsLayer { - scaleX = thumbScale - scaleY = thumbScale - } - .shadow( - elevation = thumbShadowState.elevation, - shape = CircleShape, - ambientColor = thumbShadowState.ambientColor, - spotColor = thumbShadowState.spotColor, - ) - .background( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.6f), - resolvedThumbColor.copy(alpha = 0.95f), - resolvedThumbColor, - ), - center = Offset(0f, -0.55f), - radius = 1.1f, - ), - shape = CircleShape, - ) - .border( - width = 0.8.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.08f), - ), - ), - shape = CircleShape, - ), - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt deleted file mode 100644 index 0c016aba..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt +++ /dev/null @@ -1,243 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash - -private val TextFieldMinHeight = 44.dp - -private data class ResolvedTextFieldState( - val background: Brush, - val highlight: Color, - val shape: Shape, - val border: BaseUITheme.Border, - val shadow: BaseUITheme.Shadow, - val foreground: Color, -) - -@Composable -private fun resolveTextFieldState( - theme: BaseUITheme.TextFieldTheme, - isFocused: Boolean, - isHovered: Boolean, -): ResolvedTextFieldState { - return when { - isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused) - isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered) - else -> ResolvedTextFieldState(theme.background.normal, theme.highlight.normal, theme.shape.normal, theme.border.normal, theme.shadow.normal, theme.foreground.normal) - } -} - -@Composable -fun TextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - label: @Composable (() -> Unit)? = null, - singleLine: Boolean = false, - maxLines: Int = Int.MAX_VALUE, - enabled: Boolean = true, - readOnly: Boolean = false, - isError: Boolean = false, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - visualTransformation: VisualTransformation = VisualTransformation.None, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - textStyle: TextStyle = TextStyle.Default, - cursorBrush: Color? = null, - theme: BaseUITheme.TextFieldTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.textField - val textFieldTheme = theme ?: baseTheme - val isFocused by interactionSource.collectIsFocusedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - - val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered) - - val resolvedBorder = if (isError) { - BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width) - } else { - state.border - } - - val contentColor = when { - !enabled -> state.foreground.copy(alpha = 0.38f) - else -> state.foreground - } - - val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor - - Column(modifier = modifier) { - AnimatedVisibility( - visible = label != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - label?.invoke() - } - - Box( - modifier = Modifier - .defaultMinSize(minHeight = TextFieldMinHeight) - .then( - if (state.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = state.shadow.elevation, - shape = state.shape, - ambientColor = state.shadow.ambientColor, - spotColor = state.shadow.spotColor, - ) - } else Modifier - ) - .clip(state.shape) - .background(state.background, state.shape) - .border(BorderStroke(resolvedBorder.width, resolvedBorder.color), state.shape) - .highlight(state.highlight) - .padding(textFieldTheme.padding), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (leadingIcon != null) { - leadingIcon() - } - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .hoverable(interactionSource = interactionSource, enabled = enabled), - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle.copy(color = contentColor), - cursorBrush = cursor, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - visualTransformation = visualTransformation, - interactionSource = interactionSource, - decorationBox = { innerTextField -> - if (value.isEmpty() && placeholder != null && !isFocused) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Box(contentAlignment = Alignment.CenterStart) { - placeholder() - } - } - } else { - innerTextField() - } - }, - ) - } - - if (trailingIcon != null) { - trailingIcon() - } - } - } - } -} - -@Preview -@Composable -fun TextFieldPreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - TextField( - modifier = Modifier.padding(top = 4.dp), - value = "Twenty One Pilots", - onValueChange = {}, - placeholder = { Text("Placeholder") }, - label = { - Text( - "Search Field", - modifier = Modifier.padding(bottom = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - }, - leadingIcon = { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search Icon", - ) - }, - trailingIcon = { - androidx.compose.material3.Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Clear Icon", - ) - }, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt deleted file mode 100644 index e195ffc2..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ /dev/null @@ -1,1105 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.AutoCompleteMenuTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.CheckBoxTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.ChipTabTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.SliderTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.TextFieldTheme - -@Stable -data class BaseUITheme( - val buttons: ButtonVariants, - val iconButtons: ButtonVariants, - val textField: TextFieldTheme, - val autoCompleteMenuTheme: AutoCompleteMenuTheme, - val slider: SliderTheme, - val checkBox: CheckBoxTheme, - val chipTab: ChipTabTheme, - val toggle: ToggleTheme, - val card: CardTheme, - val listRowTile: ListRowTheme, - val dialog: DialogTheme, - val dropdownMenu: DropdownMenuTheme, -) { - - @Stable - data class InteractionState( - val normal: T, - val hovered: T, - val pressed: T, - val focused: T, - ) { - companion object { - fun fromSingleValue(value: T): InteractionState { - return InteractionState(value, value, value, value) - } - } - } - - @Stable - data class AdvancedInteractionState( - val normal: T, - val hovered: T, - val pressed: T, - val focused: T, - val selected: T, - val disabled: T, - ) { - companion object { - fun fromSingleValue(value: T): AdvancedInteractionState { - return AdvancedInteractionState(value, value, value, value, value, value) - } - } - } - - @Stable - data class Border( - val color: Color, - val width: Dp, - ) - - @Stable - data class Shadow( - val elevation: Dp, - val clip: Boolean = elevation > 0.dp, - val ambientColor: Color, - val spotColor: Color, - ) - - @Stable - data class ButtonVariants( - val primary: ButtonStyle, - val secondary: ButtonStyle, - val outline: ButtonStyle, - val ghost: ButtonStyle, - ) - - @Stable - data class ButtonStyle( - val colors: InteractionState, - val shape: InteractionState, - val shadow: InteractionState, - val border: InteractionState, - val padding: InteractionState, - ) - - @Stable - data class ButtonColors( - val background: Brush, - val foreground: Color, - val highlight: Color, - ) - - data class CardTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val padding: PaddingValues, - val shadow: Shadow, - ) - - data class ListRowTheme( - val background: AdvancedInteractionState, - val shape: AdvancedInteractionState, - val border: AdvancedInteractionState, - val shadow: AdvancedInteractionState, - val padding: PaddingValues, - val textStyle: AdvancedInteractionState, - val foreground: AdvancedInteractionState, - ) - - data class TextFieldTheme( - val background: InteractionState, - val highlight: InteractionState, - val shape: InteractionState, - val border: InteractionState, - val shadow: InteractionState, - val padding: PaddingValues, - val textStyle: TextStyle, - val cursor: Brush, - val foreground: InteractionState, - ) - - data class AutoCompleteMenuTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val padding: PaddingValues, - val shadowElevation: Dp, - ) - - data class DialogTheme( - val background: Brush, - val scrim: Color, - val shape: Shape, - val border: Border, - val shadow: Shadow, - val padding: PaddingValues, - ) - - data class DropdownMenuTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val shadow: Shadow, - val itemHoverBackground: Brush, - val itemForeground: Color, - val itemHighlight: Color, - val padding: PaddingValues, - val itemPadding: PaddingValues, - ) - - data class SliderTheme( - val trackActiveColor: InteractionState, - val trackInactiveColor: InteractionState, - val thumbColor: InteractionState, - val thumbShadow: InteractionState, - val stepActiveColor: Color, - val stepInactiveColor: Color, - ) - - data class CheckBoxTheme( - val selected: ButtonStyle, - val unselected: ButtonStyle, - val checkmarkColor: Color, - ) - - data class ChipTabTheme( - val selected: ButtonStyle, - val unselected: ButtonStyle, - ) - - data class ToggleTheme( - val checked: ButtonStyle, - val unchecked: ButtonStyle, - val thumb: InteractionState, - val thumbShadow: InteractionState, - ) -} - -val LocalBaseUITheme = staticCompositionLocalOf { - throw IllegalStateException("No BaseUITheme provided") -} - -@Composable -fun rememberBaseUITheme(): BaseUITheme { - val scheme = MaterialTheme.colorScheme - val isLight = scheme.surface.luminance() > 0.5f - - val containerLighter = if (isLight) Color.White else scheme.surfaceContainerHigh - val containerDarker = if (isLight) Color(0xFFF2F2F4) else scheme.surfaceContainer - val containerPressed = if (isLight) Color(0xFFE0E0E3) else scheme.surfaceContainerHighest - val highlight = if (isLight) Color.White.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.06f) - val shadowColor = scheme.onSurface - val border = scheme.outlineVariant - - val secondaryContainer = scheme.secondaryContainer - val secondaryContainerPressed = secondaryContainer.copy(alpha = if (isLight) 0.85f else 0.92f) - val secondaryHighlight = - if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.08f) - - val defaultShape = RoundedCornerShape(14.dp) - val defaultShapeState = - BaseUITheme.InteractionState(defaultShape, defaultShape, defaultShape, defaultShape) - val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp) - val noBorderState = BaseUITheme.InteractionState(noBorder, noBorder, noBorder, noBorder) - val noShadow = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent) - val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow, noShadow) - val defaultButtonPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 20.dp, vertical = 10.dp), - PaddingValues(horizontal = 20.dp, vertical = 10.dp), - PaddingValues(horizontal = 20.dp, vertical = 10.dp), - PaddingValues(horizontal = 20.dp, vertical = 10.dp), - ) - val iconButtonPadding = BaseUITheme.InteractionState( - PaddingValues(8.dp), - PaddingValues(8.dp), - PaddingValues(8.dp), - PaddingValues(8.dp), - ) - - val outlineColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerPressed, containerPressed)), - foreground = scheme.onSurface, - highlight = highlight, - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - ) - val outlineShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 9.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) - ), - focused = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - ) - val outlineBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(border, 0.5.dp), - hovered = BaseUITheme.Border(border, 0.5.dp), - pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp), - focused = BaseUITheme.Border(border, 0.5.dp), - ) - - val primaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - scheme.primary.copy(alpha = 0.85f), - scheme.primary - ) - ), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - ) - val primaryShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) - ), - hovered = BaseUITheme.Shadow( - 9.dp, - true, - scheme.primary.copy(alpha = 0.45f), - scheme.primary.copy(alpha = 0.5f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) - ), - focused = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) - ), - ) - val primaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(scheme.primary, 0.5.dp), - hovered = BaseUITheme.Border(scheme.primary, 0.5.dp), - pressed = BaseUITheme.Border(scheme.primary, 0.5.dp), - focused = BaseUITheme.Border(scheme.primary, 0.5.dp), - ) - - val secondaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainer, - secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f) - ) - ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainer, - secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f) - ) - ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainerPressed, - secondaryContainerPressed - ) - ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - secondaryContainer, - secondaryContainer.copy(alpha = if (secondaryContainer.luminance() > 0.5f) 0.92f else 1f) - ) - ), - foreground = scheme.onSecondaryContainer, - highlight = secondaryHighlight, - ), - ) - val secondaryShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 9.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) - ), - focused = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - ) - val secondaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), - hovered = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), - pressed = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), - focused = BaseUITheme.Border(secondaryContainer.copy(alpha = 0.5f), 0.5.dp), - ) - - val ghostColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), - foreground = scheme.onSurface, - highlight = Color.Transparent, - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - containerPressed.copy(alpha = 0.5f), - containerPressed.copy(alpha = 0.5f) - ) - ), - foreground = scheme.onSurface, - highlight = Color.Transparent, - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerPressed, containerPressed)), - foreground = scheme.onSurface, - highlight = Color.Transparent, - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), - foreground = scheme.onSurface, - highlight = Color.Transparent, - ), - ) - - val outlineStyle = BaseUITheme.ButtonStyle( - outlineColors, - defaultShapeState, - outlineShadow, - outlineBorder, - defaultButtonPadding - ) - val primaryStyle = BaseUITheme.ButtonStyle( - primaryColors, - defaultShapeState, - primaryShadow, - primaryBorder, - defaultButtonPadding - ) - val secondaryStyle = BaseUITheme.ButtonStyle( - secondaryColors, - defaultShapeState, - secondaryShadow, - secondaryBorder, - defaultButtonPadding - ) - val ghostStyle = BaseUITheme.ButtonStyle( - ghostColors, - defaultShapeState, - noShadowState, - noBorderState, - defaultButtonPadding - ) - - val outlineIconStyle = BaseUITheme.ButtonStyle( - outlineColors, - defaultShapeState, - outlineShadow, - outlineBorder, - iconButtonPadding - ) - val primaryIconStyle = BaseUITheme.ButtonStyle( - primaryColors, - defaultShapeState, - primaryShadow, - primaryBorder, - iconButtonPadding - ) - val secondaryIconStyle = BaseUITheme.ButtonStyle( - secondaryColors, - defaultShapeState, - secondaryShadow, - secondaryBorder, - iconButtonPadding - ) - val ghostIconStyle = BaseUITheme.ButtonStyle( - ghostColors, - defaultShapeState, - noShadowState, - noBorderState, - iconButtonPadding - ) - - val textFieldForeground = BaseUITheme.InteractionState( - normal = scheme.onSurface, - hovered = scheme.onSurface, - pressed = scheme.onSurface, - focused = scheme.onSurface, - ) - - val checkBoxShape = RoundedCornerShape(6.dp) - val checkBoxShapeState = - BaseUITheme.InteractionState(checkBoxShape, checkBoxShape, checkBoxShape, checkBoxShape) - val noPadding = - BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp)) - - val chipTabShape = RoundedCornerShape(10.dp) - val chipTabShapeState = - BaseUITheme.InteractionState(chipTabShape, chipTabShape, chipTabShape, chipTabShape) - val chipTabPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 14.dp, vertical = 4.dp), - PaddingValues(horizontal = 14.dp, vertical = 4.dp), - PaddingValues(horizontal = 14.dp, vertical = 4.dp), - PaddingValues(horizontal = 14.dp, vertical = 4.dp), - ) - - val toggleShape = RoundedCornerShape(50) - val toggleShapeState = - BaseUITheme.InteractionState(toggleShape, toggleShape, toggleShape, toggleShape) - val togglePadding = BaseUITheme.InteractionState( - PaddingValues(2.dp), - PaddingValues(2.dp), - PaddingValues(2.dp), - PaddingValues(2.dp), - ) - - return BaseUITheme( - buttons = BaseUITheme.ButtonVariants( - primary = primaryStyle, - secondary = secondaryStyle, - outline = outlineStyle, - ghost = ghostStyle, - ), - iconButtons = BaseUITheme.ButtonVariants( - primary = primaryIconStyle, - secondary = secondaryIconStyle, - outline = outlineIconStyle, - ghost = ghostIconStyle, - ), - textField = TextFieldTheme( - background = BaseUITheme.InteractionState( - normal = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - pressed = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - ), - highlight = BaseUITheme.InteractionState( - normal = highlight, - hovered = highlight, - pressed = highlight, - focused = highlight, - ), - shape = BaseUITheme.InteractionState( - normal = defaultShape, - hovered = defaultShape, - pressed = defaultShape, - focused = defaultShape, - ), - border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp), - hovered = BaseUITheme.Border(border.copy(alpha = 0.85f), 0.5.dp), - pressed = BaseUITheme.Border(border, 0.5.dp), - focused = BaseUITheme.Border(scheme.primary, 0.5.dp), - ), - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 9.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - hovered = BaseUITheme.Shadow( - 9.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - pressed = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - focused = BaseUITheme.Shadow( - 9.dp, - true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) - ), - ), - padding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), - textStyle = TextStyle.Default, - cursor = SolidColor(scheme.primary), - foreground = textFieldForeground, - ), - autoCompleteMenuTheme = AutoCompleteMenuTheme( - background = Brush.verticalGradient( - listOf( - scheme.surfaceContainerHigh, - scheme.surfaceContainer - ) - ), - shape = RoundedCornerShape(12.dp), - border = BaseUITheme.Border(border, 0.5.dp), - padding = PaddingValues(0.dp), - shadowElevation = 8.dp, - ), - slider = SliderTheme( - trackActiveColor = BaseUITheme.InteractionState( - normal = scheme.primary, - hovered = scheme.primary, - pressed = scheme.primary, - focused = scheme.primary, - ), - trackInactiveColor = BaseUITheme.InteractionState( - normal = border.copy(alpha = 0.6f), - hovered = border.copy(alpha = 0.6f), - pressed = border.copy(alpha = 0.6f), - focused = border.copy(alpha = 0.6f), - ), - thumbColor = BaseUITheme.InteractionState( - normal = scheme.primary, - hovered = scheme.primary, - pressed = scheme.primary, - focused = scheme.primary, - ), - thumbShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 5.dp, - true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) - ), - hovered = BaseUITheme.Shadow( - 8.dp, - true, - scheme.primary.copy(alpha = 0.4f), - scheme.primary.copy(alpha = 0.5f) - ), - pressed = BaseUITheme.Shadow( - 2.dp, - true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) - ), - focused = BaseUITheme.Shadow( - 5.dp, - true, - scheme.primary.copy(alpha = 0.35f), - scheme.primary.copy(alpha = 0.4f) - ), - ), - stepActiveColor = scheme.onPrimary.copy(alpha = 0.5f), - stepInactiveColor = scheme.onSurface.copy(alpha = 0.25f), - ), - checkBox = CheckBoxTheme( - selected = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = checkBoxShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - hovered = BaseUITheme.Shadow( - 8.dp, - true, - scheme.primary.copy(alpha = 0.4f), - scheme.primary.copy(alpha = 0.45f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) - ), - focused = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - ), - border = primaryBorder, - padding = noPadding, - ), - unselected = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = checkBoxShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 5.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.24f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) - ), - focused = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - ), - border = outlineBorder, - padding = noPadding, - ), - checkmarkColor = scheme.onPrimary, - ), - chipTab = ChipTabTheme( - selected = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = chipTabShapeState, - shadow = primaryShadow, - border = primaryBorder, - padding = chipTabPadding, - ), - unselected = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = chipTabShapeState, - shadow = outlineShadow, - border = outlineBorder, - padding = chipTabPadding, - ), - ), - toggle = BaseUITheme.ToggleTheme( - checked = BaseUITheme.ButtonStyle( - colors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - scheme.primary.copy(alpha = 0.85f), - scheme.primary - ) - ), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)), - foreground = scheme.onPrimary, - highlight = Color.White.copy(alpha = 0.25f), - ), - ), - shape = toggleShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 4.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - hovered = BaseUITheme.Shadow( - 6.dp, - true, - scheme.primary.copy(alpha = 0.4f), - scheme.primary.copy(alpha = 0.45f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - scheme.primary.copy(alpha = 0.2f), - scheme.primary.copy(alpha = 0.25f) - ), - focused = BaseUITheme.Shadow( - 4.dp, - true, - scheme.primary.copy(alpha = 0.3f), - scheme.primary.copy(alpha = 0.35f) - ), - ), - border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(scheme.primary, 0.5.dp), - hovered = BaseUITheme.Border(scheme.primary, 0.5.dp), - pressed = BaseUITheme.Border(scheme.primary, 0.5.dp), - focused = BaseUITheme.Border(scheme.primary, 0.5.dp), - ), - padding = togglePadding, - ), - unchecked = BaseUITheme.ButtonStyle( - colors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - hovered = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - pressed = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerPressed, containerPressed)), - foreground = scheme.onSurface, - highlight = highlight, - ), - focused = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - foreground = scheme.onSurface, - highlight = highlight, - ), - ), - shape = toggleShapeState, - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.08f), - shadowColor.copy(alpha = 0.1f) - ), - focused = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - ), - border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(border, 0.5.dp), - hovered = BaseUITheme.Border(border, 0.5.dp), - pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp), - focused = BaseUITheme.Border(border, 0.5.dp), - ), - padding = togglePadding, - ), - thumb = BaseUITheme.InteractionState( - normal = Color.White, - hovered = Color.White, - pressed = Color.White, - focused = Color.White, - ), - thumbShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - hovered = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.28f), - shadowColor.copy(alpha = 0.32f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.12f), - shadowColor.copy(alpha = 0.15f) - ), - focused = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - ), - ), - card = BaseUITheme.CardTheme( - background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(border, 0.5.dp), - padding = PaddingValues(0.dp), - shadow = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.15f), - shadowColor.copy(alpha = 0.18f) - ), - ), - listRowTile = BaseUITheme.ListRowTheme( - background = BaseUITheme.AdvancedInteractionState( - normal = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), - hovered = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - pressed = Brush.verticalGradient(listOf(containerPressed, containerPressed)), - focused = Brush.verticalGradient(listOf(containerLighter, containerDarker)), - selected = Brush.verticalGradient( - listOf( - scheme.primaryContainer.copy(alpha = 0.3f), - scheme.primaryContainer.copy(alpha = 0.3f) - ) - ), - disabled = Brush.verticalGradient( - listOf( - containerLighter.copy(alpha = 0.5f), - containerDarker.copy(alpha = 0.5f) - ) - ), - ), - shape = BaseUITheme.AdvancedInteractionState( - normal = RoundedCornerShape(8.dp), - hovered = RoundedCornerShape(8.dp), - pressed = RoundedCornerShape(8.dp), - focused = RoundedCornerShape(8.dp), - selected = RoundedCornerShape(8.dp), - disabled = RoundedCornerShape(8.dp), - ), - border = BaseUITheme.AdvancedInteractionState( - normal = BaseUITheme.Border(Color.Transparent, 0.dp), - hovered = BaseUITheme.Border(Color.Transparent, 0.dp), - pressed = BaseUITheme.Border(Color.Transparent, 0.dp), - focused = BaseUITheme.Border(Color.Transparent, 0.dp), - selected = BaseUITheme.Border(Color.Transparent, 0.dp), - disabled = BaseUITheme.Border(Color.Transparent, 0.dp), - ), - shadow = BaseUITheme.AdvancedInteractionState( - normal = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - hovered = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - pressed = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - selected = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - disabled = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent), - ), - padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), - textStyle = BaseUITheme.AdvancedInteractionState( - normal = TextStyle.Default, - hovered = TextStyle.Default, - pressed = TextStyle.Default, - focused = TextStyle.Default, - selected = TextStyle.Default, - disabled = TextStyle.Default, - ), - foreground = BaseUITheme.AdvancedInteractionState( - normal = scheme.onSurface, - hovered = scheme.onSurface, - pressed = scheme.onSurface, - focused = scheme.onSurface, - selected = scheme.onSurface, - disabled = scheme.onSurface.copy(alpha = 0.38f), - ), - ), - dialog = BaseUITheme.DialogTheme( - background = Brush.verticalGradient( - listOf( - scheme.surface, - scheme.surfaceContainerHigh, - ) - ), - scrim = Color.Black.copy(alpha = 0.42f), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(border, 0.5.dp), - shadow = BaseUITheme.Shadow( - 24.dp, - true, - shadowColor.copy(alpha = 0.3f), - shadowColor.copy(alpha = 0.4f) - ), - padding = PaddingValues(24.dp), - ), - dropdownMenu = BaseUITheme.DropdownMenuTheme( - background = Brush.verticalGradient( - listOf( - scheme.surfaceContainerHigh, - scheme.surfaceContainer, - ) - ), - shape = RoundedCornerShape(12.dp), - border = BaseUITheme.Border(border, 0.5.dp), - shadow = BaseUITheme.Shadow( - 12.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - itemHoverBackground = Brush.verticalGradient( - listOf(containerLighter, containerDarker) - ), - itemForeground = scheme.onSurface, - itemHighlight = highlight, - padding = PaddingValues(4.dp), - itemPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), - ), - ) -} - -// Helper functions for easier theme overrides - -fun BaseUITheme.ButtonStyle.copyShape(shape: Shape): BaseUITheme.ButtonStyle = - copy(shape = BaseUITheme.InteractionState.fromSingleValue(shape)) - -fun BaseUITheme.ButtonStyle.copyShadow(shadow: BaseUITheme.Shadow): BaseUITheme.ButtonStyle = - copy(shadow = BaseUITheme.InteractionState.fromSingleValue(shadow)) - -fun BaseUITheme.ButtonStyle.copyBorder(border: BaseUITheme.Border): BaseUITheme.ButtonStyle = - copy(border = BaseUITheme.InteractionState.fromSingleValue(border)) - -fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.ButtonStyle = - copy(padding = BaseUITheme.InteractionState.fromSingleValue(padding)) - -fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle = - copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors)) - -@Composable -fun invertedButtonStyle(): BaseUITheme.ButtonStyle { - val scheme = MaterialTheme.colorScheme - val isLight = scheme.surface.luminance() > 0.5f - val shadowColor = scheme.onSurface - - val invertedColors = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.onSurface, scheme.onSurface)), - foreground = scheme.surface, - highlight = if (isLight) Color.White.copy(alpha = 0.15f) else Color.White.copy(alpha = 0.08f), - ) - val pressedColors = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - scheme.onSurfaceVariant, - scheme.onSurfaceVariant - ) - ), - foreground = scheme.surface, - highlight = if (isLight) Color.White.copy(alpha = 0.1f) else Color.White.copy(alpha = 0.05f), - ) - val border = BaseUITheme.Border(scheme.onSurfaceVariant, 0.5.dp) - val shadow = BaseUITheme.Shadow( - elevation = 6.dp, - clip = true, - ambientColor = shadowColor.copy(alpha = 0.2f), - spotColor = shadowColor.copy(alpha = 0.25f), - ) - - return BaseUITheme.ButtonStyle( - colors = BaseUITheme.InteractionState( - normal = invertedColors, - hovered = invertedColors, - pressed = pressedColors, - focused = invertedColors, - ), - shape = BaseUITheme.InteractionState.fromSingleValue(RoundedCornerShape(14.dp)), - shadow = BaseUITheme.InteractionState.fromSingleValue(shadow), - border = BaseUITheme.InteractionState.fromSingleValue(border), - padding = BaseUITheme.InteractionState.fromSingleValue(PaddingValues(8.dp)), - ) -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt deleted file mode 100644 index 446a3fe9..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt +++ /dev/null @@ -1,264 +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 . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max -import kotlin.math.roundToInt - -private val ToggleWidth = 44.dp -private val ToggleHeight = 24.dp -private val ThumbSize = 20.dp - -private data class ResolvedToggleState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveToggleState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedToggleState { - return when { - isPressed -> ResolvedToggleState( - style.colors.pressed, - style.shape.pressed, - style.shadow.pressed, - style.border.pressed, - ) - - isHovered -> ResolvedToggleState( - style.colors.hovered, - style.shape.hovered, - style.shadow.hovered, - style.border.hovered, - ) - - else -> ResolvedToggleState( - style.colors.normal, - style.shape.normal, - style.shadow.normal, - style.border.normal, - ) - } -} - -@Composable -fun Toggle( - checked: Boolean, - onCheckedChange: ((Boolean) -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - theme: BaseUITheme.ToggleTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.toggle - val toggleTheme = theme ?: baseTheme - val source = interactionSource ?: remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val style = if (checked) toggleTheme.checked else toggleTheme.unchecked - val resolved = resolveToggleState(style, isPressed, isHovered) - - val thumbColor = when { - isPressed -> toggleTheme.thumb.pressed - isHovered -> toggleTheme.thumb.hovered - else -> toggleTheme.thumb.normal - } - - val thumbShadowState = when { - isPressed -> toggleTheme.thumbShadow.pressed - isHovered -> toggleTheme.thumbShadow.hovered - else -> toggleTheme.thumbShadow.normal - } - - val borderColor = when { - !enabled -> resolved.colors.foreground.copy(alpha = 0.38f) - else -> resolved.border.color - } - - val thumbProgress by animateFloatAsState( - targetValue = if (checked) 1f else 0f, - animationSpec = tween(durationMillis = 200), - label = "thumbProgress", - ) - - val thumbScale by animateFloatAsState( - targetValue = when { - isPressed -> 0.92f - isHovered -> 1.05f - else -> 1f - }, - animationSpec = tween(durationMillis = 150), - label = "thumbScale", - ) - - val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp - val density = LocalDensity.current - val thumbOffsetXPx by animateDpAsState( - targetValue = with(density) { - val trackWidth = ToggleWidth - ThumbSize - max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp) - }, - animationSpec = tween(durationMillis = 200), - label = "thumbOffsetX", - ) - - Box( - modifier = modifier - .width(ToggleWidth) - .height(ToggleHeight) - .graphicsLayer { - translationY = lift.toPx() - } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = resolved.shape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(resolved.shape) - .background(resolved.colors.background, resolved.shape) - .border(BorderStroke(resolved.border.width, borderColor), resolved.shape) - .highlight(resolved.colors.highlight) - .then( - if (onCheckedChange != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = source, - indication = null, - onClick = { onCheckedChange(!checked) }, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.CenterStart, - ) { - Box( - modifier = Modifier - .offset(x = thumbOffsetXPx) - .size(ThumbSize) - .graphicsLayer { - scaleX = thumbScale - scaleY = thumbScale - } - .shadow( - elevation = thumbShadowState.elevation, - shape = CircleShape, - ambientColor = thumbShadowState.ambientColor, - spotColor = thumbShadowState.spotColor, - ) - .background( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.6f), - thumbColor.copy(alpha = 0.95f), - thumbColor, - ), - center = Offset(0f, -0.55f), - radius = 1.1f, - ), - shape = CircleShape, - ) - .border( - width = 0.8.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.08f), - ), - ), - shape = CircleShape, - ), - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun TogglePreview() { - MaterialTheme { - val theme = rememberBaseUITheme() - CompositionLocalProvider(LocalBaseUITheme provides theme) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(24.dp), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Toggle(checked = true, onCheckedChange = {}) - Toggle(checked = false, onCheckedChange = {}) - Toggle(checked = true, onCheckedChange = null) - Toggle(checked = false, onCheckedChange = null, enabled = false) - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt deleted file mode 100644 index fc8ce272..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color - -fun Modifier.highlight(color: Color): Modifier = - drawWithCache { - val highlightBrush = Brush.verticalGradient( - colors = listOf(color, Color.Transparent), - startY = 0f, - endY = size.height * 0.5f, - ) - onDrawWithContent { - drawContent() - drawRect( - brush = highlightBrush, - topLeft = androidx.compose.ui.geometry.Offset.Zero, - size = size, - ) - } - } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt deleted file mode 100644 index 07131282..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ /dev/null @@ -1,124 +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 . - */ - -package dev.krtirtho.spotube.core.ui.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.resources.iconsax.FluentDismiss -import dev.krtirtho.spotube.resources.iconsax.Iconsax - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdaptiveDialogBottomSheet( - onDismiss: () -> Unit, - title: @Composable (() -> Unit)? = null, - modifier: Modifier = Modifier, - breakpointDp: Float = 600f, - actions: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp - - if (isLargeScreen) { - ThemedDialog( - onDismissRequest = onDismiss, - title = if (title != null) { - { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Box(Modifier.weight(1f)) { title() } - IconButton(onClick = onDismiss) { - Icon( - Iconsax.FluentDismiss, - contentDescription = "Close dialog", - ) - } - } - } - } else { - null - }, - actions = actions, - modifier = modifier, - ) { - content() - } - } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - modifier = modifier, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 16.dp), - horizontalAlignment = Alignment.Start, - ) { - title?.let { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Box(Modifier.weight(1f)) { it() } - IconButton(onClick = onDismiss) { - Icon( - Iconsax.FluentDismiss, - contentDescription = "Close dialog", - ) - } - } - Spacer(Modifier.height(12.dp)) - } - content() - actions?.let { - Spacer(Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - Spacer(Modifier.height(8.dp)) - it() - } - } - Spacer(Modifier.height(24.dp)) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt deleted file mode 100644 index b87433f7..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ /dev/null @@ -1,360 +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 . - */ - -package dev.krtirtho.spotube.core.ui.component - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -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.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.Check -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.DropdownMenu -import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem -import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash - -data class AdaptiveMenuItem( - val icon: ImageVector? = null, - val label: String, - val onClick: () -> Unit, - val enabled: Boolean = true, - val selected: Boolean = false, - val dividerBefore: Boolean = false, -) - -enum class HeaderDisplayMode { - Always, - OnlyInDropdown, - OnlyInBottomSheet, -} - -@Composable -fun AdaptiveDropdownBottomSheet( - items: List, - trigger: @Composable (onClick: () -> Unit) -> Unit, - modifier: Modifier = Modifier, - breakpointDp: Float = 600f, - menuMinWidth: Dp = 200.dp, - header: @Composable (() -> Unit)? = null, - headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { - var expanded by remember { mutableStateOf(false) } - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp - - Box(modifier = modifier) { - trigger { expanded = true } - - if (isLargeScreen) { - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { - header() - } - - var query by remember { mutableStateOf("") } - if (filter != null) { - TextField( - value = query, - onValueChange = { - query = it - }, - placeholder = { Text("Search...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - - items.forEach { item -> - if (query.isNotBlank() && filter != null && !filter(item, query)) { - return@forEach - } - if (item.dividerBefore) { - DropdownMenuDivider() - } - DropdownMenuItem( - text = item.label, - onClick = { - item.onClick() - expanded = false - }, - enabled = item.enabled, - selected = item.selected, - leadingIcon = item.icon, - ) - } - } - } else { - if (expanded) { - AdaptiveBottomSheetContent( - onDismiss = { expanded = false }, - items = items, - header = if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInBottomSheet)) { - header - } else { - null - }, - filter = filter, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AdaptiveBottomSheetContent( - onDismiss: () -> Unit, - items: List, - header: @Composable (() -> Unit)?, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { - val hasSelection = items.any { it.selected } - var query by remember { mutableStateOf("") } - val filteredItems = if (filter != null && query.isNotBlank()) { - items.filter { filter(it, query) } - } else { - items - } - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - header?.invoke() - - if (filter != null) { - Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = query, - onValueChange = { - query = it - }, - placeholder = { Text("Search...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - items( - count = filteredItems.size, - ) { index -> - val item = filteredItems[index] - - if (item.dividerBefore) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .clickable( - enabled = item.enabled, - onClick = { - item.onClick() - onDismiss() - }, - ) - .background( - color = if (item.selected) { - MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, - ) - .padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (item.icon != null) { - Icon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } else if (item.selected) { - Icon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else if (hasSelection) { - Spacer(modifier = Modifier.size(22.dp)) - } - Text( - text = item.label, - style = MaterialTheme.typography.bodyLarge, - color = if (item.enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } - - if (item.selected && item.icon != null) { - Icon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } -} - -@Preview -@Composable -private fun AdaptiveDropdownBottomSheetPreview() { - Scaffold { - AdaptiveDropdownBottomSheet( - items = listOf( - AdaptiveMenuItem( - icon = Iconsax.IconsaxHeart, - label = "Favorite", - onClick = {}, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxShare, - label = "Share", - onClick = {}, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxTrash, - label = "Delete", - onClick = {}, - enabled = false, - ), - ), - trigger = { onClick -> - IconButton( - onClick = onClick, - modifier = Modifier - .size(32.dp) - .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - shape = CircleShape, - ) - .clip(CircleShape), - ) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt deleted file mode 100644 index 4cb5922e..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt +++ /dev/null @@ -1,70 +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 . - */ - -package dev.krtirtho.spotube.core.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -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.shape.CircleShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.Alignment -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard -import org.koin.compose.koinInject - -@Composable -fun ArtistCard( - artist: MetadataArtist.Basic, - modifier: Modifier = Modifier, - isBlacklisted: Boolean = false, -) { - val navigationCommands = koinInject() - - AvatarCard( - title = artist.name, - subtitle = if (isBlacklisted) "Blacklisted" else "Artist", - imageURL = artist.thumbnails.firstOrNull()?.url, - onClick = { - navigationCommands.navigateTo(Routes.Artist(artist.id)) - }, - modifier = modifier, - isBlacklisted = isBlacklisted, - ) -} - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt deleted file mode 100644 index 585ba1d5..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ /dev/null @@ -1,287 +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 . - */ - -package dev.krtirtho.spotube.core.ui.component - -import androidx.compose.foundation.background -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.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.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -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.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.AlertCircle -import compose.icons.feathericons.Copy -import compose.icons.feathericons.Maximize2 -import compose.icons.feathericons.RefreshCw - -@Composable -fun ErrorDisplay( - errorMessage: String, - onRetry: () -> Unit, - modifier: Modifier = Modifier, -) { - var showFullLog by remember { mutableStateOf(false) } - val clipboardManager = LocalClipboardManager.current - - Column( - modifier = modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - Icon( - imageVector = FeatherIcons.AlertCircle, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), - ) - - Text( - text = "We're Truly Sorry", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - - Text( - text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2, - ) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), - ), - shape = MaterialTheme.shapes.medium, - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Error Details", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton( - onClick = { - clipboardManager.setText(AnnotatedString(errorMessage)) - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = FeatherIcons.Copy, - contentDescription = "Copy to clipboard", - modifier = Modifier.size(16.dp), - ) - } - - IconButton( - onClick = { showFullLog = true }, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = FeatherIcons.Maximize2, - contentDescription = "View full log", - modifier = Modifier.size(16.dp), - ) - } - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f), - ) { - SelectionContainer { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 5, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(12.dp), - ) - } - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Button( - onClick = onRetry, - modifier = Modifier.height(40.dp), - ) { - Icon( - imageVector = FeatherIcons.RefreshCw, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp).size(18.dp), - ) - Text("Retry") - } - } - } - - if (showFullLog) { - ErrorLogDialog( - errorMessage = errorMessage, - onDismiss = { showFullLog = false }, - ) - } -} - -@Composable -private fun ErrorLogDialog( - errorMessage: String, - onDismiss: () -> Unit, -) { - val clipboardManager = LocalClipboardManager.current - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Full Error Log", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - - IconButton( - onClick = { - clipboardManager.setText(AnnotatedString(errorMessage)) - }, - ) { - Icon( - imageVector = FeatherIcons.Copy, - contentDescription = "Copy to clipboard", - ) - } - } - }, - text = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(400.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)), - ) { - SelectionContainer { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(12.dp), - ) - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("Close") - } - }, - ) -} - -@Preview -@Composable -private fun ErrorDisplayPreview() { - Surface { - ErrorDisplay( - errorMessage = "This is an error message", - onRetry = {}, - ) - } -} - -@Preview -@Composable -private fun ErrorLogDialogPreview() { - Scaffold { - ErrorLogDialog( - errorMessage = "This is an error message", - onDismiss = {}, - ) - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt deleted file mode 100644 index eca9b2e2..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt +++ /dev/null @@ -1,170 +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 . - */ - -package dev.krtirtho.spotube.core.ui.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -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.shape.RoundedCornerShape -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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 dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle -import dev.krtirtho.spotube.resources.iconsax.Play -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.painterResource -import org.koin.compose.koinInject -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.liked_tracks - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun LikedTracksCard( - modifier: Modifier = Modifier, - audioPlayerQueue: AudioPlayerQueue = koinInject(), - playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject(), -) { - val scope = rememberCoroutineScope() - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() - val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks - - val sharedTransitionScope = LocalSharedTransitionScope.current - val animatedVisibilityScope = LocalAnimatedVisibilityScope.current - - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) - .width(160.dp) - .hoverable(interactionSource = interactionSource), - ) { - Column(modifier = Modifier.fillMaxWidth()) { - Box(modifier = Modifier.fillMaxWidth()) { - androidx.compose.foundation.Image( - painter = painterResource(Res.drawable.liked_tracks), - contentDescription = "Liked Tracks", - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .sharedElementOrNone( - key = "saved_tracks_art", - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ), - contentScale = ContentScale.Crop, - ) - - Box( - modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), - contentAlignment = Alignment.BottomEnd - ) { - if (isHovered) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(4.dp) - ) { - FilledTonalIconButton( - onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, - modifier = Modifier.size(30.dp) - ) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue", - modifier = Modifier.size(16.dp) - ) - } - Spacer(modifier = Modifier.height(8.dp)) - FilledIconButton( - onClick = { scope.launch { playbackHelper.playSavedTracks() } }, - modifier = Modifier.size(30.dp) - ) { - Icon( - imageVector = if (isPlaying) { - Iconsax.IconsaxPauseCircle - } else Iconsax.Play, - contentDescription = "Play", - modifier = Modifier.size(16.dp) - ) - } - } - } - } - } - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp) - ) { - Text( - text = "Liked Tracks", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Your saved songs", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt deleted file mode 100644 index 8c75b3f1..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ /dev/null @@ -1,1010 +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 . - */ - -package dev.krtirtho.spotube.core.ui.component - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -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.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -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.draw.drawWithCache -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -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.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.ButtonGroup -import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.CheckBox -import dev.krtirtho.spotube.core.ui.base.CheckBoxState -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.GroupIconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.highlight -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.getPlatform -import dev.krtirtho.spotube.isDesktop -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxSort - -private val CompactTrackListBreakpoint = 600.dp -private val LargeTrackListBreakpoint = 840.dp -private const val ShimmerRowCount = 6 - -enum class TrackSortOption(val label: String) { - None("Original"), - Title("Title"), - Artist("Artist"), - Album("Album"), - Duration("Duration"), -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) -@Composable -fun TrackList( - tracks: List = emptyList(), - error: String? = null, - hasMore: Boolean = false, - isLoading: Boolean = false, - isLoadingNextPage: Boolean = false, - showEmptyMessage: Boolean = true, - headerContent: (@Composable () -> Unit)? = null, - footerContent: (@Composable () -> Unit)? = null, - onLoadNextPage: () -> Unit = {}, - onTrackClick: (MetadataTrack) -> Unit = {}, - onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> }, - onArtistClick: (MetadataArtist.Basic) -> Unit = {}, - onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {}, - onArtistsOverflowClick: (MetadataTrack) -> Unit = {}, - onBulkDownload: (List) -> Unit = {}, - onBulkAddToQueue: (List) -> Unit = {}, - onBulkPlayNext: (List) -> Unit = {}, - onBulkAddToPlaylist: (List) -> Unit = {}, - currentTrackId: String? = null, - isCurrentTrackPlaying: Boolean = false, - trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), - simplified: Boolean = false, - scrollable: Boolean = true, - state: LazyListState? = null, -) { - var filterQuery by rememberSaveable { mutableStateOf("") } - var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } - var selectedTrackForOptions by remember { mutableStateOf(null) } - var isSelectionMode by rememberSaveable { mutableStateOf(false) } - var selectedTrackIds by rememberSaveable { mutableStateOf>(emptySet()) } - - val normalizedQuery = remember(filterQuery) { filterQuery.trim().lowercase() } - val visibleTracks = remember(tracks, normalizedQuery, sortBy) { - val filtered = if (normalizedQuery.isBlank()) { - tracks - } else { - tracks.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) - } - } - - when (sortBy) { - TrackSortOption.None -> filtered - TrackSortOption.Title -> filtered.sortedBy { it.title.lowercase() } - TrackSortOption.Artist -> filtered.sortedBy { - it.artists.firstOrNull()?.name?.lowercase().orEmpty() - } - - TrackSortOption.Album -> filtered.sortedBy { it.album?.title?.lowercase() } - TrackSortOption.Duration -> filtered.sortedBy { it.durationMs } - } - } - - val listState = state ?: rememberLazyListState() - val density = LocalDensity.current - val shouldLoadMore = remember(density) { - derivedStateOf { - val loadMoreThreshold = with(density) { 200.dp.toPx() } - val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" } - // Trigger when the bottom of the track card is close to/inside the viewport, - // which means the user has scrolled through (or is about to scroll through) - // all loaded tracks. - cardItem != null && cardItem.offset + cardItem.size <= listState.layoutInfo.viewportEndOffset + loadMoreThreshold - } - } - - LaunchedEffect(shouldLoadMore.value) { - if (shouldLoadMore.value && hasMore && !isLoading && !isLoadingNextPage) { - onLoadNextPage() - } - } - - val windowInfo = LocalWindowInfo.current - val maxWidth = with(density) { windowInfo.containerSize.width.toDp() } - val isCompact = maxWidth < CompactTrackListBreakpoint - val isDesktop = remember { getPlatform().isDesktop() } - val showIndex = maxWidth >= LargeTrackListBreakpoint - val showAlbum = !isCompact - val useDropdownForOptions = isDesktop && !isCompact - - val trackCardContent: @Composable () -> Unit = { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 12.dp) - ) { - visibleTracks.forEachIndexed { displayedIndex, track -> - if (displayedIndex > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - TrackListRow( - index = displayedIndex + 1, - track = track, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - isCurrentTrack = track.id == currentTrackId, - isCurrentTrackPlaying = isCurrentTrackPlaying, - isSelectionMode = isSelectionMode, - isSelected = selectedTrackIds.contains(track.id), - onTrackClick = { - if (isSelectionMode) { - selectedTrackIds = - if (selectedTrackIds.contains(track.id)) { - selectedTrackIds - track.id - } else { - selectedTrackIds + track.id - } - } else { - onTrackClick(track) - } - }, - onLongClick = { - if (!useDropdownForOptions && !isSelectionMode) { - isSelectionMode = true - selectedTrackIds = setOf(track.id) - } else if (!useDropdownForOptions) { - selectedTrackForOptions = track - } - }, - onSelectionToggle = { checked -> - isSelectionMode = true - selectedTrackIds = if (checked) { - selectedTrackIds + track.id - } else { - selectedTrackIds - track.id - } - }, - onTrackOptionsAction = { action -> - onTrackOptionsAction( - track, - action - ) - }, - trackOptionsState = trackOptionsState(track), - onShowOptionsClick = { selectedTrackForOptions = track }, - onArtistClick = onArtistClick, - onAlbumClick = onAlbumClick, - onArtistsOverflowClick = { onArtistsOverflowClick(track) }, - ) - } - - if (isLoading && tracks.isEmpty()) { - repeat(ShimmerRowCount) { shimmerIndex -> - if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - ShimmerTrackListRow( - index = shimmerIndex + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) - } - } - - if (error != null) { - if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - TrackListFeedbackRow( - message = error, - isError = true, - ) - } - - if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { - TrackListFeedbackRow("No tracks found") - } - - if (isLoadingNextPage) { - if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - ShimmerTrackListRow( - index = visibleTracks.size + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) - } - } - } - } - - val filterSortRow: @Composable () -> Unit = { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - .heightIn(max = 60.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), - ) { - if (showIndex || isSelectionMode) { - val allSelected = - visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size - val anySelected = selectedTrackIds.isNotEmpty() - val headerState = when { - allSelected -> CheckBoxState.SELECTED - anySelected -> CheckBoxState.INDETERMINATE - else -> CheckBoxState.UNSELECTED - } - CheckBox( - state = headerState, - onClick = { - if (allSelected) { - isSelectionMode = false - selectedTrackIds = emptySet() - } else { - isSelectionMode = true - selectedTrackIds = visibleTracks.map { it.id }.toSet() - } - }, - ) - } - - Box( - modifier = Modifier - .weight(1f) - ) { - TextField( - value = filterQuery, - onValueChange = { filterQuery = it }, - modifier = Modifier - .widthIn(max = 400.dp) - .align(Alignment.CenterEnd), - placeholder = { TextWithShimmer("Filter") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Filter", - ) - }, - singleLine = true, - ) - } - - ButtonGroup { - Box( - contentAlignment = Alignment.Center, - ) { - AdaptiveDropdownBottomSheet( - items = TrackSortOption.entries.map { option -> - AdaptiveMenuItem( - label = option.label, - onClick = { sortBy = option }, - selected = sortBy == option, - ) - }, - trigger = { onClick -> - GroupIconButton( - onClick = onClick, - ) { - Icon( - imageVector = Iconsax.IconsaxSort, - contentDescription = "Sort", - ) - } - }, - ) - } - ButtonGroupDivider() - Box( - contentAlignment = Alignment.Center, - ) { - val targetTracks = if (selectedTrackIds.isNotEmpty()) { - visibleTracks.filter { selectedTrackIds.contains(it.id) } - } else { - visibleTracks - } - val trackCount = targetTracks.size - val isAll = - selectedTrackIds.isEmpty() || trackCount == visibleTracks.size - AdaptiveDropdownBottomSheet( - items = listOf( - AdaptiveMenuItem( - icon = Iconsax.IconsaxDirectboxReceive, - label = if (isAll) "Download All" else "Download $trackCount", - onClick = { onBulkDownload(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicPlaylist, - label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", - onClick = { onBulkAddToPlaylist(targetTracks) }, - ), - ), - trigger = { onClick -> - GroupIconButton( - onClick = onClick, - ) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Bulk actions", - ) - } - }, - ) - } - } - } - } - - if (scrollable) { - Box(modifier = modifier.fillMaxWidth()) { - LazyColumn( - state = listState, - modifier = Modifier - .widthIn(max = 1280.dp) - .align(Alignment.TopCenter) - .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - if (headerContent != null) { - item { - headerContent() - } - } - if (!simplified) { - item { filterSortRow() } - } - item(key = "track-card") { trackCardContent() } - if (footerContent != null) { - item { - footerContent() - } - } - } - - if (!useDropdownForOptions) { - selectedTrackForOptions?.let { track -> - TrackOptionsBottomSheet( - track = track, - state = trackOptionsState(track), - onDismiss = { selectedTrackForOptions = null }, - onAction = { action -> - onTrackOptionsAction(track, action) - selectedTrackForOptions = null - }, - onAlbumClick = { track.album?.let { onAlbumClick(it) } }, - ) - } - } - VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) - } - } else { - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - if (headerContent != null) { - headerContent() - } - if (!simplified) { - filterSortRow() - } - trackCardContent() - if (footerContent != null) { - footerContent() - } - } - - if (!useDropdownForOptions) { - selectedTrackForOptions?.let { track -> - TrackOptionsBottomSheet( - track = track, - state = trackOptionsState(track), - onDismiss = { selectedTrackForOptions = null }, - onAction = { action -> - onTrackOptionsAction(track, action) - selectedTrackForOptions = null - }, - onAlbumClick = { track.album?.let { onAlbumClick(it) } }, - ) - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun TrackListRow( - index: Int, - track: MetadataTrack, - showIndex: Boolean, - showAlbum: Boolean, - useDropdownForOptions: Boolean, - isCurrentTrack: Boolean, - isCurrentTrackPlaying: Boolean, - isSelectionMode: Boolean, - isSelected: Boolean, - onTrackClick: () -> Unit, - onLongClick: () -> Unit, - onSelectionToggle: (Boolean) -> Unit, - onTrackOptionsAction: (TrackOptionsAction) -> Unit, - trackOptionsState: TrackOptionsState, - onShowOptionsClick: () -> Unit, - onArtistClick: (MetadataArtist.Basic) -> Unit, - onAlbumClick: (MetadataAlbum.Detailed) -> Unit, - onArtistsOverflowClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val rowTheme = LocalBaseUITheme.current.listRowTile - val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val artworkInteractionSource = remember { MutableInteractionSource() } - val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() - val rowInteractionSource = remember { MutableInteractionSource() } - val isRowHovered by rowInteractionSource.collectIsHoveredAsState() - val rowBackgroundColor = when { - isSelected -> rowTheme.background.selected - isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered - isCurrentTrack -> rowTheme.background.focused - trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f)) - else -> rowTheme.background.normal - } - val highlightColor = Color.White.copy( - alpha = if (isLight) 0.9f else 0.06f - ) - - Row( - modifier = modifier - .fillMaxWidth() - .hoverable(rowInteractionSource) - .combinedClickable( - onClick = onTrackClick, - onLongClick = onLongClick, - ) - .background(rowBackgroundColor) - .then( - if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) { - Modifier.highlight(highlightColor) - } else { - Modifier - } - ) - .clip(MaterialTheme.shapes.small) - .padding(horizontal = 8.dp, vertical = 6.dp) - .padding(end = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (isSelectionMode) { - CheckBox( - state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - onClick = { onSelectionToggle(!isSelected) }, - modifier = Modifier.shimmerApply() - ) - } else if (showIndex) { - Box( - modifier = Modifier.width(30.dp), - contentAlignment = Alignment.Center, - ) { - AnimatedContent( - targetState = isRowHovered, - transitionSpec = { - fadeIn() togetherWith fadeOut() - }, - label = "index-checkbox", - ) { hovered -> - if (hovered) { - CheckBox( - state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - onClick = { onSelectionToggle(!isSelected) }, - modifier = Modifier.shimmerApply() - ) - } else { - TextWithShimmer( - text = index.toString(), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - Box( - modifier = Modifier - .size(44.dp) - .clip(MaterialTheme.shapes.small) - .hoverable(interactionSource = artworkInteractionSource), - contentAlignment = Alignment.Center, - ) { - - val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url - val platformContext = LocalPlatformContext.current - val imageRequest = remember(imageUrl) { - ImageRequest.Builder(platformContext) - .data(imageUrl) - .size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size. - .crossfade(false) // Crucial for scroll performance - .build() - } - - AsyncImage( - model = imageRequest, - contentDescription = track.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().shimmerApply(), - ) - - if (isArtworkHovered || isCurrentTrack) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.10f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play", - tint = Color.White, - modifier = Modifier.size(18.dp).shimmerApply(), - ) - } - } - } - - Column(modifier = Modifier.weight(1.2f)) { - TextWithShimmer( - text = track.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - ArtistChips( - artists = track.artists, - onArtistClick = onArtistClick, - onArtistsOverflowClick = onArtistsOverflowClick, - ) - } - - if (showAlbum && track.album != null) { - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier.weight(1f) - ) { - TextWithShimmer( - text = track.album!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .hoverable(interactionSource) - .clickable( - indication = null, - interactionSource = interactionSource - ) { onAlbumClick(track.album!!) } - // Hide ripple and show underline on hover/click - .then( - if (isHovered || isPressed) { - Modifier.drawWithCache { - val underlineHeight = 1.dp.toPx() - onDrawWithContent { - drawContent() - drawLine( - color = colorScheme.primary, - start = Offset(0f, size.height - underlineHeight), - end = Offset(size.width, size.height - underlineHeight), - strokeWidth = underlineHeight, - ) - } - } - } else { - Modifier - } - ) - ) - } - } - - TextWithShimmer( - text = track.durationMs.toDurationString(), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.End, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(56.dp), - ) - - if (useDropdownForOptions) { - TrackOptions( - track = track, - state = trackOptionsState, - onAction = onTrackOptionsAction, - onAlbumClick = { track.album?.let { onAlbumClick(it) } }, - ) - } else { - GhostIconButton(onClick = onShowOptionsClick) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Track options", - ) - } - } - } -} - -@Composable -private fun ArtistChips( - artists: List, - onArtistClick: (MetadataArtist.Basic) -> Unit, - onArtistsOverflowClick: () -> Unit, -) { - val visibleArtists = artists.take(2) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - visibleArtists.forEachIndexed { index, artist -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - - TextWithShimmer( - text = artist.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, - modifier = Modifier - .clickable( - indication = null, - interactionSource = interactionSource - ) { onArtistClick(artist) } - , - ) - if (index < visibleArtists.size - 1) - Text( - ",", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - } - - val remaining = artists.size - visibleArtists.size - if (remaining > 0) { - TextWithShimmer( - text = "+$remaining more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable(onClick = onArtistsOverflowClick), - ) - } - } -} - -@Composable -private fun ShimmerTrackListRow( - index: Int, - showIndex: Boolean, - showAlbum: Boolean, - useDropdownForOptions: Boolean, -) { - val dummyTrack = remember(index) { dummyTrackForShimmer(index) } - SkeletonTree(true) { - TrackListRow( - index = index, - track = dummyTrack, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - isCurrentTrack = false, - isCurrentTrackPlaying = false, - isSelectionMode = false, - isSelected = false, - onTrackClick = {}, - onLongClick = {}, - onSelectionToggle = {}, - onTrackOptionsAction = {}, - trackOptionsState = TrackOptionsState(), - onShowOptionsClick = {}, - onArtistClick = {}, - onAlbumClick = {}, - onArtistsOverflowClick = {}, - ) - } -} - -private fun dummyTrackForShimmer(index: Int): MetadataTrack { - val artist = MetadataArtist.Basic( - id = "shimmer_artist_$index", - name = "Artist Name", - thumbnails = emptyList(), - externalUri = null, - ) - return MetadataTrack( - id = "shimmer_track_$index", - title = "Track Title Here", - durationMs = 210_000, - trackNumber = index, - discNumber = 1, - artists = listOf( - artist, - artist.copy(id = "shimmer_artist_${index}_2", name = "Another Artist") - ), - album = MetadataAlbum.Detailed( - releaseDate = null, - genres = emptyList(), - trackCount = 10, - id = "shimmer_album_$index", - title = "Album Name", - description = null, - thumbnails = emptyList(), - albumType = MetadataAlbumType.Album, - artists = listOf(artist), - externalUri = null, - ), - thumbnails = null, - explicit = false, - popularity = 0, - isrcCode = null, - externalUri = null, - ) -} - -@Composable -private fun TrackListFeedbackRow( - message: String, - isError: Boolean = false, -) { - TextWithShimmer( - text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - ) -} - -private fun Long.toDurationString(): String { - val totalSeconds = (this / 1000).coerceAtLeast(0) - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} - -fun previewTracks(): List { - val artists = listOf( - MetadataArtist.Basic( - id = "artist_1", - name = "Aria Vale", - thumbnails = emptyList(), - externalUri = null, - ), - MetadataArtist.Basic( - id = "artist_2", - name = "Neon Harbor", - thumbnails = emptyList(), - externalUri = null, - ), - MetadataArtist.Basic( - id = "artist_3", - name = "Echo Drift", - thumbnails = emptyList(), - externalUri = null, - ), - ) - - val album = MetadataAlbum.Detailed( - releaseDate = null, - genres = emptyList(), - trackCount = 10, - id = "album_1", - title = "Night Drive Archives", - description = null, - thumbnails = listOf( - Thumbnail( - url = "https://picsum.photos/120", - width = 120, - height = 120, - ) - ), - albumType = MetadataAlbumType.Album, - artists = artists.take(2), - externalUri = null, - ) - - return listOf( - MetadataTrack( - id = "track_1", - title = "Signal Bloom", - durationMs = 198_000, - trackNumber = 1, - discNumber = 1, - artists = artists, - album = album, - thumbnails = null, - explicit = false, - popularity = 92, - isrcCode = null, - externalUri = null, - ), - MetadataTrack( - id = "track_2", - title = "Static Horizon", - durationMs = 224_000, - trackNumber = 2, - discNumber = 1, - artists = artists.take(2), - album = album.copy(id = "album_2", title = "City Pulse"), - explicit = false, - popularity = 80, - isrcCode = null, - externalUri = null, - thumbnails = null, - ), - MetadataTrack( - id = "track_3", - title = "Last Train Echo", - durationMs = 246_000, - trackNumber = 3, - discNumber = 1, - artists = artists.take(1), - album = album.copy(id = "album_3", title = "Afterlight"), - explicit = false, - popularity = 77, - isrcCode = null, - externalUri = null, - thumbnails = null, - ), - ) -} - -@Composable -@Preview -private fun TrackListPreview() { - Scaffold { - TrackList( - tracks = previewTracks(), - hasMore = true, - isLoadingNextPage = true, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt deleted file mode 100644 index 1e5d69cc..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ /dev/null @@ -1,56 +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 . - */ - -package dev.krtirtho.spotube.core.ui.theming - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import com.materialkolor.rememberDynamicColorScheme -import dev.krtirtho.spotube.modules.settings.Theme -import dev.krtirtho.spotube.modules.settings.UserSettings - -@Composable -fun SpotubeTheme( - settings: UserSettings, - content: @Composable () -> Unit, -) { - val isDarkTheme = when (settings.theme) { - Theme.LIGHT -> false - Theme.DARK -> true - Theme.SYSTEM -> isSystemInDarkTheme() - } - - val accent = if (isDarkTheme) { - settings.accentColor.toDarkColor() - } else { - settings.accentColor.toLightColor() - } - - val colorScheme = rememberDynamicColorScheme( - seedColor = accent, - isDark = isDarkTheme, - ) - - MaterialTheme( - colorScheme = colorScheme, - ) { - Surface(content = content) - } -} - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt deleted file mode 100644 index adc839cd..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ /dev/null @@ -1,178 +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 . - */ - -package dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt - -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.newpipe.NewPipeService -import dev.krtirtho.spotube.core.newpipe.VideoSearchResult -import org.koin.core.component.KoinComponent - -class RealNewPipeAudioAPI : AudioAPI, KoinComponent { - private val logger by injectLogger() - private val newPipeService = NewPipeService() - - companion object { - private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$") - } - - private val isrcProvider = ISRCProviders() - - private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { - var confidence = 0f - - val isTitleMatch = track.title.lowercase() in video.title.lowercase() - val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() } - val isAlbumInTitle = - track.album != null && track.album!!.title.lowercase() in video.title.lowercase() - val isUploaderMatch = - video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "") - val isDurationMatch = - video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds - - if (isTitleMatch) confidence += 0.3f - if (isUploaderMatch) confidence += 0.3f - if (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds - if (isArtistInTitle) confidence += 0.1f - if (isAlbumInTitle) confidence += 0.1f - - return AudioSource.Basic( - id = video.id, - title = video.title, - artist = video.uploader, - album = null, - thumbnails = listOf( - Thumbnail( - url = video.thumbnailUrl, - width = 300, - height = 300, - ) - ), - externalUri = "https://www.youtube.com/watch?v=${video.id}", - confidence = confidence, - ) - } - - override val supportedQualities: List = listOf( - AudioFormat( - codec = "opus", container = "webm", qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) - ), AudioFormat( - codec = "aac", container = "mp4", qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) - ) - ) - - override suspend fun getStreamsByTrack(track: MetadataTrack): List { - val isYouTubeUrl = - track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains( - "youtu.be" - )) - val isYouTubeID = track.id.matches(youtubeIDRegex) - - - logger.i { - "Searching streams for track(${track.id}): ${track.title} by ${ - track.artists.joinToString( - ", " - ) { it.name } - }" - } - if (isYouTubeID || isYouTubeUrl) { - logger.i { "Track has YouTube ID or URL, fetching video info directly" } - return newPipeService.getVideoInfo(track.id).let { videoInfo -> - listOf( - AudioSource.Streamed( - id = videoInfo.id, - title = videoInfo.title, - artist = videoInfo.uploader, - album = null, - thumbnails = listOf( - Thumbnail( - url = videoInfo.thumbnailUrl, - width = 300, - height = 300, - ) - ), - externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", - confidence = 1.0f, - streams = videoInfo.audioStreams, - ) - ) - } - } - - logger.i { "No YouTube ID or URL found, performing search with track metadata" } - - val isrcCode = track.isrcCode ?: isrcProvider.auto(track) - val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim() - - logger.i { "Searching for video with query: $searchQuery" } - val videos = newPipeService.searchVideos(isrcCode ?: searchQuery) - - logger.i { "Found ${videos.size} videos with query: $searchQuery" } - var sources = videos.map { transformVideo(track, it) } - - if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) { - logger.i { "ISRC search failed, fallback to title search" } - val videos = newPipeService.searchVideos(searchQuery) - sources = videos.map { transformVideo(track, it) } - } - - logger.i { "Found ${sources.size} sources with confidence >= 0.3f" } - - // Return 5 highest to lowest confidence - return sources.sortedByDescending { it.confidence }.take(5) - } - - override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { - return newPipeService.getVideoInfo(source.id).let { videoInfo -> - listOf( - AudioSource.Streamed( - id = videoInfo.id, - title = videoInfo.title, - artist = videoInfo.uploader, - album = null, - thumbnails = listOf( - Thumbnail( - url = videoInfo.thumbnailUrl, - width = 300, - height = 300, - ) - ), - externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", - confidence = source.confidence, - streams = videoInfo.audioStreams - ) - ) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt deleted file mode 100644 index 5b76c144..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ /dev/null @@ -1,704 +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 . - */ - -package dev.krtirtho.spotube.modules.artist - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.snapshotFlow -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.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.TrackList -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.component.dragScrollable -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove -import dev.krtirtho.spotube.resources.iconsax.User -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlin.math.roundToInt - -@Composable -fun ArtistScreen( - viewModel: ArtistViewModel, - audioPlayerQueue: AudioPlayerQueue, - audioPlayer: AudioPlayerInterface, - navigationCommands: NavigationCommands -) { - val state by viewModel.state.collectAsStateWithLifecycle() - val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() - val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() - val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() - val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() - val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() - val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() - val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - - Scaffold( - topBar = { ApplicationMainBar() } - ) { innerPadding -> - when (val currentState = state) { - is ArtistScreenState.Loading -> { - ArtistLoadingContent(innerPadding) - } - - is ArtistScreenState.Error -> { - ArtistErrorContent( - innerPadding = innerPadding, - message = currentState.message, - onRetry = { viewModel.refresh() } - ) - } - - is ArtistScreenState.Loaded -> { - val listState = rememberLazyListState() - - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - item { - ArtistHeaderCard( - artist = currentState.artist, - isSaved = savedArtistIds.contains(currentState.artist.id), - onFollowClick = viewModel::toggleSavedArtist, - isBlacklisted = currentState.artist.id in blacklistedArtistIds, - onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }, - ) - } - - item { - TopTracksHeader( - onPlay = viewModel::playTopTracks, - onAddToQueue = viewModel::addTopTracksToQueue, - ) - } - - item { - TrackList( - tracks = currentState.topTracks, - error = null, - hasMore = false, - isLoading = false, - isLoadingNextPage = false, - currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, - isCurrentTrackPlaying = playerState == PlayerState.PLAYING, - onTrackClick = viewModel::playTopTracksFromTrack, - onTrackOptionsAction = viewModel::handleTrackOptionsAction, - trackOptionsState = trackOptionsContext::stateFor, - onArtistClick = { trackArtist -> - navigationCommands.navigateTo( - Routes.Artist( - trackArtist.id - ) - ) - }, - onAlbumClick = { album -> - navigationCommands.navigateTo( - Routes.Album( - album.id - ) - ) - }, - onLoadNextPage = {}, - simplified = true, - scrollable = false, - onBulkDownload = viewModel::downloadTracks, - onBulkAddToQueue = viewModel::addTracksToQueue, - onBulkPlayNext = viewModel::playTracksNext, - onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, - ) - } - - if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { - item { - AlbumsSection( - albums = currentState.albums, - onAlbumClick = { album -> - navigationCommands.navigateTo( - Routes.Album( - album.id - ) - ) - }, - onLoadMore = { viewModel.loadMoreAlbums() }, - ) - } - } - - if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) { - item { - RelatedArtistsSection( - artists = currentState.relatedArtists, - onArtistClick = { artist -> - navigationCommands.navigateTo( - Routes.Artist( - artist.id - ) - ) - }, - onLoadMore = { viewModel.loadMoreRelatedArtists() }, - ) - } - } - - if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) { - item { - FeaturedPlaylistsSection( - playlists = currentState.featuredPlaylists, - onPlaylistClick = { playlist -> - navigationCommands.navigateTo( - Routes.Playlist( - playlist.id - ) - ) - }, - onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }, - ) - } - } - } - } - } - - AddToPlaylistPicker( - visible = showAddToPlaylistPicker, - currentUserId = currentUserId, - onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) - } -} - -@Composable -private fun ArtistLoadingContent(innerPadding: PaddingValues) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - SkeletonTree(true) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Box( - modifier = Modifier - .size(200.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - ) - } - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - items(4) { - PlayableCard( - title = "Item Title", - subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) - } - } - } - } - } - } -} - -@Composable -private fun ArtistErrorContent( - innerPadding: PaddingValues, - message: String, - onRetry: () -> Unit, -) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyLarge, - ) - PrimaryButton(onClick = onRetry) { - Text("Retry") - } - } - } -} - -@Composable -private fun ArtistHeaderCard( - artist: MetadataArtist.Detailed, - isSaved: Boolean, - onFollowClick: () -> Unit, - isBlacklisted: Boolean, - onBlacklistClick: () -> Unit, -) { - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val isCompact = maxWidth < 600.dp - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(20.dp), - ) { - if (isCompact) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ArtistAvatar( - artist = artist, - size = 180.dp, - ) - - ArtistMeta( - artist = artist, - isCompact = true, - ) - - ArtistHeaderActions( - isSaved = isSaved, - onFollowClick = onFollowClick, - isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick, - ) - } - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.Top, - ) { - ArtistAvatar( - artist = artist, - size = 220.dp, - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - ArtistMeta( - artist = artist, - isCompact = false, - ) - - ArtistHeaderActions( - isSaved = isSaved, - onFollowClick = onFollowClick, - isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick, - ) - } - } - } - } - } -} - -@Composable -private fun ArtistAvatar( - artist: MetadataArtist.Detailed, - size: androidx.compose.ui.unit.Dp, -) { - Box( - modifier = Modifier - .size(size) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - contentAlignment = Alignment.Center, - ) { - val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() - if (imageUrl.isNotBlank()) { - AsyncImage( - model = imageUrl, - contentDescription = artist.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } else { - Icon( - imageVector = Iconsax.User, - contentDescription = artist.name, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(size * 0.4f), - ) - } - } -} - -@Composable -private fun ArtistMeta( - artist: MetadataArtist.Detailed, - isCompact: Boolean, -) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = artist.name, - style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.SemiBold, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = buildString { - append(formatFollowers(artist.followersCount)) - if (artist.genres.isNotEmpty()) { - append(" • ") - append(artist.genres.joinToString(", ")) - } - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, - ) - - artist.biography?.takeIf { it.isNotBlank() }?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = if (isCompact) 4 else 6, - overflow = TextOverflow.Ellipsis, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, - ) - } - } -} - -@Composable -private fun ArtistHeaderActions( - isSaved: Boolean, - onFollowClick: () -> Unit, - isBlacklisted: Boolean, - onBlacklistClick: () -> Unit, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSaved) { - SecondaryButton(onClick = onFollowClick) { - Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) - } - } else { - PrimaryButton(onClick = onFollowClick) { - 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", - ) - } - } - } -} - -@Composable -private fun TopTracksHeader( - onPlay: () -> Unit, - onAddToQueue: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Top Tracks", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryIconButton(onClick = onPlay) { - Icon( - imageVector = Iconsax.IconsaxPlay, - contentDescription = "Play top tracks", - ) - } - SecondaryIconButton(onClick = onAddToQueue) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add top tracks to queue", - ) - } - } - } -} - -@Composable -private fun AlbumsSection( - albums: List, - onAlbumClick: (MetadataAlbum.Detailed) -> Unit, - onLoadMore: () -> Unit, -) { - val rowState = rememberLazyListState() - - LaunchedEffect(rowState) { - snapshotFlow { rowState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { - onLoadMore() - } - } - } - - SectionHeader(title = "Albums") - Spacer(modifier = Modifier.size(8.dp)) - LazyRow( - state = rowState, - modifier = Modifier.dragScrollable(rowState), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items(albums, key = { it.id }) { album -> - AlbumCard(album = album) - } - } -} - -@Composable -private fun RelatedArtistsSection( - artists: List, - onArtistClick: (MetadataArtist.Basic) -> Unit, - onLoadMore: () -> Unit, -) { - val rowState = rememberLazyListState() - - LaunchedEffect(rowState) { - snapshotFlow { rowState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { - onLoadMore() - } - } - } - - SectionHeader(title = "Related Artists") - Spacer(modifier = Modifier.size(8.dp)) - LazyRow( - state = rowState, - modifier = Modifier.dragScrollable(rowState), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items(artists, key = { it.id }) { artist -> - ArtistCard(artist = artist) - } - } -} - -@Composable -private fun FeaturedPlaylistsSection( - playlists: List, - onPlaylistClick: (MetadataPlaylist) -> Unit, - onLoadMore: () -> Unit, -) { - val rowState = rememberLazyListState() - - LaunchedEffect(rowState) { - snapshotFlow { rowState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { - onLoadMore() - } - } - } - - SectionHeader(title = "Featured Playlists") - Spacer(modifier = Modifier.size(8.dp)) - LazyRow( - state = rowState, - modifier = Modifier.dragScrollable(rowState), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items(playlists, key = { it.id }) { playlist -> - PlaylistCard(playlist = playlist) - } - } -} - -@Composable -private fun SectionHeader(title: String) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp), - ) -} - -private fun formatFollowers(count: Int?): String { - if (count == null) return "Followers unavailable" - return when { - count >= 1_000_000_000 -> "${formatAbbreviatedCount(count, 1_000_000_000)}B followers" - count >= 1_000_000 -> "${formatAbbreviatedCount(count, 1_000_000)}M followers" - count >= 1_000 -> "${formatAbbreviatedCount(count, 1_000)}K followers" - else -> "$count followers" - } -} - -private fun formatAbbreviatedCount(count: Int, divisor: Int): String { - val scaled = count / divisor.toDouble() - val rounded = (scaled * 10).roundToInt() / 10.0 - return if (rounded % 1.0 == 0.0) { - rounded.toInt().toString() - } else { - rounded.toString() - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt deleted file mode 100644 index 087e02ec..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt +++ /dev/null @@ -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 . - */ - -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() - 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, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModek.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModek.kt deleted file mode 100644 index 6464c81f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModek.kt +++ /dev/null @@ -1,2 +0,0 @@ -package dev.krtirtho.spotube.modules.blacklist - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt deleted file mode 100644 index 5d8b8dcc..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt +++ /dev/null @@ -1,280 +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 . - */ - -package dev.krtirtho.spotube.modules.downloads - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -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.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.crossfade -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun DownloadsScreen(modifier: Modifier = Modifier) { - val viewModel = koinViewModel() - val downloads by viewModel.downloads.collectAsStateWithLifecycle() - - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 8.dp, bottom = 16.dp + shellBottomInset) - } - - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - ) { - val hasCompleted = downloads.any { it.status == DownloadStatus.Completed } - if (hasCompleted) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = viewModel::clearCompleted) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Text( - text = " Clear completed", - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - - if (downloads.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No downloads yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - LazyColumn( - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - items(downloads, key = { it.id }) { item -> - DownloadListRow( - item = item, - onCancel = { viewModel.cancel(item.id) }, - onRetry = { viewModel.retry(item.id) }, - onRemove = { viewModel.remove(item.id) }, - ) - } - } - } - } - } -} - -@Composable -private fun DownloadListRow( - item: DownloadItem, - onCancel: () -> Unit, - onRetry: () -> Unit, - onRemove: () -> Unit, -) { - val rowBackgroundColor = when (item.status) { - is DownloadStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) - is DownloadStatus.Downloading -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - else -> MaterialTheme.colorScheme.surface - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clip(MaterialTheme.shapes.small) - .background(rowBackgroundColor) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails) - ?.firstOrNull()?.url - - val platformContext = LocalPlatformContext.current - val imageRequest = remember(imageUrl) { - ImageRequest.Builder(platformContext) - .data(imageUrl) - .size(128) - .crossfade(false) - .build() - } - - Box( - modifier = Modifier - .size(44.dp) - .clip(MaterialTheme.shapes.small), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = imageRequest, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - - if (item.status == DownloadStatus.Downloading) { - LinearProgressIndicator( - progress = { item.progress }, - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .align(Alignment.BottomCenter), - ) - } - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Text( - text = item.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - when (item.status) { - is DownloadStatus.Downloading -> { - val percent = (item.progress * 100).toInt() - Text( - text = "Downloading... $percent%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - is DownloadStatus.Queued -> { - Text( - text = "Queued", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - is DownloadStatus.Failed -> { - Text( - text = "Failed: ${item.errorMessage ?: "Unknown error"}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - is DownloadStatus.Cancelled -> { - Text( - text = "Cancelled", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - is DownloadStatus.Completed -> { - Text( - text = "Completed", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - - when (item.status) { - is DownloadStatus.Downloading, is DownloadStatus.Queued -> { - IconButton(onClick = onCancel) { - Icon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Cancel", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { - IconButton(onClick = onRetry) { - Icon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Retry", - tint = MaterialTheme.colorScheme.primary, - ) - } - } - - is DownloadStatus.Completed -> { - IconButton(onClick = onRemove) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt deleted file mode 100644 index 24d8ff86..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt +++ /dev/null @@ -1,61 +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 . - */ - -package dev.krtirtho.spotube.modules.downloads - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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 - -class DownloadsViewModel( - private val downloadManager: DownloadManager, -) : ViewModel() { - val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - emptyList(), - ) - - fun downloadTrack(track: MetadataTrack) { - downloadManager.enqueue(track) - } - - fun downloadTracks(tracks: List) { - tracks.forEach { track -> - downloadManager.enqueue(track) - } - } - - fun cancel(id: String) { - downloadManager.cancel(id) - } - - fun retry(id: String) { - downloadManager.retry(id) - } - - fun remove(id: String) { - downloadManager.remove(id) - } - - fun clearCompleted() { - downloadManager.clearCompleted() - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt deleted file mode 100644 index c5d931ed..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ /dev/null @@ -1,122 +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 . - */ - -package dev.krtirtho.spotube.modules.library - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.ChipTab -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.modules.downloads.DownloadsScreen -import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen -import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen -import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen -import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen -import dev.krtirtho.spotube.modules.shell.AppShellViewModel -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LibraryScreen( - libraryState: LibraryState = koinInject(), - appShellViewModel: AppShellViewModel = koinViewModel() -) { - val currentTab by libraryState.currentTab.collectAsState() - val searchMode by libraryState.searchMode.collectAsState() - val isLargeScreen = appShellViewModel.useSidebar() - - Scaffold( - topBar = { - Column { - ApplicationMainBar( - backButton = false, - title = { - if (!isLargeScreen) Text("Your Library") - }, - actions = { - if (!isLargeScreen) { - IconButton( - onClick = { - libraryState.setSearchMode(!searchMode) - }, - ) { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - } - } - } - ) - if (!isLargeScreen) - Column( - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Spacer(modifier = Modifier.height(4.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(LibraryTab.entries.size) { index -> - val tab = LibraryTab.entries[index] - ChipTab( - selected = currentTab == tab, - onClick = { libraryState.onTabSelected(tab) }, - ) { - Text(tab.title) - } - } - } - Spacer(modifier = Modifier.height(12.dp)) - } - } - - - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .padding(horizontal = 12.dp) - ) { - when (currentTab) { - LibraryTab.Playlists -> LibraryPlaylistsScreen(searchMode = isLargeScreen || searchMode) - LibraryTab.Albums -> LibraryAlbumsScreen(searchMode = isLargeScreen || searchMode) - LibraryTab.Artists -> LibraryArtistsScreen(searchMode = isLargeScreen || searchMode) - LibraryTab.LocalTracks -> LibraryLocalTracksScreen(searchMode = isLargeScreen || searchMode) - LibraryTab.Downloads -> DownloadsScreen() - } - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt deleted file mode 100644 index a64ebe96..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt +++ /dev/null @@ -1,34 +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 . - */ - -package dev.krtirtho.spotube.modules.library - -import androidx.compose.ui.graphics.vector.ImageVector -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary -import dev.krtirtho.spotube.resources.iconsax.User - -enum class LibraryTab(val title: String, val icon: ImageVector) { - Playlists("Playlists", Iconsax.IconsaxMusicDashboard), - Albums("Albums", Iconsax.IconsaxCd), - Artists("Artists", Iconsax.User), - LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary), - Downloads("Downloads", Iconsax.IconsaxDirectboxReceive), -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt deleted file mode 100644 index 106e4e8e..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ /dev/null @@ -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 . - */ - -package dev.krtirtho.spotube.modules.library.album - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun LibraryAlbumsScreen( - modifier: Modifier = Modifier, - searchMode: Boolean = false, - viewModel: LibraryAlbumsViewModel = koinViewModel() -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() - } - } - } - - Column(modifier = modifier) { - AnimatedVisibility(searchMode){ - TextField( - value = (state as? LibraryAlbumsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved albums...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - } - ) - } - Spacer(modifier = Modifier.height(12.dp)) - when (state) { - is LibraryAlbumsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - PlayableCard( - title = "Album Title", - subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400", - ) - } - } - } - } - - is LibraryAlbumsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryAlbumsState.Error).message, - onRetry = { viewModel.refresh() }, - ) - } - } - - is LibraryAlbumsState.Data -> { - val dataState = state as LibraryAlbumsState.Data - if (dataState.items.isEmpty() && state !is LibraryAlbumsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotBlank() -> Text("No albums found for '${dataState.query}'") - else -> Text("No saved albums") - } - } - } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(dataState.items, key = { it.id }) { album -> - AlbumCard( - album = album, - modifier = Modifier.fillMaxWidth() - ) - } - - if (state is LibraryAlbumsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - PlayableCard( - title = "Album Title", - subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400", - ) - } - } - } - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt deleted file mode 100644 index fc9b0714..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ /dev/null @@ -1,181 +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 . - */ - -package dev.krtirtho.spotube.modules.library.artist - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun LibraryArtistsScreen( - modifier: Modifier = Modifier, - searchMode: Boolean = false, - viewModel: LibraryArtistsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() - } - } - } - - Column(modifier = modifier) { - AnimatedVisibility(searchMode){ - TextField( - value = (state as? LibraryArtistsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved artists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) - } - Spacer(modifier = Modifier.height(12.dp)) - when (state) { - is LibraryArtistsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(8) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/600x400", - ) - } - } - } - } - - is LibraryArtistsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryArtistsState.Error).message, - onRetry = { viewModel.refresh() }, - ) - } - } - - is LibraryArtistsState.Data -> { - val dataState = state as LibraryArtistsState.Data - if (dataState.items.isEmpty() && state !is LibraryArtistsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotBlank() -> Text("No artists found for '${dataState.query}'") - else -> Text("No saved artists") - } - } - } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(dataState.items, key = { it.id }) { artist -> - ArtistCard( - artist = artist, - modifier = Modifier.fillMaxWidth(), - ) - } - -// if (state is LibraryArtistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/400x400", - ) - } - } -// } - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt deleted file mode 100644 index d3a93160..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ /dev/null @@ -1,219 +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 . - */ - -package dev.krtirtho.spotube.modules.library.local_tracks - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight -import dev.krtirtho.spotube.resources.iconsax.Play -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) { - val viewModel = koinViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - val permissionState = rememberLocalMediaPermissionState() - val platformType = remember { getPlatform().type } - val isAndroid = platformType == PlatformType.Android - - LaunchedEffect(permissionState.isGranted, isAndroid) { - if (!isAndroid || permissionState.isGranted) { - viewModel.refresh() - } - } - - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - Box(modifier = modifier.fillMaxWidth()) { - Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - AnimatedVisibility(searchMode) { - TextField( - value = state.query, - onValueChange = viewModel::onQueryChange, - modifier = Modifier - .fillMaxWidth(), - placeholder = { - Text( - if (state.currentFolderPath == null) { - "Search folders or tracks" - } else { - "Search tracks in folder" - } - ) - }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - }, - trailingIcon = { - Icon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Refresh", - modifier = Modifier.clickable { viewModel.refresh() } - ) - } - ) - } - - if (state.currentFolderPath != null) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = viewModel::navigateUp) { - Icon(imageVector = Iconsax.ArrowLeft3, contentDescription = "Back") - } - Text(viewModel.currentFolder()?.name ?: "Folder") - } - IconButton(onClick = { - viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } - }) { - Icon( - imageVector = Iconsax.IconsaxPlayCircle2, - contentDescription = "Play folder" - ) - } - } - } - - val folders = viewModel.visibleFolders() - val tracks = viewModel.visibleTracks() - - if (isAndroid && !permissionState.isGranted) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text("Allow audio permission to show local tracks") - TextButton(onClick = permissionState.requestPermission) { - Text("Grant access") - } - } - } - } else if (state.isRefreshing && folders.isEmpty() && tracks.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else if (state.currentFolderPath == null) { - if (folders.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(state.error ?: "No local folders found") - } - } else { - LazyColumn(contentPadding = contentPadding) { - items(folders, key = { it.path }) { folder -> - ListItem( - headlineContent = { Text(folder.name) }, - supportingContent = { Text("${folder.trackCount} track(s)") }, - leadingContent = { - Icon( - imageVector = Iconsax.IconsaxFolderOpen, - contentDescription = null - ) - }, - modifier = Modifier.clickable { viewModel.openFolder(folder.path) }, - ) - } - } - } - } else { - if (tracks.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No tracks in this folder") - } - } else { - LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) { - items(tracks, key = { it.path }) { track -> - ListItem( - headlineContent = { Text(track.name) }, - supportingContent = { - val artists = - track.artists.joinToString().ifBlank { "Unknown artist" } - val album = track.album?.takeIf { it.isNotBlank() } - Text(if (album == null) artists else "$artists - $album") - }, - leadingContent = { - Icon( - imageVector = Iconsax.IconsaxMusicCircle, - contentDescription = null - ) - }, - trailingContent = { - IconButton(onClick = { viewModel.playTrack(track.path) }) { - Icon( - imageVector = Iconsax.Play, - contentDescription = "Play" - ) - } - }, - modifier = Modifier.clickable { viewModel.playTrack(track.path) }, - ) - } - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt deleted file mode 100644 index 0987ff7b..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ /dev/null @@ -1,179 +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 . - */ - -package dev.krtirtho.spotube.modules.library.local_tracks - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class LibraryLocalTracksState( - val isRefreshing: Boolean = false, - val folders: List = emptyList(), - val currentFolderPath: String? = null, - val query: String = "", - val error: String? = null, -) - -class LibraryLocalTracksViewModel( - private val coordinator: LocalMediaLibraryCoordinator, - private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { - private val _state = MutableStateFlow(LibraryLocalTracksState()) - val state: StateFlow = _state.asStateFlow() - - init { - viewModelScope.launch { - coordinator.cacheState - .collect { cache -> - _state.update { - it.copy( - folders = cache.folders, - error = null, - ) - } - } - } - - refreshIfEmpty() - refreshIfStale() - } - - fun onQueryChange(query: String) { - _state.update { it.copy(query = query) } - } - - fun openFolder(folderPath: String) { - _state.update { it.copy(currentFolderPath = folderPath) } - } - - fun navigateUp() { - _state.update { it.copy(currentFolderPath = null) } - } - - fun refresh() { - viewModelScope.launch { - _state.update { it.copy(isRefreshing = true, error = null) } - runCatching { - coordinator.refreshNow("manual") - }.onFailure { throwable -> - _state.update { - it.copy(error = throwable.message ?: "Failed to refresh local media") - } - } - _state.update { it.copy(isRefreshing = false) } - } - } - - fun playFolder(folderPath: String) { - val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return - val tracks = filterTracks(folder.tracks, _state.value.query) - if (tracks.isEmpty()) return - - viewModelScope.launch { - audioPlayerQueue.load( - entries = tracks.map { it.toQueueEntry() }, - autoPlay = true, - startPosition = 0, - collectionEntry = null, - ) - } - } - - fun playTrack(trackPath: String) { - val folder = currentFolder() ?: return - val tracks = filterTracks(folder.tracks, _state.value.query) - val selectedIndex = tracks.indexOfFirst { it.path == trackPath } - if (selectedIndex < 0) return - - viewModelScope.launch { - audioPlayerQueue.load( - entries = tracks.map { it.toQueueEntry() }, - autoPlay = true, - startPosition = selectedIndex, - collectionEntry = null, - ) - } - } - - fun visibleFolders(): List { - val query = state.value.query.trim() - if (query.isBlank()) return state.value.folders - - return state.value.folders.filter { folder -> - folder.name.contains(query, ignoreCase = true) || - folder.tracks.any { track -> - track.name.contains(query, ignoreCase = true) || - track.artists.any { it.contains(query, ignoreCase = true) } - } - } - } - - fun visibleTracks(): List { - val folder = currentFolder() ?: return emptyList() - return filterTracks(folder.tracks, state.value.query) - } - - fun currentFolder(): LocalMediaFolder? { - val path = state.value.currentFolderPath ?: return null - return state.value.folders.firstOrNull { it.path == path } - } - - private fun refreshIfEmpty() { - if (_state.value.folders.isNotEmpty()) return - refresh() - } - - private fun refreshIfStale() { - viewModelScope.launch { - runCatching { - coordinator.refreshIfStale() - } - } - } - - private fun filterTracks(tracks: List, query: String): List { - val normalizedQuery = query.trim() - if (normalizedQuery.isBlank()) return tracks - - return tracks.filter { track -> - track.name.contains(normalizedQuery, ignoreCase = true) || - track.album?.contains(normalizedQuery, ignoreCase = true) == true || - track.artists.any { it.contains(normalizedQuery, ignoreCase = true) } - } - } - - private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack { - return QueueEntry.LocalTrack( - name = name, - artists = artists, - duration = durationMs, - album = album, - coverBytes = coverBytes, - url = path, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt deleted file mode 100644 index 1d18c751..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ /dev/null @@ -1,229 +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 . - */ - -package dev.krtirtho.spotube.modules.library.playlist - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -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.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.component.LikedTracksCard -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun LibraryPlaylistsScreen( - modifier: Modifier = Modifier, - searchMode: Boolean = false, - viewModel: LibraryPlaylistsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() - var showCreatePlaylist by remember { mutableStateOf(false) } - - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() - } - } - } - - Column(modifier = modifier) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - AnimatedVisibility( - visible = searchMode, - modifier = Modifier.weight(1f), - ) { - TextField( - value = (state as? LibraryPlaylistsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved playlists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - } - ) - } - IconButton( - onClick = { showCreatePlaylist = true }, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Create Playlist", - ) - } - } - Spacer(modifier = Modifier.height(12.dp)) - when (state) { - is LibraryPlaylistsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - PlayableCard( - title = "Playlist Name", - subtitle = "By Artist", - imageURL = "https://placehold.co/600x400", - ) - } - } - } - } - - is LibraryPlaylistsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryPlaylistsState.Error).message, - onRetry = { viewModel.refresh() }, - ) - } - } - - is LibraryPlaylistsState.Data -> { - val dataState = state as LibraryPlaylistsState.Data - if (dataState.items.isEmpty() && state !is LibraryPlaylistsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotEmpty() -> Text("No playlists found for '${dataState.query}'") - else -> Text("No saved playlists") - } - } - } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - LikedTracksCard( - modifier = Modifier.fillMaxWidth() - ) - } - - items(dataState.items, key = { it.id }) { playlist -> - PlaylistCard( - playlist = playlist, - modifier = Modifier.fillMaxWidth() - ) - } - - if (state is LibraryPlaylistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - PlayableCard( - title = "Playlist Name", - subtitle = "By Artist", - imageURL = "https://placehold.co/600x400", - ) - } - } - } - } - } - } - } - - PlaylistFormSheet( - visible = showCreatePlaylist, - onDismiss = { showCreatePlaylist = false }, - onSubmit = { data -> - viewModel.createPlaylist( - name = data.name, - description = data.description.ifBlank { null }, - isPublic = data.isPublic, - isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64, - trackIds = emptyList(), - ) - }, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt deleted file mode 100644 index b08c095c..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ /dev/null @@ -1,335 +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 . - */ - -package dev.krtirtho.spotube.modules.library.playlist - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.unit.dp -import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.Toggle -import io.github.vinceglb.filekit.dialogs.FileKitType -import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher -import io.github.vinceglb.filekit.readBytes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okio.ByteString.Companion.toByteString - -data class PlaylistFormData( - val name: String = "", - val description: String = "", - val isPublic: Boolean = true, - val isCollaborating: Boolean = false, - val imageBase64: String = "", - val imagePreviewUrl: String? = null, -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PlaylistFormSheet( - visible: Boolean, - onDismiss: () -> Unit, - onSubmit: (PlaylistFormData) -> Unit, - modifier: Modifier = Modifier, - initialData: PlaylistFormData? = null, - isEditing: Boolean = false, - breakpointDp: Float = 600f, -) { - if (!visible) return - - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp - - var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") } - var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") } - var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) } - var isCollaborating by remember(initialData) { mutableStateOf(initialData?.isCollaborating ?: false) } - var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } - var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } - - val scope = rememberCoroutineScope() - val imagePicker = rememberFilePickerLauncher( - type = FileKitType.Image - ) { file -> - if (file != null) { - scope.launch { - val bytes = file.readBytes() - val base64 = withContext(Dispatchers.Default) { - bytes.toByteString().base64() - } - imageBase64 = base64 - imagePreviewUrl = null - } - } - } - - val currentData = PlaylistFormData( - name = name, - description = description, - isPublic = isPublic, - isCollaborating = isCollaborating, - imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - ) - - val handleImagePick = { imagePicker.launch() } - - if (isLargeScreen) { - AlertDialog( - onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text(if (isEditing) "Edit Playlist" else "Create Playlist") - }, - text = { - PlaylistFormFields( - name = name, - onNameChange = { name = it }, - description = description, - onDescriptionChange = { description = it }, - isPublic = isPublic, - onIsPublicChange = { isPublic = it }, - isCollaborating = isCollaborating, - onIsCollaboratingChange = { isCollaborating = it }, - imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick, - ) - }, - confirmButton = { - PrimaryButton( - onClick = { - onSubmit(currentData) - onDismiss() - }, - enabled = name.isNotBlank(), - ) { - Text(if (isEditing) "Save" else "Create") - } - }, - dismissButton = { - OutlineButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = if (isEditing) "Edit Playlist" else "Create Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) - PlaylistFormFields( - name = name, - onNameChange = { name = it }, - description = description, - onDescriptionChange = { description = it }, - isPublic = isPublic, - onIsPublicChange = { isPublic = it }, - isCollaborating = isCollaborating, - onIsCollaboratingChange = { isCollaborating = it }, - imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick, - ) - Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - OutlineButton(onClick = onDismiss) { - Text("Cancel") - } - PrimaryButton( - onClick = { - onSubmit(currentData) - onDismiss() - }, - enabled = name.isNotBlank(), - ) { - Text(if (isEditing) "Save" else "Create") - } - } - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} - -@Composable -private fun PlaylistFormFields( - name: String, - onNameChange: (String) -> Unit, - description: String, - onDescriptionChange: (String) -> Unit, - isPublic: Boolean, - onIsPublicChange: (Boolean) -> Unit, - isCollaborating: Boolean, - onIsCollaboratingChange: (Boolean) -> Unit, - imageBase64: String, - imagePreviewUrl: String?, - onPickImage: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextField( - value = name, - onValueChange = onNameChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Playlist name") }, - singleLine = true, - label = { Text("Name") }, - ) - - TextField( - value = description, - onValueChange = onDescriptionChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Description (optional)") }, - maxLines = 3, - label = { Text("Description") }, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { onIsPublicChange(!isPublic) } - .padding(horizontal = 4.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Public", style = MaterialTheme.typography.bodyLarge) - Text( - "Anyone can see this playlist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Toggle(checked = isPublic, onCheckedChange = onIsPublicChange) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { onIsCollaboratingChange(!isCollaborating) } - .padding(horizontal = 4.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Collaborative", style = MaterialTheme.typography.bodyLarge) - Text( - "Others can add tracks to this playlist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Toggle(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Cover Image", style = MaterialTheme.typography.bodyLarge) - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(64.dp) - .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { - when { - imagePreviewUrl != null -> AsyncImage( - model = imagePreviewUrl, - contentDescription = "Playlist cover", - modifier = Modifier.size(64.dp), - contentScale = ContentScale.Crop, - ) - imageBase64.isNotBlank() -> { - Text( - "✓", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - else -> { - Text( - "No image", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - PrimaryButton(onClick = onPickImage) { - Text(if (imageBase64.isNotBlank() || imagePreviewUrl != null) "Change Image" else "Pick Image") - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt deleted file mode 100644 index f93008e2..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ /dev/null @@ -1,284 +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 . - */ - -package dev.krtirtho.spotube.modules.lyrics - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -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.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import compose.icons.FeatherIcons -import compose.icons.feathericons.X -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun LyricsScreen( - viewModel: LyricsViewModel = koinViewModel(), - modifier: Modifier = Modifier, - onClose: (() -> Unit)? = null, -) { - val uiState by viewModel.uiState.collectAsState() - val positionMillis by viewModel.positionMillisFlow.collectAsState() - val scope = rememberCoroutineScope() - - Scaffold( - modifier = modifier, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Box( - modifier = Modifier.fillMaxWidth(), - ) { - if (onClose != null) { - IconButton( - onClick = onClose, - modifier = Modifier.align(Alignment.CenterStart) - ) { - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = "Close" - ) - } - } - Row( - modifier = Modifier - .align(Alignment.Center) - .clip(RoundedCornerShape(50.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(10.dp, 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LyricsModeTab( - label = "Synced", - isSelected = uiState.mode == LyricType.SYNCED, - onClick = { viewModel.setMode(LyricType.SYNCED) } - ) - LyricsModeTab( - label = "Plain", - isSelected = uiState.mode == LyricType.STATIC, - onClick = { viewModel.setMode(LyricType.STATIC) } - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - when { - uiState.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - uiState.error != null -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = uiState.error ?: "Unknown error", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - } - - uiState.mode == LyricType.SYNCED -> { - SyncedLyricsContent( - syncedLyrics = uiState.syncedLyrics ?: emptyList(), - currentIndex = viewModel.currentLyricIndex(positionMillis), - onScrollToIndex = { index -> - scope.launch { - // scroll will be handled by LazyColumn - } - } - ) - } - - uiState.mode == LyricType.STATIC -> { - PlainLyricsContent(plainLyrics = uiState.plainLyrics ?: "") - } - } - } - } -} - -@Composable -private fun LyricsModeTab( - label: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = RoundedCornerShape(20.dp), - color = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surface - }, - contentColor = if (isSelected) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface - } - ) { - Text( - text = label, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - } -} - -@Composable -private fun SyncedLyricsContent( - syncedLyrics: List, - currentIndex: Int, - onScrollToIndex: (Int) -> Unit, -) { - if (syncedLyrics.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "No synced lyrics available", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - return - } - - val listState = rememberLazyListState() - - LaunchedEffect(currentIndex) { - if (currentIndex >= 0) { - listState.animateScrollToItem( - index = currentIndex.coerceIn(0, syncedLyrics.lastIndex), - scrollOffset = -100 - ) - } - } - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - itemsIndexed(syncedLyrics) { index, line -> - val isCurrentLine = index == currentIndex - Text( - text = line.text.ifBlank { "..." }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal, - color = if (isCurrentLine) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ), - textAlign = TextAlign.Center - ) - } - } -} - -@Composable -private fun PlainLyricsContent( - plainLyrics: String, -) { - if (plainLyrics.isBlank()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "No plain lyrics available", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - return - } - - LazyColumn { - item { - Text( - text = plainLyrics, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 30.sp), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt deleted file mode 100644 index 1696ad32..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ /dev/null @@ -1,325 +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 . - */ - -package dev.krtirtho.spotube.modules.playlist - -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.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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -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.rememberCoroutineScope -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 coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.search.SearchRepository -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch -import kotlinx.coroutines.launch -import org.koin.compose.koinInject - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddTracksToPlaylistDialog( - visible: Boolean, - playlistId: String, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - searchRepository: SearchRepository = koinInject(), - libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f, -) { - if (!visible) return - - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp - - var query by remember { mutableStateOf("") } - var searchResults by remember { mutableStateOf>(emptyList()) } - var isSearching by remember { mutableStateOf(false) } - var hasSearched by remember { mutableStateOf(false) } - var addingTrackIds by remember { mutableStateOf>(emptySet()) } - var addedTrackIds by remember { mutableStateOf>(emptySet()) } - val scope = rememberCoroutineScope() - - LaunchedEffect(visible) { - if (!visible) { - query = "" - searchResults = emptyList() - hasSearched = false - addingTrackIds = emptySet() - addedTrackIds = emptySet() - } - } - - val handleSearch = { - if (query.isNotBlank() && !isSearching) { - isSearching = true - hasSearched = true - scope.launch { - val result = searchRepository.searchTracks(query) - searchResults = result.items.map { it.data } - isSearching = false - } - } - } - - val handleAddTrack = { track: MetadataTrack -> - if (!addingTrackIds.contains(track.id) && !addedTrackIds.contains(track.id)) { - addingTrackIds = addingTrackIds + track.id - scope.launch { - libraryRepository.addTracksToPlaylist(playlistId, listOf(track.id)) - addingTrackIds = addingTrackIds - track.id - addedTrackIds = addedTrackIds + track.id - } - } - } - - val content: @Composable () -> Unit = { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = query, - onValueChange = { query = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Search tracks...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) - IconButton(onClick = handleSearch) { - Icon( - imageVector = Iconsax.IconsaxSearch, - contentDescription = "Search", - ) - } - } - - when { - isSearching -> { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - hasSearched && searchResults.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - Text("No tracks found") - } - } - searchResults.isNotEmpty() -> { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(searchResults, key = { it.id }) { track -> - TrackSearchResultRow( - track = track, - isAdding = addingTrackIds.contains(track.id), - isAdded = addedTrackIds.contains(track.id), - onAdd = { handleAddTrack(track) }, - ) - } - } - } - else -> { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - Text("Search for tracks to add") - } - } - } - } - } - - if (isLargeScreen) { - AlertDialog( - onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text("Add Tracks to Playlist") - }, - text = { - Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { - content() - } - }, - confirmButton = { - PrimaryButton(onClick = onDismiss) { - Text("Done") - } - }, - ) - } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .height(600.dp), - ) { - Text( - text = "Add Tracks to Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) - content() - } - } - } -} - -@Composable -private fun TrackSearchResultRow( - track: MetadataTrack, - isAdding: Boolean, - isAdded: Boolean, - onAdd: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - val imageUrl = track.album?.thumbnails?.firstOrNull()?.url - if (imageUrl != null) { - AsyncImage( - model = imageUrl, - contentDescription = track.title, - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(6.dp)), - contentScale = ContentScale.Crop, - ) - } else { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = track.title.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - ) - } - } - - 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, - ) - } - - when { - isAdded -> { - Icon( - imageVector = Iconsax.IconsaxCheckCircle, - contentDescription = "Added", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) - } - isAdding -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } - else -> { - OutlineButton(onClick = onAdd) { - Text("Add") - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt deleted file mode 100644 index 031397ed..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ /dev/null @@ -1,1172 +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 . - */ - -package dev.krtirtho.spotube.modules.plugin - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.crossfade -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.extras.kebabToTitleCase -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.plugin.components.PluginCard -import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog -import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.openUrlInBrowser -import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxBox -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText -import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit -import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxLink -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxSound -import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft -import io.github.vinceglb.filekit.dialogs.FileKitType -import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher -import okio.Path -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.plugin_action_download -import spotube.composeapp.generated.resources.plugin_action_install_from_file -import spotube.composeapp.generated.resources.plugin_configure_title -import spotube.composeapp.generated.resources.plugin_empty_subtitle -import spotube.composeapp.generated.resources.plugin_empty_title -import spotube.composeapp.generated.resources.plugin_error_download_failed -import spotube.composeapp.generated.resources.plugin_error_enter_url -import spotube.composeapp.generated.resources.plugin_error_url_scheme -import spotube.composeapp.generated.resources.plugin_install_section_title -import spotube.composeapp.generated.resources.plugin_installed_count -import spotube.composeapp.generated.resources.plugin_installed_plural -import spotube.composeapp.generated.resources.plugin_installed_singular -import spotube.composeapp.generated.resources.plugin_screen_title -import spotube.composeapp.generated.resources.plugin_section_file_title -import spotube.composeapp.generated.resources.plugin_section_install -import spotube.composeapp.generated.resources.plugin_section_url_title -import spotube.composeapp.generated.resources.plugin_url_placeholder -import spotube.composeapp.generated.resources.settings_plugins_ability_audio -import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics -import spotube.composeapp.generated.resources.settings_plugins_ability_metadata -import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble -import spotube.composeapp.generated.resources.settings_plugins_action_change -import spotube.composeapp.generated.resources.settings_plugins_action_select -import spotube.composeapp.generated.resources.settings_plugins_default_ability_title -import spotube.composeapp.generated.resources.settings_plugins_no_plugins -import spotube.composeapp.generated.resources.settings_plugins_no_selection -import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description - -private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") - -private val VERIFIED_PLUGIN_OWNERS = setOf() - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PluginScreen( - viewModel: PluginViewModel = koinViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val shellBottomInset = LocalAppShellBottomInset.current - - val platform = remember { getPlatform() } - val launcher = rememberFilePickerLauncher( - type = FileKitType.File( - extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug") - ) - ) { file -> - viewModel.onFileSelected(file) - } - - val state = uiState - if (state is PluginUiState.Data) { - state.pendingPlugin?.let { pending -> - PluginPermissionDialog( - pluginInfo = pending.entry, - title = pending.title, - message = pending.message, - confirmLabel = pending.confirmLabel, - existingPlugin = pending.existingEntry, - logoPath = state.pendingPluginLogoPath, - onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { - { viewModel.confirmInstall() } - } else { - null - }, - onDismiss = { viewModel.dismissInstall() } - ) - } - - state.showPluginInfo?.let { plugin -> - PluginInfoDialog( - plugin = plugin, - logoPath = state.pluginInfoLogoPath, - onDismiss = { viewModel.dismissPluginInfo() } - ) - } - - state.showPluginSupport?.let { plugin -> - SupportDialog( - plugin = plugin, - supportText = state.supportText, - isLoading = state.isLoadingSupport, - onDismiss = { viewModel.dismissSupport() } - ) - } - - state.installDialogRepo?.let { repo -> - PluginInstallDialog( - repo = repo, - releases = state.releases, - isLoadingReleases = state.isLoadingReleases, - onDismiss = { viewModel.dismissInstallDialog() }, - onInstall = { release -> - viewModel.dismissInstallDialog() - viewModel.installPlugin(release, repo.id) - } - ) - } - - if (state.showInstallSheet) { - InstallSheet( - urlInput = state.urlInput, - urlError = state.urlError, - isLoadingUrl = state.isLoadingUrl, - onUrlChange = { viewModel.onUrlInputChange(it) }, - onSubmitUrl = { viewModel.submitUrl() }, - onPickFile = { launcher.launch() }, - onDismiss = { viewModel.dismissInstallSheet() } - ) - } - } - - Scaffold( - topBar = { - ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) - } - ) { innerPadding -> - when (val data = uiState) { - is PluginUiState.Loading -> { - Box( - modifier = Modifier.fillMaxSize().padding(innerPadding), - contentAlignment = Alignment.Center - ) { CircularProgressIndicator() } - } - - is PluginUiState.Data -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - val discoverListState = rememberLazyListState() - LazyColumn( - state = discoverListState, - modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, - top = 8.dp, - bottom = 24.dp + shellBottomInset - ), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(Res.string.plugin_configure_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - PrimaryButton(onClick = { viewModel.showInstallSheet() }) { - Icon( - Iconsax.IconsaxAdd, - contentDescription = "Install a plugin", - ) - Text(stringResource(Res.string.plugin_install_section_title)) - } - } - } - - item { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 4.dp) - ) { - data.abilitySelections.forEachIndexed { index, selection -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) - } - DefaultAbilityPluginSelector( - selection = selection, - onSelected = { plugin -> - viewModel.selectPlugin(selection.ability, plugin) - }, - ) - } - } - } - } - - if (data.plugins.isEmpty()) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.size(72.dp) - .clip(RoundedCornerShape(18.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - Text( - stringResource(Res.string.plugin_empty_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - Text( - stringResource(Res.string.plugin_empty_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - item { - val noun = if (data.plugins.size == 1) { - stringResource(Res.string.plugin_installed_singular) - } else { - stringResource(Res.string.plugin_installed_plural) - } - Text( - stringResource( - Res.string.plugin_installed_count, - data.plugins.size, - noun - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - ) - } - item { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - data.plugins.forEachIndexed { index, item -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) - } - PluginCard( - plugin = item.plugin, - isSelected = item.isSelected, - onRemove = { viewModel.removePlugin(item.plugin) }, - isLoggedIn = item.authState.isLoggedIn, - logoPath = item.logoPath, - onInfo = { viewModel.showPluginInfo(item.plugin) }, - onSupport = if (item.isSelected) { - { viewModel.loadSupport(item.plugin) } - } else null, - onLogin = if (item.isSelected && item.authState.requiresAuth) { - { viewModel.login(item.plugin) } - } else null, - onLogout = if (item.isSelected && item.authState.requiresAuth) { - { viewModel.logout(item.plugin) } - } else null, - ) - } - } - } - } - } - - if (data.discover.isLoading || data.discover.repos.isNotEmpty()) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Iconsax.IconsaxGlobe, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - "Discover Plugins", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - } - } - items( - data.discover.repos, - key = { it.id } - ) { repo -> - DiscoverRepoCard( - repo = repo, - isInstalling = data.discover.installingRepoId == repo.id, - onInstall = { viewModel.showInstallDialog(repo) } - ) - } - - if (data.discover.isLoadingMore) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } - } - - data.discover.error?.let { error -> - item { - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding( - horizontal = 4.dp, - vertical = 8.dp - ) - ) - } - } - } - } - - val density = LocalDensity.current - val shouldLoadMore = remember(density) { - derivedStateOf { - val totalItems = discoverListState.layoutInfo.totalItemsCount - val lastVisibleIndex = - discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index - ?: 0 - totalItems > 0 && lastVisibleIndex >= totalItems - 3 - } - } - - // Pagination trigger stays in the composable because it is driven by - // LazyList layout information, which only exists in UI scope. - // The actual loading call is forwarded to the ViewModel. - LaunchedEffect(shouldLoadMore.value) { - if (shouldLoadMore.value) { - viewModel.loadNextDiscoverPage() - } - } - } - } - } - } -} - -@Composable -private fun UrlError.text(): String = when (this) { - is UrlError.Empty -> stringResource(Res.string.plugin_error_enter_url) - is UrlError.InvalidScheme -> stringResource(Res.string.plugin_error_url_scheme) - is UrlError.DownloadFailed -> stringResource(Res.string.plugin_error_download_failed) - is UrlError.Message -> message -} - -@Composable -private fun InstallSheet( - urlInput: String, - urlError: UrlError?, - isLoadingUrl: Boolean, - onUrlChange: (String) -> Unit, - onSubmitUrl: () -> Unit, - onPickFile: () -> Unit, - onDismiss: () -> Unit, -) { - AdaptiveDialogBottomSheet( - onDismiss = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - stringResource(Res.string.plugin_install_section_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - stringResource(Res.string.plugin_section_url_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextField( - value = urlInput, - onValueChange = onUrlChange, - modifier = Modifier.weight(1f), - placeholder = { - Text( - stringResource(Res.string.plugin_url_placeholder), - style = MaterialTheme.typography.bodySmall - ) - }, - leadingIcon = { - Icon( - Iconsax.IconsaxLink, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - isError = urlError != null, - singleLine = true, - ) - SecondaryIconButton( - onClick = onSubmitUrl, - enabled = !isLoadingUrl, - ) { - if (isLoadingUrl) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Icon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = stringResource(Res.string.plugin_action_download), - ) - } - } - } - - urlError?.let { error -> - Text( - error.text(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - - Text( - stringResource(Res.string.plugin_section_file_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - OutlineButton( - onClick = onPickFile, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Iconsax.IconsaxExportArrowBulk, - contentDescription = stringResource(Res.string.plugin_action_install_from_file) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.plugin_action_install_from_file)) - } - } - } -} - -@Composable -private fun PluginInfoDialog( - plugin: PluginEntry, - logoPath: Path?, - onDismiss: () -> Unit, -) { - ThemedDialog( - onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - ) { - if (logoPath != null) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(logoPath.toString()) - .crossfade(true) - .build(), - contentDescription = plugin.name, - modifier = Modifier.fillMaxSize() - ) - } else { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - Text( - plugin.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - }, - actions = { - PrimaryButton(onClick = onDismiss) { - Text("Close") - } - } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - if (plugin.description.isNotBlank()) { - Text( - plugin.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - - DetailRow("Version", plugin.version) - DetailRow("API Version", plugin.apiVersion) - DetailRow("Author", plugin.author) - if (plugin.license.isNotBlank()) { - DetailRow("License", plugin.license) - } - - if (plugin.capabilities.isNotEmpty()) { - DetailChipsRow("Capabilities", plugin.capabilities.map { it.name }) - } - - if (plugin.abilities.isNotEmpty()) { - DetailChipsRow("Abilities", plugin.abilities.map { ability -> - when (ability) { - PluginAbility.METADATA -> "Metadata" - PluginAbility.AUDIO -> "Audio" - PluginAbility.LYRICS -> "Lyrics" - PluginAbility.SCROBBLE -> "Scrobble" - } - }) - } - - if (plugin.repository.isNotBlank()) { - ClickableDetailRow(label = "Repository", value = plugin.repository) { - openUrlInBrowser(plugin.repository) - } - } - - if (plugin.contact.isNotBlank()) { - DetailRow("Contact", plugin.contact) - } - - if (plugin.bugs.isNotBlank()) { - ClickableDetailRow(label = "Report Bugs", value = plugin.bugs) { - openUrlInBrowser(plugin.bugs) - } - } - } - } -} - -@Composable -private fun SupportDialog( - plugin: PluginEntry, - supportText: String?, - isLoading: Boolean, - onDismiss: () -> Unit, -) { - ThemedDialog( - onDismissRequest = onDismiss, - title = { - Text( - "Support ${plugin.name}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - }, - actions = { - PrimaryButton(onClick = onDismiss) { - Text("Close") - } - } - ) { - if (isLoading) { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - Text( - supportText ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - } -} - -@Composable -private fun DiscoverRepoCard( - repo: GitHubRepo, - isInstalling: Boolean, - onInstall: () -> Unit, -) { - val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS - val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(repo.owner.avatarUrl) - .crossfade(true) - .build(), - contentDescription = repo.owner.login, - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - repo.fullName.split("/") - .last() - .replace("spotube-plugin-", "") - .kebabToTitleCase(), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - if (isOfficial) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) - ) { - Text( - "Official", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) - ) - } - } else if (isVerified) { - Surface( - shape = RoundedCornerShape(4.dp), - color = Color(0xFF4CAF50).copy(alpha = 0.15f) - ) { - Row( - modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Icon( - Iconsax.IconsaxCheckCircle, - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = Color(0xFF4CAF50) - ) - Text( - "Verified", - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF4CAF50) - ) - } - } - } - } - if (!repo.description.isNullOrBlank()) { - Text( - repo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - repo.owner.login, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp) - ) { - Icon( - Iconsax.IconsaxHeart, - contentDescription = "Github Stars", - modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - repo.stargazersCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.clickable { - openUrlInBrowser(repo.htmlUrl) - } - ) { - Row( - modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp) - ) { - Icon( - Iconsax.CarbonGithubLogo, - contentDescription = "Github Repository URL", - modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - "github.com", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - SecondaryButton( - onClick = onInstall, - enabled = !isInstalling - ) { - if (isInstalling) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - Iconsax.IconsaxAdd, - contentDescription = null, - ) - } - Text(stringResource(Res.string.plugin_section_install)) - } - } - } -} - -@Composable -private fun DetailRow(label: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.widthIn(min = 90.dp) - ) - Text( - value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } -} - -@Composable -private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.widthIn(min = 90.dp) - ) - Text( - value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -private fun DetailChipsRow(label: String, chips: List) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.widthIn(min = 90.dp) - ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - chips.forEach { chip -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) - ) { - Text( - chip, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - } -} - -@Composable -fun DefaultAbilityPluginSelector( - selection: AbilitySelection, - onSelected: (PluginEntry?) -> Unit = { }, -) { - val plugins = selection.plugins - val selectedPlugin = selection.selectedPlugin - val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) - - val menuItems = buildList { - if (plugins.isNotEmpty()) { - plugins.forEach { plugin -> - val isSelected = selectedPlugin?.name == plugin.name - add( - AdaptiveMenuItem( - label = plugin.name, - onClick = { onSelected(plugin) }, - selected = isSelected, - ) - ) - } - } else { - add( - AdaptiveMenuItem( - label = noPluginsText, - onClick = { }, - enabled = false, - ) - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } - ) { - Icon( - imageVector = when (selection.ability) { - PluginAbility.METADATA -> Iconsax.IconsaxDocumentText - PluginAbility.AUDIO -> Iconsax.IconsaxMusic - PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft - PluginAbility.SCROBBLE -> Iconsax.IconsaxSound - }, - contentDescription = stringResource( - Res.string.settings_plugins_plugin_content_description, - selection.ability.displayLabel() - ), - modifier = Modifier.padding(8.dp), - tint = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource( - Res.string.settings_plugins_default_ability_title, - selection.ability.displayLabel() - ), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - if (selectedPlugin != null) { - Text( - selectedPlugin.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp) - ) - } else { - Text( - stringResource(Res.string.settings_plugins_no_selection), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } - - AdaptiveDropdownBottomSheet( - items = menuItems, - headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, - header = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } - ) { - Icon( - imageVector = when (selection.ability) { - PluginAbility.METADATA -> Iconsax.IconsaxDocumentText - PluginAbility.AUDIO -> Iconsax.IconsaxMusic - PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft - PluginAbility.SCROBBLE -> Iconsax.IconsaxSound - }, - contentDescription = null, - modifier = Modifier.padding(8.dp), - tint = when (selection.ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource( - Res.string.settings_plugins_default_ability_title, - selection.ability.displayLabel() - ), - style = MaterialTheme.typography.titleMedium, - ) - selectedPlugin?.let { - Text( - it.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - }, - trigger = { onClick -> - OutlineButton(onClick = onClick) { - Text( - if (selectedPlugin != null) { - stringResource(Res.string.settings_plugins_action_change) - } else { - stringResource(Res.string.settings_plugins_action_select) + " " - }, - ) - Icon( - imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) - } - }, - ) - } -} - -@Composable -private fun PluginAbility.displayLabel(): String { - return when (this) { - PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) - PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) - PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) - PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt deleted file mode 100644 index 81049853..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt +++ /dev/null @@ -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 . - */ - -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, - isLoadingReleases: Boolean, - onDismiss: () -> Unit, - onInstall: (GitHubRelease) -> Unit, -) { - var selectedRelease by remember { mutableStateOf(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 - ) - } - } - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt deleted file mode 100644 index b641fe89..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ /dev/null @@ -1,349 +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 . - */ - -package dev.krtirtho.spotube.modules.plugin.components - -import androidx.compose.foundation.background -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.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 okio.Path -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.modules.plugin.PluginCapability -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxEye -import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare -import dev.krtirtho.spotube.resources.iconsax.PhosphorDatabaseDuotone -import dev.krtirtho.spotube.resources.iconsax.User -import org.jetbrains.compose.resources.stringResource -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.plugin_permissions_api_diff -import spotube.composeapp.generated.resources.plugin_permissions_author_version -import spotube.composeapp.generated.resources.plugin_permissions_capability_network_desc -import spotube.composeapp.generated.resources.plugin_permissions_capability_network_title -import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_desc -import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_title -import spotube.composeapp.generated.resources.plugin_permissions_capability_webview_desc -import spotube.composeapp.generated.resources.plugin_permissions_capability_webview_title -import spotube.composeapp.generated.resources.plugin_permissions_compare_title -import spotube.composeapp.generated.resources.plugin_permissions_installed_label -import spotube.composeapp.generated.resources.plugin_permissions_none -import spotube.composeapp.generated.resources.plugin_permissions_requested_title -import spotube.composeapp.generated.resources.plugin_permissions_supplied_label -import spotube.composeapp.generated.resources.plugin_version_label -import spotube.composeapp.generated.resources.settings_action_cancel -import spotube.composeapp.generated.resources.settings_action_close - -@Composable -fun PluginPermissionDialog( - pluginInfo: PluginEntry, - title: String, - message: String, - confirmLabel: String?, - existingPlugin: PluginEntry? = null, - logoPath: Path? = null, - onConfirm: (() -> Unit)? = null, - onDismiss: () -> Unit, -) { - ThemedDialog( - onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center - ) { - if (logoPath != null) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(logoPath.toString()) - .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)) { - Text( - title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - pluginInfo.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(top = 8.dp) - ) { - Icon( - Iconsax.User, - contentDescription = null, - modifier = Modifier.size(13.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - pluginInfo.author, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - stringResource( - Res.string.plugin_permissions_author_version, - stringResource(Res.string.plugin_version_label, pluginInfo.version) - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - actions = { - OutlineButton(onClick = onDismiss) { - Text( - if (confirmLabel == null) { - stringResource(Res.string.settings_action_close) - } else { - stringResource(Res.string.settings_action_cancel) - } - ) - } - if (confirmLabel != null && onConfirm != null) { - PrimaryButton(onClick = onConfirm) { - Text(confirmLabel) - } - } - }, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (pluginInfo.description.isNotBlank()) { - Text( - pluginInfo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - Card { - Text( - message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(14.dp) - ) - } - - existingPlugin?.let { - InstalledComparisonCard( - installedPlugin = it, - incomingPlugin = pluginInfo - ) - } - - Text( - stringResource(Res.string.plugin_permissions_requested_title), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - if (pluginInfo.capabilities.isEmpty()) { - Text( - stringResource(Res.string.plugin_permissions_none), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - pluginInfo.capabilities.forEach { capability -> - CapabilityRow(capability) - } - } - } - } -} - -@Composable -private fun InstalledComparisonCard( - installedPlugin: PluginEntry, - incomingPlugin: PluginEntry, -) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - stringResource(Res.string.plugin_permissions_compare_title), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - VersionStat( - label = stringResource(Res.string.plugin_permissions_installed_label), - value = installedPlugin.version, - ) - VersionStat( - label = stringResource(Res.string.plugin_permissions_supplied_label), - value = incomingPlugin.version, - ) - } - Text( - stringResource( - Res.string.plugin_permissions_api_diff, - installedPlugin.apiVersion, - incomingPlugin.apiVersion - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun VersionStat(label: String, value: String) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - stringResource(Res.string.plugin_version_label, value), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - } -} - -@Composable -private fun CapabilityRow(capability: PluginCapability) { - val (icon, label, description) = when (capability) { - PluginCapability.PERSISTENT_STORAGE -> Triple( - Iconsax.PhosphorDatabaseDuotone, - stringResource(Res.string.plugin_permissions_capability_storage_title), - stringResource(Res.string.plugin_permissions_capability_storage_desc) - ) - PluginCapability.NETWORK_REQUESTS -> Triple( - Iconsax.IconsaxWifiSquare, - stringResource(Res.string.plugin_permissions_capability_network_title), - stringResource(Res.string.plugin_permissions_capability_network_desc) - ) - PluginCapability.WEBVIEW -> Triple( - Iconsax.IconsaxEye, - stringResource(Res.string.plugin_permissions_capability_webview_title), - stringResource(Res.string.plugin_permissions_capability_webview_desc) - ) - } - - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) - ) { - Box( - modifier = Modifier - .size(34.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error - ) - } - Column { - Text( - label, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt deleted file mode 100644 index cfd8ed3e..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ /dev/null @@ -1,79 +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 . - */ - -package dev.krtirtho.spotube.modules.settings - -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import kotlinx.serialization.Serializable - -@Serializable -enum class Theme { - LIGHT, DARK, SYSTEM -} - -@Serializable -data class UserSettings( - // Language and Region - val language: SupportedLanguages = SupportedLanguages.EN, - val country: CountryCode = CountryCode.BD, - - // Appearance - val theme: Theme = Theme.SYSTEM, - val accentColor: AccentColors = AccentColors.GREEN_GOBLIN, - - // Playback - val streamingMusicFormat: AudioFormat = AudioFormat( - codec = "opus", - container = "webm", - qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) - ), - val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), - val enableMusicCaching: Boolean = true, - val cacheFolder: String? = null, - val cacheSizeLimitMB: Long = -1L, - val enableEndlessPlayback: Boolean = true, - val enableConnect: Boolean = false, - val playbackProxyServerPort: Int = 14769, - - // Downloads - val overloadedDownloadFolder: String? = null, // When null, uses default music folder - val localMediaFolders: List = emptyList(), - val downloadMusicFormat: AudioFormat = AudioFormat( - codec = "aac", - container = "mp4", - qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) - ), - val downloadMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), - - // Desktop - val minimizeToTray: Boolean = false, - val discordRichPresence: Boolean = true, - - // Updates - val autoCheckForUpdates: Boolean = true, -) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt deleted file mode 100644 index ac6a987f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ /dev/null @@ -1,55 +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 . - */ - -package dev.krtirtho.spotube.modules.settings - -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.spotube.core.db.Database -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.* -import kotlinx.serialization.json.Json - -class SettingsRepository(private val database: Database) { - companion object { - private val SETTINGS_KEY = stringPreferencesKey("user_settings") - } - - val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> - val json = prefs[SETTINGS_KEY] - if (json != null) { - Json.decodeFromString(json as String) - } else { - UserSettings() // Default value - } - }.stateIn( - scope, - started = SharingStarted.Eagerly, - initialValue = UserSettings() // Default value - ) - - suspend fun updateSettings(newSettings: UserSettings) { - database.settingsDataStore.edit { prefs -> - prefs[SETTINGS_KEY] = Json.encodeToString(newSettings) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt deleted file mode 100644 index e420009a..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ /dev/null @@ -1,136 +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 . - */ - -package dev.krtirtho.spotube.modules.settings - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.sections.appearanceSection -import dev.krtirtho.spotube.modules.settings.sections.cacheSection -import dev.krtirtho.spotube.modules.settings.sections.desktopSection -import dev.krtirtho.spotube.modules.settings.sections.downloadsSection -import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection -import dev.krtirtho.spotube.modules.settings.sections.playbackSection -import dev.krtirtho.spotube.modules.settings.sections.pluginsSection -import dev.krtirtho.spotube.modules.settings.sections.updatesSection -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen(settingsViewModel: SettingsViewModel) { - val navigatorCommands = koinInject() - val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() - val platformType = remember { getPlatform().type } - val isDesktopPlatform = platformType == PlatformType.Windows || - platformType == PlatformType.Linux || - platformType == PlatformType.MacOS - - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - Scaffold( - topBar = { - ApplicationMainBar( - title = { - Text(stringResource(Res.string.settings_screen_title)) - }, - backButton = false - ) - } - ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - LazyColumn( - modifier = Modifier - .widthIn(max = 1280.dp) - .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { - pluginsSection( - navigatorCommands = navigatorCommands, - ) - if (settingsState != null) { - languageRegionSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - } - if (settingsState != null) - appearanceSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - if (settingsState != null) - playbackSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - navigatorCommands = navigatorCommands, - ) - if (settingsState != null) - cacheSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - if (settingsState != null) - downloadsSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - if (isDesktopPlatform && settingsState != null) { - desktopSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - } - if (settingsState != null) - updatesSection( - settings = settingsState!!, - settingsViewModel = settingsViewModel, - ) - } - - } - } -} - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt deleted file mode 100644 index 35736f3c..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ /dev/null @@ -1,289 +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 . - */ - -package dev.krtirtho.spotube.modules.settings.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.Radio -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.base.Toggle -import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import spotube.composeapp.generated.resources.* -import org.jetbrains.compose.resources.stringResource - -@Composable -internal fun SwitchSettingCard( - title: String, - subtitle: String? = null, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - icon: (@Composable () -> Unit)? = null, -) { - SettingCardItem( - title = title, - subtitle = subtitle, - icon = icon, - trailingContent = { - Toggle( - checked = checked, - onCheckedChange = onCheckedChange, - ) - }, - onClick = { - onCheckedChange(!checked) - } - ) -} - -@Composable -internal fun SelectionSettingCard( - title: String, - subtitle: String? = null, - selectedOption: T, - options: List, - optionLabel: @Composable (T) -> String, - onOptionSelected: (T) -> Unit, - dialogTitle: String = title, - icon: (@Composable () -> Unit)? = null, - inlineSelectorMinWidth: Dp = 700.dp, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { - var isDialogOpen by remember { mutableStateOf(false) } - - BoxWithConstraints { - val isWideLayout = maxWidth >= inlineSelectorMinWidth - - SettingCardItem( - title = title, - subtitle = subtitle, - icon = icon, - trailingContent = { - AdaptiveDropdownBottomSheet( - items = options.map { option -> - AdaptiveMenuItem( - label = optionLabel(option), - onClick = { onOptionSelected(option) }, - selected = option == selectedOption, - ) - }, - trigger = { onClick -> - OutlineButton(onClick = onClick) { - Text(optionLabel(selectedOption)) - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) - } - }, - filter = filter, - ) - }, - onClick = { - if (isWideLayout) { - isDialogOpen = true - } - } - ) - - if (!isWideLayout && isDialogOpen) { - ThemedDialog( - onDismissRequest = { isDialogOpen = false }, - title = { - Text(dialogTitle, style = MaterialTheme.typography.titleLarge) - }, - actions = { - OutlineButton(onClick = { isDialogOpen = false }) { - Text(stringResource(Res.string.settings_action_close)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - options.forEach { option -> - val isSelected = option == selectedOption - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable { - onOptionSelected(option) - isDialogOpen = false - } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Radio( - selected = isSelected, - onClick = { - onOptionSelected(option) - isDialogOpen = false - } - ) - Text( - text = optionLabel(option), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - } - } -} - -@Composable -internal fun TextInputSettingCard( - title: String, - subtitle: String? = null, - value: String, - onValueSaved: (String) -> Unit, - dialogTitle: String = title, - dialogDescription: String? = null, - placeholder: String = "", - normalize: (String) -> String = { it.trim() }, - validate: (String) -> String? = { null }, - icon: (@Composable () -> Unit)? = null, - inlineTextFieldMinWidth: Dp = 700.dp, - inlineTextFieldWidth: Dp = 220.dp, - enabled: Boolean = true, -) { - var isDialogOpen by remember { mutableStateOf(false) } - - BoxWithConstraints { - val isWideLayout = maxWidth >= inlineTextFieldMinWidth - var inlineDraft by remember(value, isWideLayout) { mutableStateOf(value) } - - SettingCardItem( - enabled = enabled, - title = title, - subtitle = subtitle, - icon = icon, - trailingContent = if (isWideLayout) { - { - TextField( - value = inlineDraft, - onValueChange = { draft -> - inlineDraft = draft - val normalizedValue = normalize(draft) - if (validate(normalizedValue) == null) { - onValueSaved(normalizedValue) - } - }, - modifier = Modifier.width(inlineTextFieldWidth), - placeholder = if (placeholder.isNotEmpty()) { - { Text(placeholder) } - } else { - null - }, - singleLine = true, - enabled = enabled, - ) - } - } else { - null - }, - onClick = { - if (!isWideLayout) { - isDialogOpen = true - } - } - ) - - if (!isWideLayout && isDialogOpen) { - var draft by remember(value, isDialogOpen) { mutableStateOf(value) } - val normalizedValue = normalize(draft) - val errorMessage = validate(normalizedValue) - - ThemedDialog( - onDismissRequest = { isDialogOpen = false }, - title = { - Text(dialogTitle, style = MaterialTheme.typography.titleLarge) - }, - actions = { - PrimaryButton( - onClick = { - if (errorMessage == null) { - onValueSaved(normalizedValue) - isDialogOpen = false - } - } - ) { - Text(stringResource(Res.string.settings_action_save)) - } - OutlineButton(onClick = { isDialogOpen = false }) { - Text(stringResource(Res.string.settings_action_cancel)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - dialogDescription?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = if (placeholder.isNotEmpty()) { - { Text(placeholder) } - } else { - null - }, - isError = errorMessage != null, - supportingText = errorMessage?.let { message -> - { Text(message) } - }, - singleLine = true, - ) - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt deleted file mode 100644 index 0838fc75..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ /dev/null @@ -1,318 +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 . - */ - -package dev.krtirtho.spotube.modules.settings.sections - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.Droplet -import compose.icons.feathericons.Monitor -import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.Radio -import dev.krtirtho.spotube.modules.settings.AccentColors -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.Theme -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch -import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare -import org.jetbrains.compose.resources.stringResource -import spotube.composeapp.generated.resources.* - -internal fun LazyListScope.appearanceSection( - settings: UserSettings, - settingsViewModel: SettingsViewModel, -) { - settingsSectionHeader(Res.string.settings_section_appearance) - settingsSectionCard( - items = listOf( - { - SelectionSettingCard( - title = stringResource(Res.string.settings_theme_title), - subtitle = stringResource( - Res.string.settings_theme_subtitle_current, - settings.theme.displayLabel() - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxColorSwatch, stringResource(Res.string.settings_theme_title)) - }, - selectedOption = settings.theme, - options = Theme.entries, - optionLabel = { it.displayLabel() }, - onOptionSelected = { theme -> - settingsViewModel.updateSettings { - copy(theme = theme) - } - } - ) - }, - { - AccentColorSettingCard( - selectedAccent = settings.accentColor, - icon = { - SettingsItemIcon(Iconsax.IconsaxColorsSquare, stringResource(Res.string.settings_accent_title)) - }, - onColorSaved = { accent -> - settingsViewModel.updateSettings { - copy(accentColor = accent) - } - } - ) - }, - ) - ) -} - -@Composable -private fun Theme.displayLabel(): String { - return when (this) { - Theme.LIGHT -> stringResource(Res.string.settings_theme_light) - Theme.DARK -> stringResource(Res.string.settings_theme_dark) - Theme.SYSTEM -> stringResource(Res.string.settings_theme_system) - } -} - -@Composable -private fun AccentColors.displayLabel(): String { - return when (this) { - AccentColors.GREEN_GOBLIN -> stringResource(Res.string.settings_accent_green_goblin) - AccentColors.ELECTRIC_VIOLET -> stringResource(Res.string.settings_accent_electric_violet) - AccentColors.OCEANIC_CYAN -> stringResource(Res.string.settings_accent_oceanic_cyan) - AccentColors.SUNSET_ORANGE -> stringResource(Res.string.settings_accent_sunset_orange) - AccentColors.ROSE_GARDEN -> stringResource(Res.string.settings_accent_rose_garden) - AccentColors.MIDNIGHT_BLUE -> stringResource(Res.string.settings_accent_midnight_blue) - AccentColors.METALLIC_SLATE -> stringResource(Res.string.settings_accent_metallic_slate) - } -} - -@Composable -private fun AccentColorSettingCard( - selectedAccent: AccentColors, - icon: (@Composable () -> Unit)? = null, - onColorSaved: (AccentColors) -> Unit, -) { - var isDialogOpen by remember { mutableStateOf(false) } - - SettingCardItem( - title = stringResource(Res.string.settings_accent_title), - subtitle = stringResource( - Res.string.settings_accent_subtitle_current, - selectedAccent.displayLabel() - ), - icon = icon, - trailingContent = { - AccentDualPreview( - lightAccent = selectedAccent.toLightColor(), - darkAccent = selectedAccent.toDarkColor(), - ) - }, - onClick = { - isDialogOpen = true - } - ) - - if (isDialogOpen) { - var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) } - - ThemedDialog( - onDismissRequest = { isDialogOpen = false }, - title = { - Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge) - }, - actions = { - PrimaryButton( - onClick = { - onColorSaved(draftAccent) - isDialogOpen = false - } - ) { - Text(stringResource(Res.string.settings_action_save)) - } - OutlineButton(onClick = { isDialogOpen = false }) { - Text(stringResource(Res.string.settings_action_cancel)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(Res.string.settings_accent_dialog_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - AccentThemePreview( - title = stringResource(Res.string.settings_preview_light), - accent = draftAccent.toLightColor(), - background = Color(0xFFFFFFFF), - textColor = Color(0xFF121212), - modifier = Modifier.weight(1f) - ) - AccentThemePreview( - title = stringResource(Res.string.settings_preview_dark), - accent = draftAccent.toDarkColor(), - background = Color(0xFF121212), - textColor = Color(0xFFEDEDED), - modifier = Modifier.weight(1f) - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - AccentColors.entries.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { draftAccent = option } - .padding(horizontal = 4.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Radio( - selected = option == draftAccent, - onClick = { draftAccent = option } - ) - Text( - text = option.displayLabel(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - AccentDualPreview( - lightAccent = option.toLightColor(), - darkAccent = option.toDarkColor(), - ) - } - } - } - } - } - } -} - -@Composable -private fun AccentDualPreview( - lightAccent: Color, - darkAccent: Color, -) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box( - modifier = Modifier - .size(20.dp) - .background(Color.White, RoundedCornerShape(6.dp)) - .border(1.dp, Color(0x22000000), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(10.dp) - .background(lightAccent, CircleShape) - ) - } - Box( - modifier = Modifier - .size(20.dp) - .background(Color(0xFF121212), RoundedCornerShape(6.dp)) - .border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(10.dp) - .background(darkAccent, CircleShape) - ) - } - } -} - -@Composable -private fun AccentThemePreview( - title: String, - accent: Color, - background: Color, - textColor: Color, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .background(background, RoundedCornerShape(10.dp)) - .border(1.dp, textColor.copy(alpha = 0.16f), RoundedCornerShape(10.dp)) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(title, style = MaterialTheme.typography.labelMedium, color = textColor) - Box( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .background(accent, RoundedCornerShape(999.dp)) - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Box( - modifier = Modifier - .size(14.dp) - .background(accent, CircleShape) - ) - Text( - stringResource(Res.string.settings_preview_label), - style = MaterialTheme.typography.bodySmall, - color = textColor - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - stringResource(Res.string.settings_preview_button), - style = MaterialTheme.typography.labelSmall, - color = accent - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt deleted file mode 100644 index 5750a811..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ /dev/null @@ -1,339 +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 . - */ - -package dev.krtirtho.spotube.modules.settings.sections - -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -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.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting -import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher -import io.github.vinceglb.filekit.path -import org.jetbrains.compose.resources.stringResource -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.settings_action_cancel -import spotube.composeapp.generated.resources.settings_action_save -import spotube.composeapp.generated.resources.settings_download_folder_current -import spotube.composeapp.generated.resources.settings_download_folder_default -import spotube.composeapp.generated.resources.settings_download_folder_title -import spotube.composeapp.generated.resources.settings_download_format_subtitle_current -import spotube.composeapp.generated.resources.settings_download_format_title -import spotube.composeapp.generated.resources.settings_download_quality_title -import spotube.composeapp.generated.resources.settings_local_media_folders_add_action -import spotube.composeapp.generated.resources.settings_local_media_folders_description -import spotube.composeapp.generated.resources.settings_local_media_folders_manage -import spotube.composeapp.generated.resources.settings_local_media_folders_none_added -import spotube.composeapp.generated.resources.settings_local_media_folders_remove_action -import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_current -import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty -import spotube.composeapp.generated.resources.settings_local_media_folders_title -import spotube.composeapp.generated.resources.settings_section_downloads -import spotube.composeapp.generated.resources.settings_subtitle_current - -internal fun LazyListScope.downloadsSection( - settings: UserSettings, - settingsViewModel: SettingsViewModel, -) { - val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) - val downloadQualities = availableAudioQualities( - format = settings.downloadMusicFormat, - current = settings.downloadMusicQuality, - ) - - settingsSectionHeader(Res.string.settings_section_downloads) - settingsSectionCard( - items = listOf( - { - DownloadFolderSettingCard( - folder = settings.overloadedDownloadFolder, - onFolderSelected = { folder -> - settingsViewModel.updateSettings { - copy(overloadedDownloadFolder = folder) - } - } - ) - }, - { - LocalMediaFoldersSettingCard( - folders = settings.localMediaFolders, - onFoldersSaved = { folders -> - settingsViewModel.updateSettings { - copy(localMediaFolders = folders) - } - } - ) - }, - { - SelectionSettingCard( - title = stringResource(Res.string.settings_download_format_title), - subtitle = stringResource( - Res.string.settings_download_format_subtitle_current, - settings.downloadMusicFormat.displayLabel() - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_download_format_title)) - }, - selectedOption = settings.downloadMusicFormat, - options = downloadFormats, - optionLabel = { it.displayLabel() }, - onOptionSelected = { format -> - settingsViewModel.updateSettings { - copy( - downloadMusicFormat = format, - downloadMusicQuality = format.resolveQuality(downloadMusicQuality), - ) - } - } - ) - }, - { - SelectionSettingCard( - title = stringResource(Res.string.settings_download_quality_title), - subtitle = stringResource( - Res.string.settings_subtitle_current, - settings.downloadMusicQuality.displayLabel() - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_download_quality_title)) - }, - selectedOption = settings.downloadMusicQuality, - options = downloadQualities, - optionLabel = { it.displayLabel() }, - onOptionSelected = { quality -> - settingsViewModel.updateSettings { - copy(downloadMusicQuality = quality) - } - } - ) - }, - ) - ) -} - -@Composable -private fun DownloadFolderSettingCard( - folder: String?, - onFolderSelected: (String?) -> Unit, -) { - val pickerLauncher = rememberDirectoryPickerLauncher { directory -> - if (directory != null) { - val normalized = normalizePath(directory.path) - if (normalized.isNotEmpty()) { - onFolderSelected(normalized) - } - } - } - - SettingCardItem( - title = stringResource(Res.string.settings_download_folder_title), - subtitle = folder?.let { - stringResource(Res.string.settings_download_folder_current, it) - } ?: stringResource(Res.string.settings_download_folder_default), - icon = { - SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title)) - }, - trailingContent = { - GhostIconButton(onClick = { pickerLauncher.launch() }) { - Icon( - imageVector = Iconsax.IconsaxFolderAdd, - contentDescription = stringResource(Res.string.settings_download_folder_title) - ) - } - }, - onClick = { - pickerLauncher.launch() - } - ) -} - -@Composable -private fun LocalMediaFoldersSettingCard( - folders: List, - onFoldersSaved: (List) -> Unit, -) { - var isDialogOpen by remember { mutableStateOf(false) } - - val subtitlePreview = when { - folders.isEmpty() -> stringResource(Res.string.settings_local_media_folders_subtitle_empty) - folders.size == 1 -> folders.first() - else -> stringResource( - Res.string.settings_local_media_folders_subtitle_current, - folders.size, - ) - } - - SettingCardItem( - title = stringResource(Res.string.settings_local_media_folders_title), - subtitle = subtitlePreview, - icon = { - SettingsItemIcon( - Iconsax.IconsaxFolder, - stringResource(Res.string.settings_local_media_folders_title), - ) - }, - trailingContent = { - OutlineButton(onClick = { isDialogOpen = true }) { - Text(stringResource(Res.string.settings_local_media_folders_manage)) - } - }, - onClick = { - isDialogOpen = true - } - ) - - if (isDialogOpen) { - LocalMediaFoldersDialog( - folders = folders, - onDismiss = { isDialogOpen = false }, - onFoldersSaved = onFoldersSaved, - ) - } -} - -@Composable -private fun LocalMediaFoldersDialog( - folders: List, - onDismiss: () -> Unit, - onFoldersSaved: (List) -> Unit, -) { - val draftFolders = remember(folders) { - mutableStateListOf().apply { addAll(folders) } - } - val pickerLauncher = rememberDirectoryPickerLauncher { directory -> - if (directory != null) { - val normalized = normalizePath(directory.path) - if (normalized.isNotEmpty() && draftFolders.none { normalizePath(it) == normalized }) { - draftFolders.add(normalized) - } - } - } - - ThemedDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(Res.string.settings_local_media_folders_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - actions = { - PrimaryButton( - onClick = { - onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) - onDismiss() - } - ) { - Text(stringResource(Res.string.settings_action_save)) - } - OutlineButton(onClick = onDismiss) { - Text(stringResource(Res.string.settings_action_cancel)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(Res.string.settings_local_media_folders_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - OutlineButton(onClick = { pickerLauncher.launch() }) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Iconsax.IconsaxAddSquare, contentDescription = null) - Text(stringResource(Res.string.settings_local_media_folders_add_action)) - } - } - - if (draftFolders.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Text( - text = stringResource(Res.string.settings_local_media_folders_none_added), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - draftFolders.forEachIndexed { index, folder -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = folder, - modifier = Modifier.weight(1f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) - GhostIconButton( - onClick = { - if (index in draftFolders.indices) { - draftFolders.removeAt(index) - } - } - ) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = stringResource( - Res.string.settings_local_media_folders_remove_action, - ), - ) - } - } - } - } - } - } - } -} - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt deleted file mode 100644 index 4dee2d85..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ /dev/null @@ -1,195 +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 . - */ - -package dev.krtirtho.spotube.modules.settings.sections - -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import compose.icons.FeatherIcons -import compose.icons.feathericons.ChevronRight -import compose.icons.feathericons.Cast -import compose.icons.feathericons.Radio -import compose.icons.feathericons.Repeat -import compose.icons.feathericons.Server -import compose.icons.feathericons.Sliders -import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard -import dev.krtirtho.spotube.resources.iconsax.CustomServer -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatArrow -import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting -import org.jetbrains.compose.resources.stringResource - -internal fun LazyListScope.playbackSection( - settings: UserSettings, - settingsViewModel: SettingsViewModel, - navigatorCommands: NavigationCommands, -) { - val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) - val streamingQualities = availableAudioQualities( - format = settings.streamingMusicFormat, - current = settings.streamingMusicQuality, - ) - - settingsSectionHeader(Res.string.settings_section_playback) - settingsSectionCard( - items = listOf( - { - SelectionSettingCard( - title = stringResource(Res.string.settings_streaming_format_title), - subtitle = stringResource( - Res.string.settings_streaming_format_subtitle_current, - settings.streamingMusicFormat.displayLabel() - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title)) - }, - selectedOption = settings.streamingMusicFormat, - options = streamingFormats, - optionLabel = { it.displayLabel() }, - onOptionSelected = { format -> - settingsViewModel.updateSettings { - copy( - streamingMusicFormat = format, - streamingMusicQuality = format.resolveQuality(streamingMusicQuality), - ) - } - } - ) - }, - { - SelectionSettingCard( - title = stringResource(Res.string.settings_streaming_quality_title), - subtitle = stringResource( - Res.string.settings_subtitle_current, - settings.streamingMusicQuality.displayLabel() - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title)) - }, - selectedOption = settings.streamingMusicQuality, - options = streamingQualities, - optionLabel = { it.displayLabel() }, - onOptionSelected = { quality -> - settingsViewModel.updateSettings { - copy(streamingMusicQuality = quality) - } - } - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_endless_playback_title), - subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), - icon = { - SettingsItemIcon( - Iconsax.IconsaxRepeatArrow, - stringResource(Res.string.settings_enable_endless_playback_title) - ) - }, - checked = settings.enableEndlessPlayback, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableEndlessPlayback = enabled) - } - } - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_connect_title), - subtitle = stringResource(Res.string.settings_enable_connect_subtitle), - icon = { - SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title)) - }, - checked = settings.enableConnect, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableConnect = enabled) - } - } - ) - }, - { - val error_whole_number = stringResource(Res.string.settings_error_whole_number) - val error_port_range = stringResource(Res.string.settings_error_port_range) - - TextInputSettingCard( - title = stringResource(Res.string.settings_playback_port_title), - subtitle = stringResource( - Res.string.settings_playback_port_subtitle_current, - settings.playbackProxyServerPort - ), - icon = { - SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title)) - }, - value = settings.playbackProxyServerPort.toString(), - dialogDescription = stringResource(Res.string.settings_playback_port_description), - placeholder = stringResource(Res.string.settings_playback_port_placeholder), - normalize = { it.trim() }, - validate = { value -> - val port = value.toIntOrNull() - when { - port == null -> error_whole_number - port !in 1..65535 -> error_port_range - else -> null - } - }, - onValueSaved = { value -> - settingsViewModel.updateSettings { - copy(playbackProxyServerPort = value.toInt()) - } - } - ) - }, - { - 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) - }, - ) - }, - ) - ) -} - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt deleted file mode 100644 index 4603e3e7..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ /dev/null @@ -1,113 +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 . - */ - -package dev.krtirtho.spotube.modules.shell - -import androidx.compose.foundation.clickable -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.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.navigation.NavigationState -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator -import dev.krtirtho.spotube.tabs - -@Composable -fun AppBottombar( - navigator: Navigator, - navigationState: NavigationState, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), - shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - shadowElevation = 10.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(80.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - tabs.forEach { (label, icon, activeIcon, screen) -> - val selected = navigationState.topLevelRoute == screen - val labelColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - Column( - modifier = Modifier - .clip(CircleShape) - .clickable { navigator.navigate(screen) } - .padding(8.dp) - .size(56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Box { - Icon( - imageVector = activeIcon, - contentDescription = label, - tint = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - if (screen == Routes.Library) { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, - color = labelColor, - ) - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt deleted file mode 100644 index 9d7ffbd1..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ /dev/null @@ -1,416 +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 . - */ - -package dev.krtirtho.spotube.modules.shell - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -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.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeColorEffect -import dev.chrisbanes.haze.blur.blurEffect -import dev.chrisbanes.haze.hazeEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -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.LoopState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.Slider -import dev.krtirtho.spotube.core.ui.base.VariableIconButton -import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle -import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle -import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross -import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh -import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow -import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 -import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf -import kotlin.time.Duration.Companion.milliseconds - - -// The main player on large screens shown at the bottom of the screen permanently. It should be -// below the sidebar. And should be similarly give a floating vibe with a translucent background -// just like the AppBottombar. -// -// On the left it contains the album art, song title, artists and like btn grouped together -// On the center it contains the playback controls and the progress bar -// On the right it contains the queue btn, download button btn, alternative track source button -// and three dots for options grouped together and below these buttons the volume slider will be shown -@Composable -fun AppLargePlayer( - modifier: Modifier = Modifier, - hazeState: HazeState, - onQueue: () -> Unit = {}, - onAlternativeSource: () -> Unit = {}, - onMoreOptions: () -> Unit = {}, - onLyrics: () -> Unit = {}, - audioPlayer: AudioPlayerInterface = koinInject(), - audioPlayerQueue: AudioPlayerQueue = koinInject(), - downloadsViewModel: DownloadsViewModel = koinViewModel(), - savedTracksViewModel: SavedTracksViewModel = koinViewModel( - key = SAVED_TRACKS_COLLECTION_ID, - parameters = { parametersOf() } - ), -) { - val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) - val scope = rememberCoroutineScope() - val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() - var isSeeking by remember { mutableStateOf(false) } - var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } - - LaunchedEffect(playerUiState.progress, isSeeking) { - if (!isSeeking) { - seekProgress = playerUiState.progress - } - } - - LaunchedEffect(playerUiState.volume) { - if (playerUiState.volume > 0f) { - lastNonZeroVolume = playerUiState.volume - } - } - - fun onPlayPause() { - scope.launch { - if (playerUiState.isPlaying) { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - } - - fun onSkipPrevious() { - scope.launch { audioPlayer.skipToPrevious() } - } - - fun onSkipNext() { - scope.launch { audioPlayer.skipToNext() } - } - - fun onShuffleToggle() { - scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } - } - - fun onLoopToggle() { - scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } - } - - fun onSeekFinished() { - val durationMillis = playerUiState.seekDuration.inWholeMilliseconds - if (durationMillis <= 0L) return - scope.launch { - audioPlayer.seekTo( - (durationMillis * seekProgress.coerceIn( - 0f, - 1f - )).toLong().milliseconds - ) - } - } - - val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) - - Surface( - modifier = modifier - .windowInsetsPadding(WindowInsets.navigationBars), - color = Color.Transparent, - shadowElevation = 0.dp, - ) { - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = Color.Gray, - thickness = 1.dp, - ) - Box( - modifier = Modifier - .fillMaxWidth() - .hazeEffect(state = hazeState) { - blurEffect { - blurRadius = 20.dp - colorEffects = listOf( - HazeColorEffect.tint(surfaceTint) - ) - } - } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - ) { - AsyncImage( - model = coverModel, - contentDescription = playerUiState.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - Column(modifier = Modifier.padding(start = 12.dp)) { - Text( - text = playerUiState.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = playerUiState.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(modifier = Modifier.width(10.dp)) - PlayerHeartButton( - audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, - ) - } - - Column( - modifier = Modifier.weight(1.1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Slider( - value = seekProgress, - onValueChange = { - seekProgress = it - isSeeking = true - }, - onValueChangeFinished = { - isSeeking = false - onSeekFinished() - }, - enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, - modifier = Modifier.fillMaxWidth(), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = formatPlayerTime(playerUiState.position), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = formatPlayerTime(playerUiState.displayDuration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - VariableIconButton( - onClick = ::onShuffleToggle, - variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost - ) { - Icon( - Iconsax.IconsaxShuffle, - contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", - tint = if (playerUiState.isShuffling) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - GhostIconButton(onClick = ::onSkipPrevious) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = ::onPlayPause, - theme = invertedButtonStyle().copyShape(CircleShape), - modifier = Modifier - .size(50.dp), - ) { - Icon( - if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", - ) - } - GhostIconButton(onClick = ::onSkipNext) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") - } - VariableIconButton( - onClick = ::onLoopToggle, - variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline - ) { - Icon( - imageVector = when (playerUiState.loopState) { - LoopState.NONE -> Iconsax.IconsaxRepeateMusic - LoopState.ONE -> Iconsax.IconsaxRepeateOne - LoopState.ALL -> Iconsax.IconsaxRepeatMusic - }, - contentDescription = "Loop mode ${playerUiState.loopState.name}", - tint = if (playerUiState.loopState == LoopState.NONE) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.primary - } - ) - } - } - } - - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.End - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton(onClick = onQueue) { - Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") - } - GhostIconButton(onClick = { - val track = (currentEntry as? QueueEntry.StreamingTrack)?.track - if (track != null) { - downloadsViewModel.downloadTrack(track) - } - }) { - DownloadProgressIcon( - track = (currentEntry as? QueueEntry.StreamingTrack)?.track, - icon = Iconsax.IconsaxDirectboxReceive, - contentDescription = "Download", - ) - } - GhostIconButton(onClick = onAlternativeSource) { - Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source") - } - GhostIconButton(onClick = onLyrics) { - Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") - } - GhostIconButton(onClick = onMoreOptions) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton( - onClick = { - scope.launch { - if (playerUiState.volume <= 0f) { - audioPlayer.setVolume(lastNonZeroVolume) - } else { - lastNonZeroVolume = playerUiState.volume - audioPlayer.setVolume(0f) - } - } - } - ) { - val volumeIcon = when { - playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross - playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow - else -> Iconsax.IconsaxVolumeHigh - } - Icon( - imageVector = volumeIcon, - contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute" - ) - } - Slider( - value = playerUiState.volume, - onValueChange = { - lastNonZeroVolume = it - scope.launch { - audioPlayer.setVolume(it) - } - }, - modifier = Modifier.fillMaxWidth(0.62f) - ) - } - } - } - } - } - -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt deleted file mode 100644 index a3f7e1c3..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ /dev/null @@ -1,371 +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 . - */ - -package dev.krtirtho.spotube.modules.shell - -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxWithConstraintsScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetValue -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.NavigationState -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.modules.lyrics.LyricsScreen -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel -import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackSheet -import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContent -import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel -import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet -import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel - -val LocalAppShellBottomInset = staticCompositionLocalOf { 0.dp } - -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) -@Composable -fun AppShell( - navigator: Navigator, - navigationState: NavigationState, - viewModel: AppShellViewModel = koinViewModel(), - queueViewModel: PlayerQueueContentViewModel = koinViewModel(), - alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel(), - content: @Composable () -> Unit, -) { - val navigatorCommands: NavigationCommands = koinInject() - val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() - val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() - val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() - - LaunchedEffect(navigatorCommands, navigator) { - launch { - navigatorCommands.navigationCommandFlow.collect { route -> - navigator.navigate(route) - } - } - launch { - navigatorCommands.navigationPopCommandFlow.collect { route -> - navigationState.backStacks[navigationState.topLevelRoute]?.let { backStack -> - backStack.lastOrNull()?.let { currentRoute -> - if (route == null || route == currentRoute) { - navigator.pop() - } - } - } - } - } - } - - Box(modifier = Modifier.fillMaxSize()) { - val useSidebar = viewModel.useSidebar() - val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) - - CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) { - if (useSidebar) { - val hazeState = rememberHazeState() - val bgHazeState = rememberHazeState() - - Box(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .hazeSource(state = bgHazeState) // <-- Put it here! - ) - Row(modifier = Modifier.fillMaxSize()) { - AppSidebar( - hazeState = bgHazeState, - navigator = navigator, - navigationState = navigationState - ) - VerticalDivider( - modifier = Modifier.fillMaxHeight(), - color = Color.Gray.copy(alpha = 0.2f), - thickness = 1.dp, - ) - Box(modifier = Modifier.weight(1f).hazeSource(hazeState)) { - content() - } - } - - if (isQueueVisible) { - Box( - modifier = Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { queueViewModel.setQueueVisibility(false) }, - ) - ) - } - - if (isAlternativeVisible) { - Box( - modifier = Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { alternativeViewModel.setAlternativeVisibility(false) }, - ) - ) - } - - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { queueViewModel.setQueueVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { - PlayerQueueContent( - viewModel = queueViewModel, - modifier = Modifier.fillMaxSize(), - ) - } - - AlternativeTrackSheet( - isVisible = isAlternativeVisible, - onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { - AlternativeTrackContent( - viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize(), - ) - } - } - AppLargePlayer( - hazeState = hazeState, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onQueue = queueViewModel::toggleQueueVisibility, - onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, - onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }, - ) - } - } else { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - CompactPlayerOverlay( - navigator = navigator, - navigationState = navigationState, - viewModel = viewModel, - onQueue = queueViewModel::toggleQueueVisibility, - onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, - onExpandLyrics = viewModel::showLyricsOverlay, - content = content, - ) - - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { queueViewModel.setQueueVisibility(false) }, - modifier = Modifier.fillMaxSize() - ) { - PlayerQueueContent( - viewModel = queueViewModel, - modifier = Modifier.fillMaxSize(), - ) - } - - AlternativeTrackSheet( - isVisible = isAlternativeVisible, - onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { - AlternativeTrackContent( - viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize(), - ) - } - - if (isLyricsOverlayVisible) { - Dialog( - onDismissRequest = { viewModel.hideLyricsOverlay() }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnBackPress = true, - dismissOnClickOutside = true, - ), - ) { - Box(modifier = Modifier.fillMaxSize()) { - LyricsScreen( - modifier = Modifier.fillMaxSize(), - onClose = { viewModel.hideLyricsOverlay() }, - ) - } - } - } - } - } - } - } -} - -@OptIn( - ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class, - ExperimentalMaterial3Api::class -) -@Composable -private fun BoxWithConstraintsScope.CompactPlayerOverlay( - navigator: Navigator, - navigationState: NavigationState, - viewModel: AppShellViewModel, - onQueue: () -> Unit, - onAlternativeSource: () -> Unit, - onExpandLyrics: () -> Unit, - content: @Composable () -> Unit, -) { - val density = LocalDensity.current - val scope = rememberCoroutineScope() - - val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() } - val bottomBarHeightPx = with(density) { viewModel.bottomBarHeight.toPx() } - val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() - val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx - val peekHeight = with(density) { peekHeightPx.toDp() } - - val sheetHeightPx = with(density) { maxHeight.toPx() } - - val sheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded, - skipHiddenState = true, - ) - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = sheetState, - ) - - val progressState = remember { mutableFloatStateOf(0f) } - LaunchedEffect(sheetState, sheetHeightPx, peekHeightPx) { - snapshotFlow { - try { - sheetState.requireOffset() - } catch (_: IllegalStateException) { - peekHeightPx - } - }.collect { offset -> - val expandedOffset = 0f - val collapsedOffset = sheetHeightPx - peekHeightPx - val range = (collapsedOffset - expandedOffset).coerceAtLeast(1f) - val rawProgress = (collapsedOffset - offset) / range - progressState.floatValue = rawProgress.coerceIn(0f, 1f) - } - } - - val uiState by remember { - derivedStateOf { - viewModel.compactSheetUiState(progressState.floatValue) - } - } - - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = peekHeight, - sheetDragHandle = null, - sheetContainerColor = Color.Transparent, - sheetShape = RectangleShape, - sheetTonalElevation = 0.dp, - sheetShadowElevation = 0.dp, - sheetContent = { - Box(modifier = Modifier.fillMaxSize()) { - if (uiState.showExpandedPlayer) { - AppExpandedPlayer( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, - onCollapse = { - scope.launch { sheetState.partialExpand() } - }, - onQueue = onQueue, - onAlternativeSource = onAlternativeSource, - onExpandLyrics = onExpandLyrics, - ) - } - - if (uiState.floatingPlayerAlpha > 0.01f) { - Column(modifier = Modifier.fillMaxWidth()) { - AppFloatingPlayer( - modifier = Modifier - .offset(y = 12.dp) - .graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }, - ) - } - } - } - }, - ) { _ -> - Box(modifier = Modifier.fillMaxSize()) { - content() - } - } - if (uiState.floatingPlayerAlpha > 0.01f) { - AppBottombar( - navigator = navigator, - navigationState = navigationState, - modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt deleted file mode 100644 index b6e82e55..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ /dev/null @@ -1,251 +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 . - */ - -package dev.krtirtho.spotube.modules.shell - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -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.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeColorEffect -import dev.chrisbanes.haze.blur.blurEffect -import dev.chrisbanes.haze.hazeEffect -import dev.krtirtho.spotube.core.navigation.NavigationState -import dev.krtirtho.spotube.core.navigation.Navigator -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.copyPadding -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator -import dev.krtirtho.spotube.modules.library.LibraryState -import dev.krtirtho.spotube.modules.library.LibraryTab -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken -import dev.krtirtho.spotube.tabs -import org.jetbrains.compose.resources.Font -import org.koin.compose.koinInject -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.cookie_regular - -@Composable -fun AppSidebar( - hazeState: HazeState, - navigator: Navigator, - navigationState: NavigationState, - modifier: Modifier = Modifier, - libraryState: LibraryState = koinInject() -) { - var expanded by rememberSaveable { mutableStateOf(true) } - val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) - val currentLibraryTab by libraryState.currentTab.collectAsState() - val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) - - Column( - modifier = modifier - .fillMaxHeight() - .width(width) - .hazeEffect(hazeState) { - blurEffect { - blurRadius = 20.dp - colorEffects = listOf( - HazeColorEffect.tint(surfaceTint) - ) - } - }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), - horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { - Text( - text = "Spotube", - style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily( - listOf( - Font(Res.font.cookie_regular, weight = FontWeight.Normal) - ) - ) - ), - color = MaterialTheme.colorScheme.onSurface - ) - } - GhostIconButton( - onClick = { expanded = !expanded } - ) { - Icon( - imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken, - contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - tabs.forEach { (label, icon, activeIcon, screen) -> - val selected = navigationState.topLevelRoute == screen - - if (screen == Routes.Library) { - AnimatedVisibility(visible = expanded) { - Text( - text = "LIBRARY", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp) - ) - } - - LibraryTab.entries.forEach { tab -> - val isSubSelected = selected && currentLibraryTab == tab - - SidebarItem( - label = tab.title, - activeIcon = tab.icon, - onClick = { - libraryState.currentTab.value = tab - navigator.navigate(screen) - }, - selected = isSubSelected, - expanded = expanded, - showDownloadBadge = tab == LibraryTab.Downloads, - ) - } - } else { - SidebarItem( - label = label, - activeIcon = activeIcon, - onClick = { navigator.navigate(screen) }, - selected = selected, - expanded = expanded, - ) - } - } - } -} - -@Composable -fun SidebarItem( - label: String, - activeIcon: ImageVector, - selected: Boolean, - expanded: Boolean, - onClick: () -> Unit, - showDownloadBadge: Boolean = false, -) { - val itemContent: @Composable RowScope.() -> Unit = { - Box(modifier = Modifier.size(20.dp)) { - Icon( - imageVector = activeIcon, - contentDescription = label, - tint = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - if (showDownloadBadge) { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { - Text( - text = label, - maxLines = 1, - softWrap = false, - style = LocalTextStyle.current.copy( - fontSize = MaterialTheme.typography.labelLarge.fontSize, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - color = if (selected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - ) - } - Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) - } - - val buttonModifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 4.dp) - val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp) - - if (selected) { - SecondaryButton( - onClick = onClick, - modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.secondary - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(8.dp)), - content = itemContent, - ) - } else { - OutlineButton( - onClick = onClick, - modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.outline - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(8.dp)), - hoverOnly = true, - content = itemContent, - ) - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt deleted file mode 100644 index 220f0869..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt +++ /dev/null @@ -1,106 +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 . - */ - -package dev.krtirtho.spotube.modules.shell.alternative_track - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.core.animateDp -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -private val SlidingSheetBreakpoint = 840.dp - -@Composable -fun AlternativeTrackSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - BoxWithConstraints(modifier = modifier) { - if (maxWidth >= SlidingSheetBreakpoint) { - SlidingAlternativeSheet(isVisible, onDismiss, content) - } else { - BottomAlternativeSheet(isVisible, onDismiss, content) - } - } -} - -@Composable -private fun SlidingAlternativeSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = isVisible, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), - enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), - exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), - ) { - val animatedShadowElevation = transition.animateDp(label = "alternativeShadow") { state -> - if (state == EnterExitState.Visible) 20.dp else 0.dp - } - Surface( - modifier = Modifier - .fillMaxHeight() - .width(460.dp), - shape = MaterialTheme.shapes.large, - tonalElevation = 2.dp, - shadowElevation = animatedShadowElevation.value, - ) { - content() - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomAlternativeSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - ) { - content() - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt deleted file mode 100644 index 997da78f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ /dev/null @@ -1,287 +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 . - */ - -package dev.krtirtho.spotube.modules.shell.player_queue - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -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.lazy.rememberLazyListState -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ListRowTile -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import org.koin.compose.viewmodel.koinViewModel -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyListState - -@Composable -fun PlayerQueueContent( - viewModel: PlayerQueueContentViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { - val state by viewModel.queueContentUiState.collectAsState() - val displayItems = state.displayItems - val filterQuery = state.filterQuery - val isFiltered = state.isFiltered - - val lazyListState = rememberLazyListState() - val reorderableLazyListState = rememberReorderableLazyListState( - lazyListState, - onMove = { from, to -> - if (isFiltered) return@rememberReorderableLazyListState - viewModel.onMove(from.index, to.index) - }, - ) - - Surface(modifier = modifier) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = "Queue", - style = MaterialTheme.typography.titleLarge, - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = filterQuery, - onValueChange = viewModel::setQueueFilter, - placeholder = { Text("Filter queue...") }, - leadingIcon = { - Icon( - Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - IconButton( - onClick = viewModel::clearQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), - ) { - Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") - } - } - - if (displayItems.isEmpty()) { - Text( - text = "No queue entries", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Card { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = lazyListState, - contentPadding = PaddingValues(bottom = 8.dp), - ) { - items(displayItems, key = { it.id }) { item -> - ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> - val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) - QueueItemRow( - item = item, - reorderScope = if (isFiltered) null else this, - onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, - onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, - onDragStarted = { viewModel.onDragStart() }, - onDragStopped = { viewModel.onDragStop() }, - ) - } - } - } - } - } - } - } -} - -@Composable -private fun QueueItemRow( - item: QueueItemUi, - reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, - onPlayClick: () -> Unit, - onRemoveClick: () -> Unit, - onDragStarted: () -> Unit, - onDragStopped: () -> Unit, -) { - var showMenu by remember { mutableStateOf(false) } - - ListRowTile( - onClick = onPlayClick, - selected = item.isCurrent, - modifier = Modifier, - leading = { - Row( - modifier = Modifier - .height(72.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Iconsax.IconsaxDragHandle, - contentDescription = if (reorderScope != null) "Reorder" else null, - modifier = Modifier - .size(24.dp) - .then( - if (reorderScope != null) { - with(reorderScope) { - Modifier.draggableHandle( - onDragStarted = { onDragStarted() }, - onDragStopped = onDragStopped, - ) - } - } else { - Modifier - }, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Box( - modifier = Modifier - .size(48.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - if (item.imageUrl != null) { - AsyncImage( - model = item.imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } else { - Text( - text = "${item.originalIndex + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - }, - title = { - Text( - text = item.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (item.isCurrent) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - }, - subtitle = { - Text( - text = item.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - trailing = { - Text( - text = item.durationLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.width(4.dp)) - - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), - ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) - } - } - } - ) -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt deleted file mode 100644 index a0e8220c..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ /dev/null @@ -1,106 +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 . - */ - -package dev.krtirtho.spotube.modules.shell.player_queue - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.core.animateDp -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -private val SlidingSheetBreakpoint = 840.dp - -@Composable -fun QueueSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - BoxWithConstraints(modifier = modifier) { - if (maxWidth >= SlidingSheetBreakpoint) { - SlidingQueueSheet(isVisible, onDismiss, content) - } else { - BottomQueueSheet(isVisible, onDismiss, content) - } - } -} - -@Composable -private fun SlidingQueueSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = isVisible, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), - enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), - exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), - ) { - val animatedShadowElevation = transition.animateDp(label = "queueShadow") { state -> - if (state == EnterExitState.Visible) 20.dp else 0.dp - } - Surface( - modifier = Modifier - .fillMaxHeight() - .width(460.dp), - shape = MaterialTheme.shapes.large, - tonalElevation = 2.dp, - shadowElevation = animatedShadowElevation.value, - ) { - content() - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomQueueSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - ) { - content() - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt deleted file mode 100644 index e2a68daa..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt +++ /dev/null @@ -1,3 +0,0 @@ -package dev.krtirtho.spotube.resources.iconsax - -object Iconsax diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt new file mode 100644 index 00000000..ad9f5276 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/App.kt @@ -0,0 +1,274 @@ +/* + * 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 . + */ + +package org.spotube.spotube + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.ui.NavDisplay +import org.spotube.spotube.core.DebugConfig +import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.navPopTransitionSpec +import org.spotube.spotube.core.navigation.navPredictivePopTransitionSpec +import org.spotube.spotube.core.navigation.navPushTransitionSpec +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.core.navigation.toEntries +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.shell.AppShell +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.modules.support.SupportDialog +import org.spotube.spotube.modules.support.SupportReminderIntervalMillis +import org.spotube.spotube.modules.update.AppUpdateDialog +import org.spotube.spotube.modules.update.AppUpdateService +import org.spotube.spotube.modules.update.PluginUpdateDialog +import org.spotube.spotube.modules.update.PluginUpdateService +import org.spotube.spotube.modules.webview.WebViewScreen +import org.spotube.spotube.modules.welcome.WelcomeRepository +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxHome +import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular +import org.spotube.spotube.resources.iconsax.IconsaxMusicDashboard +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone +import org.spotube.spotube.resources.iconsax.User +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.time.Clock +import org.koin.compose.koinInject +import org.koin.compose.navigation3.koinEntryProvider +import org.koin.core.annotation.KoinExperimentalAPI +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_screen_title +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_downloads +import spotube.composeapp.generated.resources.library_tab_local_tracks +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.library_toolbar_compact_title +import spotube.composeapp.generated.resources.nav_devices +import spotube.composeapp.generated.resources.nav_group_connect +import spotube.composeapp.generated.resources.nav_group_on_device +import spotube.composeapp.generated.resources.nav_home +import spotube.composeapp.generated.resources.nav_search +import spotube.composeapp.generated.resources.settings_screen_title + +interface NavigationItem { + data class Tab( + val title: StringResource, val icon: ImageVector, val route: Routes, + val data: T? = null + ) : NavigationItem + + data class Group( + val title: StringResource, val items: List> + ) : NavigationItem +} + +private val tabs = listOf( + NavigationItem.Tab(Res.string.nav_home, Iconsax.IconsaxHome, Routes.Home), + NavigationItem.Tab(Res.string.nav_search, Iconsax.IconsaxSearch, Routes.Search) +) + +val sidebarTabs = tabs + listOf( + NavigationItem.Group( + Res.string.library_toolbar_compact_title, + items = listOf( + NavigationItem.Tab( + Res.string.library_tab_playlists, + Iconsax.IconsaxMusicDashboard, + Routes.Library, + LibraryTab.Playlists + ), + NavigationItem.Tab(Res.string.library_tab_albums, Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), + NavigationItem.Tab(Res.string.library_tab_artists, Iconsax.User, Routes.Library, LibraryTab.Artists) + ) + ), + NavigationItem.Group( + Res.string.nav_group_on_device, + items = listOf( + NavigationItem.Tab( + Res.string.library_tab_local_tracks, + Iconsax.IconsaxMusicLibrary, + Routes.Library, + LibraryTab.LocalTracks + ), + NavigationItem.Tab( + Res.string.library_tab_downloads, + Iconsax.IconsaxDirectboxReceive, + Routes.Library, + LibraryTab.Downloads + ) + ) + ), + NavigationItem.Group( + Res.string.nav_group_connect, + items = listOf( + NavigationItem.Tab(Res.string.nav_devices, Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), + NavigationItem.Tab(Res.string.jam_screen_title, Iconsax.IconsaxSoundTwotone, Routes.Jam) + ) + ) +) +val bottombarTabs = tabs + listOf( + NavigationItem.Tab(Res.string.library_toolbar_compact_title, Iconsax.IconsaxMusicLibrary, Routes.Library), + NavigationItem.Tab(Res.string.settings_screen_title, Iconsax.IconsaxSetting2, Routes.Settings) +) + +@OptIn(KoinExperimentalAPI::class) +@Composable +fun App( + content: @Composable () -> Unit = {} +) { + val settingsRepository: SettingsRepository = koinInject() + val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) + val appUpdateService: AppUpdateService = koinInject() + val pluginUpdateService: PluginUpdateService = koinInject() + val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle() + val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle() + + val welcomeRepository: WelcomeRepository = koinInject() + val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle() + + var showSupportDialog by remember { mutableStateOf(false) } + var supportDialogIsReminder by remember { mutableStateOf(false) } + + // Nudge the user to support the project at most once every seven days, unless they opted + // out in Settings. The first eligible launch after onboarding only records the baseline, so + // the prompt never appears right after the onboarding donation page. + LaunchedEffect(showOnboarding) { + if (showOnboarding) return@LaunchedEffect + // Read the persisted value rather than the collected state, whose initial value is the + // default (0) until DataStore has emitted once. + val current = settingsRepository.getCurrentSettings() + val now = Clock.System.now().toEpochMilliseconds() + // Development override: show the reminder on every launch, regardless of timing or the + // "Support reminders" toggle. + if (DebugConfig.FORCE_SUPPORT_REMINDER) { + supportDialogIsReminder = true + showSupportDialog = true + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + return@LaunchedEffect + } + if (!current.supportRemindersEnabled) return@LaunchedEffect + val last = current.lastSupportReminderEpochMs + if (last == 0L) { + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + } else if (now - last >= SupportReminderIntervalMillis) { + supportDialogIsReminder = true + showSupportDialog = true + settingsRepository.updateSettings( + current.copy(lastSupportReminderEpochMs = now)) + } + } + + // The app always renders the shell + NavDisplay; onboarding is just the start destination. + // When it is completed the destination flips to Home and the navigation state is rebuilt. + val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home + + val navigationState = rememberNavigationState( + startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES + ) + val navigator = remember(navigationState) { + Navigator(navigationState) + } + + // Below the navigation state, so switching languages keeps the current screen. + AppLocaleProvider(userSettings.language) { + SpotubeTheme(settings = userSettings) { + CompositionLocalProvider(LocalSupportPrompt provides { + supportDialogIsReminder = false + showSupportDialog = true + }) { + Box(modifier = Modifier.fillMaxSize()) { + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() + + Box(Modifier.fillMaxSize()) { + AppShell(navigator, navigationState) { + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout + ) { + Column { + NavDisplay( + modifier = Modifier.fillMaxSize(), + onBack = navigator::pop, + entries = navigationState.toEntries(koinEntryProvider()), + transitionSpec = navPushTransitionSpec, + popTransitionSpec = navPopTransitionSpec, + predictivePopTransitionSpec = navPredictivePopTransitionSpec + ) + } + } + } + } + when (currentRoute) { + Routes.WebView -> WebViewScreen(koinInject()) + else -> {} + } + } + } + content() + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + appUpdateService.dismissUpdate() + }) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = pluginUpdateService::installUpdate) + } + SupportDialog( + visible = showSupportDialog, + onDismissRequest = { showSupportDialog = false }, + isReminder = supportDialogIsReminder) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt index 07948670..5207160f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/Platform.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/Platform.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube enum class PlatformType { Android, IOS, Windows, Linux, MacOS, Unknown diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt new file mode 100644 index 00000000..18792453 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt @@ -0,0 +1,44 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core + +/** + * Development-only switches. These are intentionally kept out of [org.spotube.spotube.modules.settings.UserSettings] + * so they can never leak into user-facing configuration. + */ +object DebugConfig { + /** + * Flip to `true` while developing to always open the Welcome/Onboarding screen, even after + * it has been completed (so you don't have to wipe app data to see it again). It still + * dismisses for the session when you finish or skip it. + * + * Keep it `false` when committing. + */ + const val FORCE_SHOW_ONBOARDING: Boolean = false + + /** + * Flip to `true` while developing to show the support reminder dialog on the next eligible + * app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle. + * It still waits until onboarding is dismissed, and only fires once per launch. + * + * Keep it `false` when committing. + */ + const val FORCE_SUPPORT_REMINDER: Boolean = false + + const val FORCE_VLC_LOGS: Boolean = false +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt similarity index 83% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt index 8587e043..7e67b35d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.kt @@ -15,43 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlin.time.Duration -data class MediaItem( - val title: String, - val artist: String, - val album: String, - val duration: Duration, - val coverURL: String, - val url: String, - val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, -) { - fun toDebugString(): String { - return "MediaItem(title='$title', artist='$artist', album='$album', duration=$duration, coverURL='$coverURL', url='$url', protocol=$protocol)" - } -} - -enum class LoopState { - NONE, ONE, ALL; - - fun next(): LoopState { - return when (this) { - NONE -> ONE - ONE -> ALL - ALL -> NONE - } - } -} - -enum class PlayerState { - IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED -} - interface AudioPlayerInterface { // // Playback suspend fun play() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt similarity index 56% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt index b2864360..18306883 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt @@ -15,16 +15,56 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlin.time.Duration + +data class MediaItem( + val title: String, + val artist: String, + val album: String, + val duration: Duration, + val coverURL: String, + val url: String, + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + val coverBytes: ByteArray? = null) + +enum class LoopState { + NONE, ONE, ALL; + + fun next(): LoopState { + return when (this) { + NONE -> ONE + ONE -> ALL + ALL -> NONE + } + } +} + +enum class PlayerState { + IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED +} @Serializable sealed interface QueueEntry { val url: String + val addedBy: String + + /** Remote artwork URL, for consumers that can only take a link (remote control, media session). */ + val artworkUrl: String? + + /** + * Single source of truth for a queue entry's artwork, in a form Coil can load directly: + * a URL `String` for streaming tracks, embedded cover `ByteArray` for local ones. + * Every UI surface (players, queue, lists) must use this instead of reading thumbnails itself. + */ + val artworkModel: Any? + get() = (this as? LocalTrack)?.coverBytes?.takeIf { it.isNotEmpty() } ?: artworkUrl @Serializable @SerialName("streaming") @@ -32,7 +72,11 @@ sealed interface QueueEntry { val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, - ) : QueueEntry + override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? + get() = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + } @Serializable @SerialName("local") @@ -41,9 +85,13 @@ sealed interface QueueEntry { val artists: List, val duration: Long, val album: String?, - val coverBytes: ByteArray?, - override val url: String - ) : QueueEntry { + // Not persisted: the queue is saved on every change and covers made it hundreds of MB + // of JSON (#3193). Restore fills them back in from the local media index. + @Transient val coverBytes: ByteArray? = null, + override val url: String, + override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? get() = null + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false @@ -56,6 +104,7 @@ sealed interface QueueEntry { if (album != other.album) return false if (!coverBytes.contentEquals(other.coverBytes)) return false if (url != other.url) return false + if (addedBy != other.addedBy) return false return true } @@ -67,6 +116,7 @@ sealed interface QueueEntry { result = 31 * result + (album?.hashCode() ?: 0) result = 31 * result + (coverBytes?.contentHashCode() ?: 0) result = 31 * result + url.hashCode() + result = 31 * result + addedBy.hashCode() return result } } @@ -79,14 +129,12 @@ sealed interface QueueCollectionEntry { @Serializable @SerialName("playlist") data class Playlist( - override val id: String, - ) : QueueCollectionEntry + override val id: String) : QueueCollectionEntry @Serializable @SerialName("album") data class Album( - override val id: String, - ) : QueueCollectionEntry + override val id: String) : QueueCollectionEntry @Serializable @SerialName("saved_tracks") @@ -100,5 +148,4 @@ data class PersistedQueueState( val entries: List, val currentIndex: Int, val currentCollectionEntry: QueueCollectionEntry? = null, - val collectionHistory: List = emptyList(), -) + val collectionHistory: List = emptyList()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt index 8a524add..96989f17 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.coroutines.flow.StateFlow @@ -29,8 +29,7 @@ interface AudioPlayerQueue { entries: List, autoPlay: Boolean = true, startPosition: Int = 0, - collectionEntry: QueueCollectionEntry? = null, - ) + collectionEntry: QueueCollectionEntry? = null) suspend fun addToQueue(entry: QueueEntry) suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry? = null) suspend fun addAllAfterCurrent(entries: List) @@ -45,6 +44,12 @@ interface AudioPlayerQueue { suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? suspend fun getCollectionHistory(): List + /** + * Stops endless playback from adding recommendations when the queue runs out. A jam + * guest's queue belongs to the host; recommendations of its own would split them. + */ + fun setEndlessPlaybackSuppressed(suppressed: Boolean) = Unit + fun isPlaylistPlaying(playlistId: String? = null): Boolean { val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false return playlistId == null || entry.id == playlistId diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt index 91b03b26..2b03d8c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueRepository.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import androidx.datastore.preferences.core.edit -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.db.DatabaseKeys +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 230b0c78..7798ab77 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -15,13 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.plugin.PluginProvider -import dev.krtirtho.spotube.modules.settings.SettingsProvider +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository +import org.spotube.spotube.modules.plugin.PluginProvider +import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.koin.core.component.get import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds @@ -45,8 +48,7 @@ class DeviceAudioPlayerQueue( private val settingsProvider: SettingsProvider, private val repository: QueueStateRepository, private val pluginProvider: PluginProvider, - private val blacklistRepository: BlacklistRepository, -) : AudioPlayerQueue, KoinComponent { + private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() @@ -58,21 +60,22 @@ class DeviceAudioPlayerQueue( private var isRestoringPersistedState = true + // Eagerly: callers read `.value` (the stream proxy looks up the track it's asked to play, + // Jam follows the room). With WhileSubscribed, `.value` went stale whenever nothing on + // screen was collecting, so the proxy answered "not in the queue" (404) for new tracks. override val queueFlow: StateFlow> = combine( audioPlayer.playlistFlow, - entryByMediaUrl, - ) { playlist, knownEntries -> + entryByMediaUrl) { playlist, knownEntries -> playlist.map { mediaItem -> knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() } - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) override val currentQueueEntryFlow: StateFlow = combine( audioPlayer.currentMediaItemFlow, - entryByMediaUrl, - ) { mediaItem, knownEntries -> + entryByMediaUrl) { mediaItem, knownEntries -> mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + }.stateIn(scope, SharingStarted.Eagerly, null) override val currentCollectionEntryFlow: StateFlow = currentCollectionEntryState.asStateFlow() @@ -107,8 +110,7 @@ class DeviceAudioPlayerQueue( combine( audioPlayer.currentMediaItemFlow, audioPlayer.playlistFlow, - audioPlayer.loopStateFlow, - ) { currentMedia, playlist, loopState -> + audioPlayer.loopStateFlow) { currentMedia, playlist, loopState -> Triple(currentMedia, playlist, loopState) }.collect { (currentMedia, playlist, loopState) -> if (currentMedia == null || playlist.isEmpty()) return@collect @@ -129,17 +131,17 @@ class DeviceAudioPlayerQueue( entries: List, autoPlay: Boolean, startPosition: Int, - collectionEntry: QueueCollectionEntry?, - ) { + collectionEntry: QueueCollectionEntry?) { logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } - val mediaItems = entries.mapNotNull { it.toMediaItem() } + val resolved = resolveMediaItems(entries) + val mediaItems = resolved.map { it.first } if (mediaItems.isEmpty()) { logger.w { "load: no valid media items after filtering blanks" } } val normalizedStartPosition = if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } - entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + entryByMediaUrl.value = entriesByUrl(resolved) updateCollectionContext(collectionEntry) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) persistQueueStateIfReady() @@ -311,9 +313,14 @@ class DeviceAudioPlayerQueue( } private var isFetchingRecommendations = false + private var endlessPlaybackSuppressed = false + + override fun setEndlessPlaybackSuppressed(suppressed: Boolean) { + endlessPlaybackSuppressed = suppressed + } private suspend fun handleQueueCompletion() { - if (isFetchingRecommendations) return + if (isFetchingRecommendations || endlessPlaybackSuppressed) return val settings = settingsProvider.settingsState.value ?: return if (!settings.enableEndlessPlayback) return @@ -398,19 +405,66 @@ class DeviceAudioPlayerQueue( return } - val mediaItems = state.entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val restored = resolveMediaItems(withLocalCovers(state.entries)) + if (restored.isEmpty()) { logger.w { "restorePersistedState: all entries had blank urls" } return } - entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + val mediaItems = restored.map { it.first } + entryByMediaUrl.value = entriesByUrl(restored) val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) logger.i { "Persisted state restored successfully" } } + /** + * Converts each entry once. Looking every item's entry up again by converting the whole list + * per item was quadratic: ~900k suspending conversions to start a 1353-track playlist. + */ + private suspend fun resolveMediaItems(entries: List): List> = + entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } } + + /** First entry wins for a repeated url, as before. */ + private fun entriesByUrl(resolved: List>): Map = buildMap { + resolved.forEach { (item, entry) -> if (item.url !in this) put(item.url, entry) } + } + + /** Local covers aren't persisted with the queue; look them up in the local media index. */ + private suspend fun withLocalCovers(entries: List): List { + if (entries.none { it is QueueEntry.LocalTrack }) return entries + val covers = runCatching { + get().cacheFlow.first().folders + .flatMap { it.tracks } + .mapNotNull { track -> track.coverBytes?.let { track.path to it } } + .toMap() + }.getOrElse { + logger.w(it) { "Couldn't read local media index for queue covers" } + emptyMap() + } + if (covers.isEmpty()) return entries + return entries.map { entry -> + if (entry is QueueEntry.LocalTrack && entry.coverBytes == null) { + covers[entry.url]?.let { entry.copy(coverBytes = it) } ?: entry + } else entry + } + } + + /** + * Runs from fire-and-forget collectors on a scope without an exception handler, where a + * failed write (disk full, I/O error) would otherwise crash the app. Losing one save is fine. + */ private suspend fun persistQueueStateIfReady() { + try { + persistQueueState() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Failed to persist queue state" } + } + } + + private suspend fun persistQueueState() { if (isRestoringPersistedState) { logger.v { "Skipping persist: still restoring persisted state" } return @@ -436,8 +490,7 @@ class DeviceAudioPlayerQueue( entries = queueEntries, currentIndex = normalizedCurrentIndex, currentCollectionEntry = currentCollectionEntryState.value, - collectionHistory = collectionHistoryState.value, - ) + collectionHistory = collectionHistoryState.value) ) } @@ -469,10 +522,9 @@ class DeviceAudioPlayerQueue( artist = track.artists.joinToString(", ") { artist -> artist.name }, album = track.album?.title.orEmpty(), duration = track.durationMs.milliseconds, - coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), + coverURL = artworkUrl.orEmpty(), url = resolvedUrl, - protocol = protocol, - ) + protocol = protocol) } is QueueEntry.LocalTrack -> { @@ -484,7 +536,7 @@ class DeviceAudioPlayerQueue( duration = duration.milliseconds, coverURL = "", url = url, - ) + coverBytes = coverBytes?.takeIf { it.isNotEmpty() }) } } } @@ -496,8 +548,7 @@ class DeviceAudioPlayerQueue( duration = duration.inWholeMilliseconds, album = album.ifBlank { null }, coverBytes = null, - url = url, - ) + url = url) } private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt similarity index 52% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt index f268d57f..a83c5a0c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt @@ -15,18 +15,33 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.db +package org.spotube.spotube.core.db import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.paths.Paths +import okio.FileSystem import okio.Path.Companion.toPath +import okio.SYSTEM @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") class Database(val paths: Paths) { + /** + * True when Spotube had already written its own data before this process started. Captured + * synchronously at construction time — the earliest point, before any eager service can + * create a fresh store — so it reliably tells a brand-new install apart from an upgrade of + * an existing one (used to skip the first-run onboarding on upgrades). + */ + val isExistingInstall: Boolean = runCatching { + val dataDirPath = paths.getApplicationDataDirPath() + EXISTING_INSTALL_PROBE_FILES.any { fileName -> + FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath()) + } + }.getOrDefault(false) + val settingsDataStore: DataStore by lazy { PreferenceDataStoreFactory.createWithPath( produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() } @@ -46,13 +61,13 @@ class Database(val paths: Paths) { val audioPlayerQueueDataStore: DataStore by lazy { PreferenceDataStoreFactory.createWithPath( - produceFile = { "${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath() } + produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath()) } ) } val localMediaDataStore: DataStore by lazy { PreferenceDataStoreFactory.createWithPath( - produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() } + produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath()) } ) } @@ -61,6 +76,40 @@ class Database(val paths: Paths) { produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() } ) } + + val windowStateDataStore: DataStore by lazy { + PreferenceDataStoreFactory.createWithPath( + produceFile = { "${paths.getApplicationDataDirPath()}/spotube_window.preferences_pb".toPath() } + ) + } + + /** + * DataStore reads a whole preferences file into the heap. Earlier builds stored full-size + * embedded covers as JSON number arrays in the queue and local media stores, growing them to + * hundreds of MB and crashing with OutOfMemoryError on load (#3193). Both are rebuildable, so + * drop one that is far past any sane size instead of loading it. + */ + private fun dropIfOversized(path: okio.Path): okio.Path { + runCatching { + val size = FileSystem.SYSTEM.metadataOrNull(path)?.size ?: return path + if (size > MAX_CACHE_STORE_BYTES) FileSystem.SYSTEM.delete(path) + } + return path + } + + private companion object { + const val MAX_CACHE_STORE_BYTES = 8L * 1024 * 1024 + + /** Preference files written by previous launches; their presence means an upgrade. */ + val EXISTING_INSTALL_PROBE_FILES = listOf( + "spotube.preferences_pb", + "spotube_plugins.preferences_pb", + "spotube_matched_tracks.preferences_pb", + "spotube_audio_player_queue.preferences_pb", + "spotube_local_media.preferences_pb", + "spotube_blacklist.preferences_pb", + "spotube_cookies.preferences_pb") + } } object DatabaseKeys { @@ -68,4 +117,5 @@ object DatabaseKeys { val AUDIO_PLAYER_QUEUE_STATE_KEY = stringPreferencesKey("audio_player_queue_state") val LOCAL_MEDIA_CACHE_STATE_KEY = stringPreferencesKey("local_media_cache_state") val LOCAL_MEDIA_LAST_SCAN_AT_KEY = longPreferencesKey("local_media_last_scan_at") + val WINDOW_STATE_KEY = stringPreferencesKey("window_state") } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt new file mode 100644 index 00000000..c2ba2b1e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/deeplink/ExternalUriHandler.kt @@ -0,0 +1,50 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.deeplink + +/** + * Cross-platform receiver for URIs handed to the app by the operating system + * (deep links). Platform entry points (Android activity intents, desktop command + * line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable + * installs a [listener] once composition starts. + * + * Follows the Compose Multiplatform deep linking docs pattern: URIs arriving + * before a listener is installed are cached and delivered as soon as one is set. + */ +object ExternalUriHandler { + private var cached: String? = null + + var listener: ((uri: String) -> Unit)? = null + set(value) { + field = value + if (value != null) { + cached?.let(value::invoke) + cached = null + } + } + + fun onNewUri(uri: String) { + if (uri.isBlank()) return + val currentListener = listener + if (currentListener != null) { + currentListener(uri) + } else { + cached = uri + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/EnrichKoin.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/EnrichKoin.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt index 5a035274..7ae4266c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/EnrichKoin.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/EnrichKoin.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di import androidx.compose.runtime.Composable import co.touchlab.kermit.Logger diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Init.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Init.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt index 9b95c525..b0491bc4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Init.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di import co.touchlab.kermit.Logger import co.touchlab.kermit.koin.KermitKoinLogger diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt new file mode 100644 index 00000000..98d487df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -0,0 +1,313 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.di + +import org.koin.core.module.dsl.onClose +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository +import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueStateRepository +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.discord.DiscordRpcService +import org.spotube.spotube.core.jam.JamRoomClient +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.navigationModule +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlHandler +import org.spotube.spotube.core.remote.RemoteControlService +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.scrobble.ScrobbleManager +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.server.AlternativeTracksRepository +import org.spotube.spotube.core.server.CacheManager +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.core.server.MatchedTracksRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.album.AlbumViewModel +import org.spotube.spotube.modules.artist.ArtistRepository +import org.spotube.spotube.modules.artist.ArtistViewModel +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.blacklist.BlacklistViewModel +import org.spotube.spotube.modules.devices.DevicesViewModel +import org.spotube.spotube.modules.devices.RemoteControlViewModel +import org.spotube.spotube.modules.shell.ConnectStatusViewModel +import org.spotube.spotube.modules.jam.JamViewModel +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.home.HomeScreenRepository +import org.spotube.spotube.modules.home.HomeScreenViewModel +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.album.LibraryAlbumsViewModel +import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel +import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.playlist.AddTracksToPlaylistViewModel +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.playlist.PlaylistViewModel +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginProvider +import org.spotube.spotube.modules.plugin.PluginViewModel +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuardViewModel +import org.spotube.spotube.modules.plugin.ScrobblePluginSource +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.search.SearchRepository +import org.spotube.spotube.modules.search.SearchScreenViewModel +import org.spotube.spotube.modules.update.AppUpdateService +import org.spotube.spotube.modules.update.PluginUpdateService +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.modules.settings.UserSettingsStore +import org.spotube.spotube.modules.settings.UserSettingsSource +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.JamSettingsViewModel +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.shell.AppShellViewModel +import org.spotube.spotube.modules.shell.PlayerOptionsViewModel +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContentViewModel +import org.spotube.spotube.modules.welcome.WelcomeRepository +import org.spotube.spotube.modules.welcome.WelcomeViewModel +import org.koin.core.module.Module +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.createdAtStart +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.core.module.dsl.withOptions +import org.koin.dsl.module + +expect val platformModules: Module + +val sharedModules = module { + includes(navigationModule) + singleOf(::Database) + single { HttpClientFactory() } withOptions { onClose { it?.close() } } + + singleOf(::WebViewController) + + // Shell + viewModelOf(::AppShellViewModel) + viewModelOf(::PlayerOptionsViewModel) + viewModelOf(::PlayerQueueContentViewModel) + viewModelOf(::AlternativeTrackContentViewModel) + + // Home + singleOf(::HomeScreenRepository) + viewModelOf(::HomeScreenViewModel) + viewModelOf(::MetadataAuthViewModel) + + // Search + singleOf(::SearchRepository) + viewModelOf(::SearchScreenViewModel) + + // Library + singleOf(::LibraryState) + singleOf(::LibraryRepository) + singleOf(::LocalMediaCacheRepository) + singleOf(::LocalMediaFoldersConfig) + singleOf(::LocalMediaLibraryCoordinator) + viewModelOf(::LibraryPlaylistsViewModel) + viewModelOf(::LibraryAlbumsViewModel) + viewModelOf(::LibraryArtistsViewModel) + viewModelOf(::LibraryLocalTracksViewModel) + viewModel { (folderPath: String) -> LocalFolderViewModel(folderPath, get(), get()) } + + // Plugin system + singleOf(::PluginManager) { + bind() + bind() + bind() + } + viewModelOf(::PluginViewModel) + viewModelOf(::MetadataPluginGuardViewModel) + + // Settings + singleOf(::SettingsRepository) { + bind() + bind() + } + viewModelOf(::SettingsViewModel) { bind() } + viewModelOf(::JamSettingsViewModel) + viewModel { + val repository = get() + LocalMediaFoldersViewModel(repository.userSettings) { folders -> + val current = repository.getCurrentSettings() + repository.updateSettings(current.copy(localMediaFolders = folders)) + } + } + + // Welcome / Onboarding + singleOf(::WelcomeRepository) + viewModelOf(::WelcomeViewModel) + + // Updates + single { AppUpdateService(get(), get()) } withOptions { createdAtStart() } + single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } + + // Downloads + singleOf(::DownloadManager) + viewModelOf(::DownloadsViewModel) + + // Playlist + singleOf(::PlaylistRepository) + viewModel { (playlistId: String) -> + PlaylistViewModel( + playlistId = playlistId, + repository = get(), + savedTracksRepository = get(), + libraryRepository = get(), + playbackHelper = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + viewModel { (playlistId: String) -> AddTracksToPlaylistViewModel(playlistId, get(), get()) } + + // Saved Tracks + singleOf(::SavedTracksRepository) + viewModel { + SavedTracksViewModel( + repository = get(), + playbackHelper = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + libraryRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + + // Artist + singleOf(::ArtistRepository) + viewModel { (artistId: String) -> + ArtistViewModel( + artistId = artistId, + repository = get(), + libraryRepository = get(), + savedTracksRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + + // Blacklist + singleOf(::BlacklistRepository) + viewModelOf(::BlacklistViewModel) + viewModel { DevicesViewModel(get()) } + viewModelOf(::RemoteControlViewModel) + viewModelOf(::ConnectStatusViewModel) + viewModel { + JamViewModel( + jamRoomService = get(), + shareService = get(), + settingsProvider = get()) + } + + // Album + singleOf(::AlbumRepository) + viewModel { (albumId: String) -> + AlbumViewModel( + albumId = albumId, + repository = get(), + savedTracksRepository = get(), + playbackHelper = get(), + libraryRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get(), + shareService = get(), + downloadManager = get(), + remotePlaybackController = get()) + } + + // Lyrics + viewModelOf(::LyricsViewModel) + + // Local playback proxy server + single { + CollectionPlaybackHelper( + albumRepository = get(), + playlistRepository = get(), + savedTracksRepository = get(), + artistRepository = get(), + audioPlayerQueue = get(), + blacklistRepository = get()) + } + singleOf(::MatchedTracksRepository) { bind() } + single { + StreamingUrlRepository( + pluginManager = get(), + audioPlayerQueue = get(), + matchedTracksRepository = get(), + settingsRepository = get()) + } + singleOf(::CacheManager) + singleOf(::AlternativeTracksRepository) + singleOf(::LocalServer) withOptions { + createdAtStart() + } + single { RemoteControlHandler(get(), get(), get(), get()) } + single { RemoteControlClient(get()) } + singleOf(::DeviceDiscoveryService) + single { RemoteControlService(get(), get(), get()) } withOptions { + createdAtStart() + } + single { RemotePlaybackController(get(), get(), get(), get(), get()) } + singleOf(::JamRoomClient) + single { + JamRoomService( + get(), get(), get(), get(), get(), get(), get(), + evictCachedAudio = get()::invalidateCacheEntry) + } + singleOf(::AudioPlayerQueueRepository) { bind() } + single { + DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) + } + + single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() } + + // Scrobbling + single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() } + + factory { (tag: String?) -> + if (tag != null) { + Logger.withTag(tag) + } else { + // Default logger if no tag is provided + Logger + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt index b7454957..03942de1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discord/DiscordRpcService.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.discord +package org.spotube.spotube.core.discord -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -34,8 +34,7 @@ import kotlin.time.Clock class DiscordRpcService( private val audioPlayer: AudioPlayerInterface, - private val settingsProvider: SettingsProvider, -) : KoinComponent, AutoCloseable { + private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable { private val logger by injectLogger() @@ -61,8 +60,7 @@ class DiscordRpcService( audioPlayer.playerStateFlow, audioPlayer.currentMediaItemFlow, audioPlayer.positionFlow, - audioPlayer.durationFlow, - ) { settings, playerState, mediaItem, position, duration -> + audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration -> DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration) }.collect { state -> handleStateChange(state) @@ -86,8 +84,7 @@ class DiscordRpcService( album = mediaItem.album, coverUrl = mediaItem.coverURL, positionMs = state.position.inWholeMilliseconds, - durationMs = state.duration.inWholeMilliseconds, - ) + durationMs = state.duration.inWholeMilliseconds) } PlayerState.PAUSED -> { val mediaItem = state.mediaItem ?: return @@ -98,8 +95,7 @@ class DiscordRpcService( album = mediaItem.album, coverUrl = mediaItem.coverURL, positionMs = state.position.inWholeMilliseconds, - durationMs = state.duration.inWholeMilliseconds, - ) + durationMs = state.duration.inWholeMilliseconds) } PlayerState.IDLE, PlayerState.COMPLETED -> { clearPresence() @@ -158,8 +154,7 @@ class DiscordRpcService( album: String, coverUrl: String, positionMs: Long, - durationMs: Long, - ) { + durationMs: Long) { val client = rpcClient ?: return try { client.updatePresence( @@ -168,8 +163,7 @@ class DiscordRpcService( album = album, coverUrl = coverUrl, positionMs = positionMs, - durationMs = durationMs, - ) + durationMs = durationMs) } catch (e: Exception) { rpcClient = null connectionFailed = true @@ -208,8 +202,7 @@ class DiscordRpcService( private data class DiscordRpcState( val enabled: Boolean, val playerState: PlayerState, - val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, + val mediaItem: org.spotube.spotube.core.audioplayer.MediaItem?, val position: kotlin.time.Duration, - val duration: kotlin.time.Duration, - ) + val duration: kotlin.time.Duration) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt new file mode 100644 index 00000000..d67d8efb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt @@ -0,0 +1,27 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +/** + * The LAN address to publish in our mDNS advertisement, or null to let the platform decide. + * + * Only the desktop needs this: there the dns-sd library otherwise grabs the first site-local + * address of *any* interface, which on a dev box is often a Docker/VM bridge (e.g. minikube's + * 192.168.49.1) that other devices can't reach. + */ +internal expect fun preferredAdvertiseAddress(): String? diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt new file mode 100644 index 00000000..b0c0c2f2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt @@ -0,0 +1,114 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +import com.appstractive.dnssd.DiscoveryEvent +import com.appstractive.dnssd.NetService +import com.appstractive.dnssd.createNetService +import com.appstractive.dnssd.discoverServices +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.text.decodeToString + +data class DiscoveredDevice( + val name: String, + val type: String, + val host: String, + val port: Int, + val deviceId: String) { + val key: String get() = "$name$type".replace(".", "") +} + +sealed interface DiscoveryState { + /** [resolve] can block for seconds on desktop (JmDNS), so it's a suspend call that runs off the main thread. */ + data class Discovered(val device: DiscoveredDevice, val resolve: suspend () -> Unit) : DiscoveryState + data class Resolved(val device: DiscoveredDevice) : DiscoveryState + data class Removed(val device: DiscoveredDevice) : DiscoveryState +} + +class DeviceDiscoveryService { + companion object { + const val SERVICE_TYPE = "_spotube-ctrl._tcp" + const val TXT_DEVICE_ID = "deviceId" + } + + /** + * Runs on [Dispatchers.IO]: on desktop, cancelling the underlying flow calls the blocking + * `JmDNS.close()` (it waits up to 5s for its shutdown), which froze the UI whenever the + * collector was cancelled on the main thread. + */ + fun discover(): Flow = discoverServices(SERVICE_TYPE).map { event -> + when (event) { + is DiscoveryEvent.Discovered -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) + DiscoveryState.Discovered( + device = device, + resolve = { withContext(Dispatchers.IO) { event.resolve() } }) + } + + is DiscoveryEvent.Resolved -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) + DiscoveryState.Resolved(device = device) + } + + is DiscoveryEvent.Removed -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = "") + DiscoveryState.Removed(device = device) + } + } + }.flowOn(Dispatchers.IO) + + suspend fun advertise( + name: String, + port: Int, + deviceId: String, + registerTimeoutMs: Long = 5_000): NetService { + val service = createService(name, port, deviceId) + service.register(timeoutInMs = registerTimeoutMs) + return service + } + + fun createService( + name: String, + port: Int, + deviceId: String): NetService = createNetService( + type = SERVICE_TYPE, + name = name, + port = port, + addresses = preferredAdvertiseAddress()?.let { listOf(it) }, + txt = mapOf(TXT_DEVICE_ID to deviceId)) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt new file mode 100644 index 00000000..da6da28b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.kt @@ -0,0 +1,28 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** + * Returns a lambda that requests the OS permission needed for local network + * discovery (mDNS/NSD). No-op on platforms where such a permission doesn't + * exist or is granted implicitly. + */ +@Composable +expect fun rememberLocalNetworkPermissionRequester(): () -> Unit \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt index 87e08b95..77b50482 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/extras/CaseConverterExt.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/extras/CaseConverterExt.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.extras +package org.spotube.spotube.core.extras fun String.kebabToTitleCase(): String { return this.split("-").joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt new file mode 100644 index 00000000..8220e18f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.kt @@ -0,0 +1,61 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.key +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import org.spotube.spotube.modules.settings.SupportedLanguages + +/** + * Points the platform locale (which Compose resources resolve strings from) at [languageTag], + * or back to the system language when it is null, and returns the platform-specific + * composition local that carries it. + */ +@Composable +internal expect fun provideAppLocale(languageTag: String?): ProvidedValue<*> + +/** + * Renders [content] in the app language picked in settings, overriding the system language + * unless [language] is [SupportedLanguages.SYSTEM]. + * + * Compose resources only re-resolve strings when the calling composable recomposes, so the + * content is keyed on the language: switching languages recreates the subtree. Keep state that + * must survive a language switch (e.g. navigation) above this provider. + */ +@Composable +fun AppLocaleProvider(language: SupportedLanguages, content: @Composable () -> Unit) { + val languageTag = language.languageTag + val layoutDirection = when { + language == SupportedLanguages.SYSTEM -> null + language.isRtl -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } + val locales = listOfNotNull( + provideAppLocale(languageTag), + layoutDirection?.let { LocalLayoutDirection provides it }) + CompositionLocalProvider(*locales.toTypedArray()) { + key(languageTag) { + PublishResourceEnvironment() + content() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/Strings.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/Strings.kt new file mode 100644 index 00000000..bb17775a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/i18n/Strings.kt @@ -0,0 +1,71 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.ResourceEnvironment +import org.jetbrains.compose.resources.StringArrayResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getSystemResourceEnvironment +import org.jetbrains.compose.resources.rememberResourceEnvironment +import kotlin.concurrent.Volatile +import org.jetbrains.compose.resources.getPluralString as composeGetPluralString +import org.jetbrains.compose.resources.getString as composeGetString +import org.jetbrains.compose.resources.getStringArray as composeGetStringArray + +/* + * Non-composable string lookups (ViewModels, services, snackbars). Use these instead of + * org.jetbrains.compose.resources.getString/getPluralString/getStringArray. + * + * The library versions call getSystemResourceEnvironment() on every lookup. On desktop that + * also asks Skiko for the system theme, and on Linux Skiko answers with a blocking D-Bus call to + * the desktop portal on whatever thread is asking. Run from a background coroutine, that call + * segfaulted the GraalVM build on GNOME (#3183). Strings never depend on the theme, so these + * reuse the environment the UI already computed (on the UI thread, for stringResource) and only + * fall back to the library's lookup before anything has been composed. + */ + +@Volatile +private var composedEnvironment: ResourceEnvironment? = null + +/** Publishes the UI's resource environment for [getString] and friends. Called by [AppLocaleProvider]. */ +@Composable +internal fun PublishResourceEnvironment() { + val environment = rememberResourceEnvironment() + SideEffect { composedEnvironment = environment } +} + +private fun resourceEnvironment(): ResourceEnvironment = + composedEnvironment ?: getSystemResourceEnvironment() + +suspend fun getString(resource: StringResource): String = + composeGetString(resourceEnvironment(), resource) + +suspend fun getString(resource: StringResource, vararg formatArgs: Any): String = + composeGetString(resourceEnvironment(), resource, *formatArgs) + +suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String = + composeGetPluralString(resourceEnvironment(), resource, quantity) + +suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String = + composeGetPluralString(resourceEnvironment(), resource, quantity, *formatArgs) + +suspend fun getStringArray(resource: StringArrayResource): List = + composeGetStringArray(resourceEnvironment(), resource) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamClock.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamClock.kt new file mode 100644 index 00000000..770a6834 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamClock.kt @@ -0,0 +1,118 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +/** + * Estimates the offset between this device's clock and the room server's (NTP-style). + * + * Each ping is sent at local time `c` and answered with server time `s`; it arrives back + * at local time `r`. Assuming symmetric latency, the server read its clock halfway + * through, so `offset = s - (c + r) / 2`. Samples with the lowest round trip have the + * least room for asymmetry, so the estimate uses the fastest of the recent samples. + * Samples older than [maxAgeMs] are dropped, so the estimate follows slow clock drift. + */ +class JamClock(private val window: Int = 16, private val maxAgeMs: Long = 15 * 60_000L) { + private data class Sample(val rttMs: Long, val offsetMs: Long, val atMs: Long) + + private val samples = ArrayDeque() + + /** Local-to-server offset in ms, or null before the first sample. */ + var offsetMs: Long? = null + private set + + /** Round trip of the sample the offset came from. */ + var rttMs: Long? = null + private set + + fun addSample(clientSentMs: Long, serverMs: Long, clientReceivedMs: Long) { + val rtt = clientReceivedMs - clientSentMs + if (rtt < 0) return + samples.addLast(Sample(rtt, serverMs - (clientSentMs + clientReceivedMs) / 2, clientReceivedMs)) + while (samples.size > window || samples.first().atMs < clientReceivedMs - maxAgeMs) samples.removeFirst() + val best = samples.minBy { it.rttMs } + offsetMs = best.offsetMs + rttMs = best.rttMs + } + + fun serverTime(localMs: Long): Long = localMs + (offsetMs ?: 0L) + + fun reset() { + samples.clear() + offsetMs = null + rttMs = null + } +} + +/** What a guest does about the gap between where the room is and where it is. */ +sealed class JamCorrection { + /** In sync: play at the room's rate. */ + data object None : JamCorrection() + + /** Slightly off: speed up or slow down until caught up (inaudible, no gap). */ + data class Nudge(val rate: Float) : JamCorrection() + + /** Too far off to nudge: jump to [positionMs]. */ + data class Seek(val positionMs: Long) : JamCorrection() +} + +/** + * Turns drift into a [JamCorrection]. Players report their position coarsely (VLC in + * ~250 ms steps), so decisions use the median of the last few drift readings, and + * small drift inside [deadBandMs] is left alone. + */ +class JamDriftCorrector( + private val deadBandMs: Long = 120, + private val seekThresholdMs: Long = 1_500, + /** Drift is closed over roughly this long while nudging. */ + private val catchUpMs: Long = 3_000, + private val maxNudge: Float = 0.05f, + private val window: Int = 5, + /** Readings needed before a seek, so one noisy report right after a change can't cause one. */ + private val minReadingsToSeek: Int = 3) { + private val drifts = ArrayDeque() + + /** + * [expectedMs] is the room's position, [actualMs] ours, [roomRate] the host's rate. + * [seekLeadMs] is how long a seek takes to resume here; a seek aims that far ahead. + */ + fun correct(expectedMs: Long, actualMs: Long, roomRate: Float, seekLeadMs: Long): JamCorrection { + val drift = expectedMs - actualMs + // A huge jump is acted on at once; waiting for the median would replay it for seconds. + if (kotlin.math.abs(drift) > seekThresholdMs * 2) { + reset() + return JamCorrection.Seek(expectedMs + seekLeadMs) + } + drifts.addLast(drift) + while (drifts.size > window) drifts.removeFirst() + val smoothed = drifts.sorted()[drifts.size / 2] + + return when { + kotlin.math.abs(smoothed) > seekThresholdMs && drifts.size >= minReadingsToSeek -> { + reset() + JamCorrection.Seek(expectedMs + seekLeadMs) + } + kotlin.math.abs(smoothed) <= deadBandMs -> JamCorrection.None + else -> { + val nudge = (smoothed.toFloat() / catchUpMs).coerceIn(-maxNudge, maxNudge) + JamCorrection.Nudge(roomRate * (1f + nudge)) + } + } + } + + fun reset() = drifts.clear() +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt new file mode 100644 index 00000000..ebef9b60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import okio.ByteString.Companion.encodeUtf8 + +/** + * Stable traits of this device (model, OS version, locale, …) for jam bans. They never + * leave the device as they are: [jamDeviceDigest] hashes them with the room code, and the + * server hashes that again with the network, so a room only ever holds an opaque value. + */ +internal expect fun jamDeviceTraits(): List + +/** Per-room digest of [jamDeviceTraits]: unlinkable across rooms. */ +internal fun jamDeviceDigest(roomCode: String): String? = runCatching { + val traits = jamDeviceTraits().filter { it.isNotBlank() } + if (traits.isEmpty()) return null + "spotube-jam-device:$roomCode:${traits.joinToString("|")}".encodeUtf8().sha256().hex() +}.getOrNull() + +/** + * This install's participant secret for room [roomCode], derived from the install's + * [deviceKey] (random, kept in settings). The same install gets the same identity in a + * room every time, so a ban sticks across rejoins; different rooms get unrelated keys. + */ +internal fun jamParticipantKey(deviceKey: String, roomCode: String): String = + "spotube-jam:$roomCode".encodeUtf8().hmacSha256(deviceKey.encodeUtf8()).hex() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt new file mode 100644 index 00000000..fcf8a284 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt @@ -0,0 +1,52 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import okio.ByteString.Companion.encodeUtf8 +import org.spotube.spotube.core.i18n.getString +import org.spotube.spotube.core.i18n.getStringArray +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_name_adjectives +import spotube.composeapp.generated.resources.jam_name_animals +import spotube.composeapp.generated.resources.jam_name_format + +/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */ +object JamNames { + /** The server's limit (`MAX_NAME_LENGTH` in the jam-server repository's `src/protocol.ts`). */ + const val MAX_LENGTH = 40 + + /** + * The generated name for [seed]. The same seed always gets the same name, so with this + * install's key a device keeps its name across sessions until the user picks their own. + */ + suspend fun generate(seed: String): String { + val adjectives = getStringArray(Res.array.jam_name_adjectives) + val animals = getStringArray(Res.array.jam_name_animals) + val hash = "spotube-jam-name:$seed".encodeUtf8().sha256() + val adjective = adjectives[pick(hash[0], hash[1], adjectives.size)] + val animal = animals[pick(hash[2], hash[3], animals.size)] + return getString(Res.string.jam_name_format, adjective, animal).take(MAX_LENGTH) + } + + /** Cleans a name the user typed; blank means "use the generated one". */ + fun normalize(input: String): String = + input.filterNot { it.isISOControl() }.replace(Regex("\\s+"), " ").trim() + + private fun pick(high: Byte, low: Byte, size: Int): Int = + (((high.toInt() and 0xFF) shl 8) or (low.toInt() and 0xFF)) % size +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt new file mode 100644 index 00000000..195e3fe3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt @@ -0,0 +1,210 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.PluginTaggedAudioSource +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Room payloads. The server only routes and retains them; their shape is owned here. + * + * - [JamQueue] (retained, host -> everyone): the shared queue. + * - [JamAnchor] (retained, host -> everyone): where playback is, in server time. + * - [JamCommand] (guest -> host): playback control and suggestions. + */ +@Serializable +data class JamQueue( + val items: List, + val shuffleEnabled: Boolean = false) + +/** + * A playback anchor: track [trackKey] (at queue [index]) was at [positionMs] at server + * time [serverTimeMs]. While [advancing], everyone extrapolates the position from it at + * [rate]; a buffering host sends a non-advancing anchor so guests don't run ahead. + */ +/** The audio source the host plays [trackKey] from. */ +@Serializable +data class JamTrackSource(val trackKey: String, val source: PluginTaggedAudioSource) + +@Serializable +data class JamAnchor( + val trackKey: String, + val index: Int, + val positionMs: Long, + val serverTimeMs: Long, + /** Room-wide play/pause intent; guests follow every change of it. */ + val playing: Boolean, + val advancing: Boolean = playing, + val rate: Float = 1f, + /** The audio source the host plays [trackKey] from, so guests stream the same file. */ + val source: PluginTaggedAudioSource? = null, + /** + * The host's sources for the next songs. A player loads the next song long before it + * plays, so guests need the host's recording of it well before then. + */ + val upcoming: List = emptyList()) { + /** The room's position at server time [serverNowMs]. */ + fun positionAt(serverNowMs: Long): Long = + if (advancing) positionMs + ((serverNowMs - serverTimeMs) * rate).toLong() else positionMs +} + +/** Playback control and suggestions. Volume, loop and the local queue view stay local. */ +@Serializable +sealed class JamCommand { + @Serializable + @SerialName("setPlaying") + data class SetPlaying(val playing: Boolean) : JamCommand() + + @Serializable + @SerialName("skipNext") + data object SkipNext : JamCommand() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : JamCommand() + + @Serializable + @SerialName("jumpTo") + data class JumpTo(val index: Int) : JamCommand() + + @Serializable + @SerialName("seekTo") + data class SeekTo(val positionMs: Long) : JamCommand() + + @Serializable + @SerialName("suggest") + data class Suggest( + val items: List, + val addedBy: String = "") : JamCommand() +} + +@Serializable +data class JamMediaItem( + val url: String, + val trackId: String = "", + val title: String, + val artist: String, + val album: String, + val durationMs: Long, + val coverUrl: String, + val protocol: String, + val addedBy: String = "") { + /** Identifies the track across devices: the metadata id, or an opaque id for local files. */ + val key: String get() = trackId.ifBlank { url } + + /** + * This item as received from another participant, made safe to load: streaming tracks + * only (a peer's local file can't play here, and a URL would make this device fetch + * whatever a peer chose), https covers only, and bounded text. Null if unusable. + */ + fun sanitized(): JamMediaItem? { + if (trackId.isBlank() || trackId.length > MAX_ID_LENGTH) return null + return copy( + url = "", + title = title.take(MAX_TEXT_LENGTH), + artist = artist.take(MAX_TEXT_LENGTH), + album = album.take(MAX_TEXT_LENGTH), + durationMs = durationMs.coerceAtLeast(0), + coverUrl = coverUrl.takeIf { it.startsWith("https://") && it.length <= MAX_URL_LENGTH }.orEmpty(), + protocol = protocol.take(MAX_ID_LENGTH), + addedBy = addedBy.take(MAX_ID_LENGTH)) + } + + companion object { + private const val MAX_ID_LENGTH = 200 + private const val MAX_TEXT_LENGTH = 300 + private const val MAX_URL_LENGTH = 2048 + + /** The cross-device key of a local file at [path], which doesn't reveal the path. */ + fun localTrackKey(path: String): String = "local:" + path.hashCode().toUInt().toString(16) + + fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { + is QueueEntry.StreamingTrack -> JamMediaItem( + url = "", + trackId = entry.track.id, + title = entry.track.title, + artist = entry.track.artists.joinToString(", ") { it.name }, + album = entry.track.album?.title.orEmpty(), + durationMs = entry.track.durationMs, + coverUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = entry.protocol.name, + addedBy = entry.addedBy) + + // Only an opaque id: the path would show the host's folders (and user name) to everyone. + is QueueEntry.LocalTrack -> JamMediaItem( + url = localTrackKey(entry.url), + trackId = "", + title = entry.name, + artist = entry.artists.joinToString(", "), + album = entry.album.orEmpty(), + durationMs = entry.duration, + coverUrl = "", + protocol = "PROGRESSIVE", + addedBy = entry.addedBy) + } + + fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( + url = "", + trackId = track.id, + title = track.title, + artist = track.artists.joinToString(", ") { it.name }, + album = track.album?.title.orEmpty(), + durationMs = track.durationMs, + coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = "PROGRESSIVE") + + fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( + url = item.url, + title = item.title, + artist = item.artist, + album = item.album, + durationMs = item.duration.inWholeMilliseconds, + coverUrl = item.coverURL, + protocol = item.protocol.name) + + fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( + title = item.title, + artist = item.artist, + album = item.album, + duration = kotlin.time.Duration.parse("${item.durationMs}ms"), + coverURL = item.coverUrl, + url = item.url, + protocol = org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol + .valueOf(item.protocol.ifBlank { "PROGRESSIVE" })) + } +} + +@Serializable +data class JamParticipant( + val id: String, + val displayName: String, + val isHost: Boolean) + +@Serializable +enum class JamRole { + Host, + Guest, +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt new file mode 100644 index 00000000..ec8de3f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt @@ -0,0 +1,560 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import co.touchlab.kermit.Logger +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.URLBuilder +import io.ktor.http.URLProtocol +import io.ktor.http.appendPathSegments +import io.ktor.http.isSuccess +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator +import org.spotube.spotube.core.i18n.getString +import org.spotube.spotube.core.network.HttpClientFactory +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_error_banned +import spotube.composeapp.generated.resources.jam_error_connection_failed +import spotube.composeapp.generated.resources.jam_error_connection_lost +import spotube.composeapp.generated.resources.jam_error_kicked +import spotube.composeapp.generated.resources.jam_error_replaced +import spotube.composeapp.generated.resources.jam_error_room_full +import spotube.composeapp.generated.resources.jam_error_room_not_found +import spotube.composeapp.generated.resources.jam_error_server_incompatible +import spotube.composeapp.generated.resources.jam_error_server_unreachable +import spotube.composeapp.generated.resources.settings_jam_server_test_ok +import kotlin.time.Clock + +/** + * WebSocket client for one jam room on the room server (https://github.com/team-spotube/jam-server, a Cloudflare + * Durable Object per room code). + * + * The server keeps the host's latest queue and anchor for late joiners, routes guest + * commands to the host and is the clock every participant syncs playback against + * ([clock]). A dropped connection is retried with the same client id, which the + * server treats as the same participant (a host keeps the room for a grace period). + * + * The room is billed per incoming message, and on phones every transmission wakes the + * cellular radio for seconds, so the steady state sends almost nothing: one keep-alive + * a minute (answered by the server runtime for free) and a clock re-sync every + * [CLOCK_RESYNC_INTERVAL_MS]. Reconnect attempts back off to [RECONNECT_BACKOFF_MS]'s + * last step while the network stays away. + */ +class JamRoomClient(httpClientFactory: HttpClientFactory) { + private val log = Logger.withTag("JamRoomClient") + // Nothing here may take the app down: a network failure is a reconnect, not a crash. + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + + CoroutineExceptionHandler { _, e -> log.w(e) { "Unexpected jam connection failure" } }) + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + private val http = httpClientFactory.create { + install(WebSockets) + install(HttpTimeout) { connectTimeoutMillis = CONNECT_TIMEOUT_MS } + } + private val plainHttp = httpClientFactory.create { + install(HttpTimeout) { requestTimeoutMillis = CONNECT_TIMEOUT_MS } + } + + val clock = JamClock() + + private var session: DefaultWebSocketSession? = null + private var connectionJob: Job? = null + private var reader: Deferred? = null + private var pingId = 0 + /** When the current socket last received anything; a silent socket is a dead one. */ + private var lastFrameAtMs = 0L + /** The room's host as last reported by the server. */ + private var hostId: String? = null + + /** + * Our public participant id in the current room, as the server derived it from our + * secret key. Null until the first welcome. + */ + var selfId: String? = null + private set + private val pongs = MutableStateFlow(0) + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + // ---------- Public API ---------- + + /** Checks that [serverUrl] runs a compatible room server. Returns a latency description. */ + suspend fun testConnection(serverUrl: String): Result = runCatching { + val started = kotlin.time.TimeSource.Monotonic.markNow() + val response = plainHttp.get(healthUrl(serverUrl)) + if (!response.status.isSuccess()) error(getString(Res.string.jam_error_server_unreachable)) + val health = json.decodeFromString(Health.serializer(), response.bodyAsText()) + if (!health.ok || health.v != PROTOCOL_VERSION) error(getString(Res.string.jam_error_server_incompatible)) + getString(Res.string.settings_jam_server_test_ok, started.elapsedNow().inWholeMilliseconds) + } + + /** + * Opens room [code] on [serverUrl] and syncs the clock. Fails with [JamRoomException] + * when the server refuses (code taken, room not found, banned, …). Once connected, + * drops are retried in the background until [disconnect] or a refusal. + * + * [clientKey] is this participant's secret ([jamParticipantKey]): reconnecting with + * it makes us the same participant, so it never leaves this device except to the + * server. [deviceDigest] ([jamDeviceDigest]) lets a ban cover this device too. + */ + suspend fun connect( + serverUrl: String, + code: String, + clientKey: String, + displayName: String, + create: Boolean, + deviceDigest: String? = null): Result { + disconnect() + clock.reset() + val opener = Opener(serverUrl, code, clientKey, displayName, deviceDigest) + val welcome = runCatching { open(opener, create) } + .onFailure { e -> + log.w(e) { "Failed to open jam room $code" } + _connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed) + } + .getOrElse { return Result.failure(it) } + + connectionJob = scope.launch { + var current = welcome.second + var attempt = 0 + while (isActive) { + val refusal = runCatching { current.await() }.getOrNull() + ensureActive() + _isConnected.value = false + if (refusal != null) { + fail(refusal) + return@launch + } + _connectionError.value = getString(Res.string.jam_error_connection_lost) + // Rejoin: the room exists now, so even a former host comes back with "join". + while (isActive) { + delay(RECONNECT_BACKOFF_MS[attempt.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)]) + attempt++ + var reopened = runCatching { open(opener, create = false) } + // Everyone was gone long enough for the room to expire: its host brings it back. + if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" && + hostId != null && hostId == selfId + ) { + log.i { "Jam room $code expired while offline; recreating it" } + reopened = runCatching { open(opener, create = true) } + } + val error = reopened.exceptionOrNull() + if (error is JamRoomException) { + fail(error.code) + return@launch + } + val (event, socket) = reopened.getOrNull() ?: continue + attempt = 0 + current = socket + _events.emit(event) + break + } + } + } + return Result.success(welcome.first) + } + + fun sendQueue(queue: JamQueue) = send(ClientFrame.Queue(queue)) + + fun sendAnchor(anchor: JamAnchor) = send(ClientFrame.Anchor(anchor)) + + fun sendCommand(command: JamCommand) = send(ClientFrame.Cmd(command)) + + fun kick(participantId: String, ban: Boolean) = send(ClientFrame.Kick(participantId, ban)) + + suspend fun disconnect() { + connectionJob?.cancel() + connectionJob = null + hostId = null + selfId = null + reader?.cancel() + reader = null + val current = session + session = null + _isConnected.value = false + _connectionError.value = null + runCatching { current?.close() } + } + + // ---------- Internals ---------- + + /** + * Connects, waits for the welcome (or refusal) and syncs the clock. Returns the welcome + * and the socket's reader, which completes with the server's refusal (if any) on close. + */ + /** Everything needed to (re)open one room connection. */ + private data class Opener( + val serverUrl: String, + val code: String, + val clientKey: String, + val displayName: String, + val deviceDigest: String?) + + private suspend fun open(opener: Opener, create: Boolean): Pair> { + val code = opener.code + val socket = runCatching { http.webSocketSession(roomUrl(opener, create)) } + .getOrElse { throw IllegalStateException(getString(Res.string.jam_error_server_unreachable), it) } + try { + val welcome = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { awaitWelcome(socket) } + ?: throw IllegalStateException(getString(Res.string.jam_error_server_unreachable)) + if (welcome.v != PROTOCOL_VERSION) { + throw IllegalStateException(getString(Res.string.jam_error_server_incompatible)) + } + + session = socket + selfId = welcome.you + hostId = welcome.host + lastFrameAtMs = now() + _isConnected.value = true + _connectionError.value = null + // Read from here on: a pong's arrival time is part of the clock measurement. + val socketReader = scope.async { pump(socket) } + reader = socketReader + syncClock(socket) + log.i { "Joined jam room $code as ${welcome.you} (host=${welcome.host}, rtt=${clock.rttMs}ms)" } + return welcome.toEvent() to socketReader + } catch (e: Throwable) { + runCatching { socket.close() } + throw e + } + } + + private suspend fun awaitWelcome(socket: DefaultWebSocketSession): ServerFrame.Welcome { + while (true) { + val frame = socket.incoming.receiveCatching().getOrNull() + ?: throw IllegalStateException(getString(Res.string.jam_error_connection_failed)) + when (val decoded = decode(frame)) { + is ServerFrame.Welcome -> return decoded + is ServerFrame.Error -> throw JamRoomException(decoded.code, refusalMessage(decoded.code)) + else -> Unit + } + } + } + + /** + * A burst of clock pings (playback can't sync before the clock is known); waits for + * their pongs, so the first sync is done when this returns. + */ + private suspend fun syncClock(socket: DefaultWebSocketSession, pings: Int = INITIAL_PINGS) { + val target = pongs.value + pings + repeat(pings) { + ping(socket) + delay(PING_SPACING_MS) + } + withTimeoutOrNull(CLOCK_SYNC_TIMEOUT_MS) { pongs.first { it >= target } } + } + + /** + * Reads frames until the socket closes or fails (a dropped network surfaces here as + * an exception). Returns the server's refusal, if any. + */ + private suspend fun pump(socket: DefaultWebSocketSession): String? = withKeepAlive(socket) { + var refusal: String? = null + try { + for (frame in socket.incoming) { + lastFrameAtMs = now() + refusal = handle(frame) ?: refusal + } + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + log.i { "Jam connection dropped: ${e.message}" } + } + refusal + } + + /** + * Keeps the socket alive (and NAT mappings open) with free keep-alives, gives up on a + * socket that has gone silent (a half-open connection never errors by itself), and + * re-syncs the clock now and then. + */ + private suspend fun withKeepAlive( + socket: DefaultWebSocketSession, + block: suspend () -> T): T { + val keeper = scope.launch { + var lastClockSyncMs = now() + while (isActive) { + delay(KEEPALIVE_INTERVAL_MS) + if (now() - lastFrameAtMs > DEAD_AFTER_MS) { + log.i { "Jam connection went silent; reconnecting" } + runCatching { socket.close() } + socket.cancel() + return@launch + } + runCatching { socket.send(Frame.Text(KEEPALIVE_PING)) } + if (now() - lastClockSyncMs >= CLOCK_RESYNC_INTERVAL_MS) { + lastClockSyncMs = now() + runCatching { syncClock(socket, RESYNC_PINGS) } + } + } + } + return try { + block() + } finally { + keeper.cancel() + } + } + + /** Routes one frame; returns an error code when the server refused us. */ + private suspend fun handle(frame: Frame): String? { + if (frame is Frame.Text && frame.readText() == KEEPALIVE_PONG) return null + when (val decoded = decode(frame)) { + is ServerFrame.Pong -> { + clock.addSample(decoded.c, decoded.s, now()) + pongs.value++ + } + is ServerFrame.Welcome -> { + hostId = decoded.host + _events.emit(decoded.toEvent()) + } + is ServerFrame.Presence -> { + hostId = decoded.host + _events.emit(JamRoomEvent.Presence(decoded.host, decoded.participants.toParticipants(decoded.host))) + } + is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body)) + is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body)) + is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body)) + // Sent too much too fast (a burst of skips, say): not a refusal, just reconnect. + is ServerFrame.Error -> return decoded.code.takeIf { it != "rate_limited" } + null -> Unit + } + return null + } + + private suspend fun fail(code: String) { + val message = refusalMessage(code) + log.i { "Jam room closed by the server: $code" } + session = null + _connectionError.value = message + _events.emit(JamRoomEvent.Closed(code, message)) + } + + private suspend fun ping(socket: DefaultWebSocketSession) { + socket.send(Frame.Text(json.encodeToString(ClientFrame.serializer(), ClientFrame.Ping(++pingId, now())))) + } + + private fun send(frame: ClientFrame) { + val socket = session ?: return + val text = json.encodeToString(ClientFrame.serializer(), frame) + scope.launch { + runCatching { socket.send(Frame.Text(text)) } + .onFailure { log.w(it) { "Failed to send jam frame" } } + } + } + + private fun decode(frame: Frame): ServerFrame? { + if (frame !is Frame.Text) return null + val text = frame.readText() + return runCatching { json.decodeFromString(ServerFrame.serializer(), text) } + .onFailure { log.w(it) { "Failed to decode jam frame: ${text.take(200)}" } } + .getOrNull() + } + + private suspend fun refusalMessage(code: String): String = when (code) { + "room_not_found" -> getString(Res.string.jam_error_room_not_found) + "room_full" -> getString(Res.string.jam_error_room_full) + "banned" -> getString(Res.string.jam_error_banned) + "kicked" -> getString(Res.string.jam_error_kicked) + "replaced" -> getString(Res.string.jam_error_replaced) + else -> getString(Res.string.jam_error_connection_failed) + } + + private fun roomUrl(opener: Opener, create: Boolean) = + URLBuilder(opener.serverUrl.trim()).apply { + protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS + appendPathSegments("v1", "rooms", opener.code) + parameters.append("key", opener.clientKey) + parameters.append("name", opener.displayName) + parameters.append("mode", if (create) "create" else "join") + opener.deviceDigest?.let { parameters.append("dev", it) } + }.buildString() + + private fun healthUrl(serverUrl: String) = + URLBuilder(serverUrl.trim()).apply { + if (protocol == URLProtocol.WS) protocol = URLProtocol.HTTP + if (protocol == URLProtocol.WSS) protocol = URLProtocol.HTTPS + appendPathSegments("v1", "health") + }.buildString() + + private fun now(): Long = Clock.System.now().toEpochMilliseconds() + + companion object { + const val PROTOCOL_VERSION = 1 + private const val CONNECT_TIMEOUT_MS = 10_000L + private const val INITIAL_PINGS = 6 + private const val RESYNC_PINGS = 3 + private const val PING_SPACING_MS = 100L + private const val CLOCK_SYNC_TIMEOUT_MS = 3_000L + /** Clocks drift by a few ms per minute at most; this keeps sync error well under 50 ms. */ + private const val CLOCK_RESYNC_INTERVAL_MS = 10 * 60_000L + /** + * As rare as possible (each one wakes a phone's radio) while staying under the + * 100 s idle timeout of Cloudflare's proxy. A dropped idle socket would only cost + * a reconnect, so there's no need for margin beyond that. + */ + private const val KEEPALIVE_INTERVAL_MS = 60_000L + /** No pong to two keep-alives in a row: the connection is gone. */ + private const val DEAD_AFTER_MS = 2 * KEEPALIVE_INTERVAL_MS + 10_000L + private const val KEEPALIVE_PING = "ping" + private const val KEEPALIVE_PONG = "pong" + /** Quick at first (most drops are a network switch), then slower to spare the battery. */ + private val RECONNECT_BACKOFF_MS = longArrayOf(500, 1_000, 2_000, 4_000, 8_000, 8_000, 15_000, 15_000, 30_000, 60_000) + } +} + +/** Something that happened in the room, for [JamRoomService]. */ +sealed interface JamRoomEvent { + /** (Re)joined the room; carries the retained queue and anchor. */ + data class Welcome( + val you: String, + val host: String?, + val participants: List, + val queue: JamQueue?, + val anchor: JamAnchor?) : JamRoomEvent + + data class Presence(val host: String?, val participants: List) : JamRoomEvent + data class Queue(val queue: JamQueue) : JamRoomEvent + data class Anchor(val anchor: JamAnchor) : JamRoomEvent + data class Command(val from: String, val command: JamCommand) : JamRoomEvent + + /** The server ended our membership ([code]: kicked, banned, …); no retry follows. */ + data class Closed(val code: String, val message: String) : JamRoomEvent +} + +/** The server refused to let us into the room. */ +class JamRoomException(val code: String, message: String) : Exception(message) + +// ---------- Wire frames (see src/protocol.ts in https://github.com/team-spotube/jam-server) ---------- + +@Serializable +private data class Health(val ok: Boolean = false, val v: Int = 0) + +@Serializable +private data class WireParticipant(val id: String, val name: String) + +private fun List.toParticipants(host: String?) = + map { JamParticipant(it.id, it.name, isHost = it.id == host) } + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("t") +private sealed class ClientFrame { + @Serializable + @SerialName("ping") + data class Ping(val id: Int, val c: Long) : ClientFrame() + + @Serializable + @SerialName("queue") + data class Queue(val body: JamQueue) : ClientFrame() + + @Serializable + @SerialName("anchor") + data class Anchor(val body: JamAnchor) : ClientFrame() + + @Serializable + @SerialName("cmd") + data class Cmd(val body: JamCommand) : ClientFrame() + + @Serializable + @SerialName("kick") + data class Kick(val target: String, val ban: Boolean) : ClientFrame() +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("t") +private sealed class ServerFrame { + @Serializable + @SerialName("welcome") + data class Welcome( + val v: Int, + val you: String, + val host: String? = null, + val participants: List = emptyList(), + val queue: JamQueue? = null, + val anchor: JamAnchor? = null) : ServerFrame() { + fun toEvent() = JamRoomEvent.Welcome(you, host, participants.toParticipants(host), queue, anchor) + } + + @Serializable + @SerialName("pong") + data class Pong(val id: Int, val c: Long, val s: Long) : ServerFrame() + + @Serializable + @SerialName("presence") + data class Presence(val host: String? = null, val participants: List = emptyList()) : ServerFrame() + + @Serializable + @SerialName("queue") + data class Queue(val body: JamQueue) : ServerFrame() + + @Serializable + @SerialName("anchor") + data class Anchor(val body: JamAnchor) : ServerFrame() + + @Serializable + @SerialName("cmd") + data class Cmd(val from: String, val body: JamCommand) : ServerFrame() + + @Serializable + @SerialName("error") + data class Error(val code: String) : ServerFrame() +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt new file mode 100644 index 00000000..3846e813 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt @@ -0,0 +1,59 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Six-character room codes shared verbally / by text. Codes are opaque keys used + * to name a jam room on the room server — they carry no connection details. + * + * The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to + * read aloud and retype. + */ +object JamRoomCode { + const val LENGTH = 6 + private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + /** From a cryptographically secure source: a predictable code would let others in. */ + @OptIn(ExperimentalUuidApi::class) + fun generate(): String { + val bytes = Uuid.random().toByteArray() + // 32 symbols = 5 bits each; the low 5 bits of a random byte are uniform. + return buildString(LENGTH) { + repeat(LENGTH) { append(ALPHABET[bytes[it].toInt() and 31]) } + } + } + + /** + * A participant's secret for one session, from a cryptographically secure source. + * The server derives the public participant id from it; only this device knows it. + */ + @OptIn(ExperimentalUuidApi::class) + fun newClientKey(): String = Uuid.random().toHexString() + Uuid.random().toHexString() + + /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ + fun normalize(input: String): String = input + .uppercase() + .filter { it.isLetterOrDigit() } + .take(LENGTH) + + fun isValid(code: String): Boolean = + code.length == LENGTH && code.all { it in ALPHABET } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt new file mode 100644 index 00000000..3ed3998a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt @@ -0,0 +1,1025 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.PluginTaggedAudioSource +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettingsStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.abs +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_error_invalid_code +import spotube.composeapp.generated.resources.jam_error_no_server +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds + +/** + * A jam session: one host, any number of guests, all playing the same thing at the same + * moment. + * + * Sync model: + * - The host owns the queue and broadcasts it ([JamQueue]), plus a playback [JamAnchor] + * ("track X was at position P at server time T") whenever playback changes. Times are + * in the room server's clock, which every device estimates with [JamClock], so guests + * extrapolate an anchor on their own; the host only re-anchors when its player drifts + * from its last anchor (and rarely, as a safety net). Rooms are billed per message, + * so nothing is sent periodically. + * - Battery: nothing polls while there's nothing to follow. A paused room, a buffering + * host or a missing queue puts the guest's sync loop to sleep until the room or the + * player changes, and the host only checks its own drift while playing. + * - Guests follow the anchor's track and play state, and keep their position within + * ~150 ms of the room by nudging their playback rate a few percent ([JamDriftCorrector]), + * seeking only when far off. + * - Anyone's play/pause, skip, jump and seek go to the host, which applies them for the + * whole room. Volume and loop stay local. + * - Guests stream each track from the host's audio source when they use the same audio + * plugin, so everyone hears the same recording (different uploads don't line up). + * - The server hands hosting to the longest-connected guest when the host leaves. + */ +class JamRoomService( + private val jamClient: JamRoomClient, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val settingsRepository: UserSettingsStore, + private val audioPluginSource: AudioPluginSource, + private val trackSources: TrackSourceRepository, + private val streamingUrlRepository: StreamingUrlRepository, + /** Deletes a track's audio from the disk cache (`CacheManager.invalidateCacheEntry`). */ + private val evictCachedAudio: suspend (trackId: String) -> Unit) { + private val log = Logger.withTag("JamRoomService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * Play/pause intent. Transient states (buffering between tracks, loading, completion) + * keep the last value, so they never pause or resume the room. + */ + private val playIntent: StateFlow = audioPlayer.playerStateFlow + .mapNotNull { state -> + when (state) { + PlayerState.PLAYING -> true + PlayerState.PAUSED -> false + else -> null + } + } + .stateIn(scope, SharingStarted.Eagerly, audioPlayer.playerStateFlow.value == PlayerState.PLAYING) + + private val _role = MutableStateFlow(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _roomCode = MutableStateFlow(null) + val roomCode: StateFlow = _roomCode.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _shuffleEnabled = MutableStateFlow(false) + val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() + + private var localDisplayName: String = "" + + /** Display name of the local participant. */ + val participantDisplayName: String + get() = localDisplayName + + /** Stable id of the local participant (stamped on items this device adds). */ + val participantClientId: String + get() = jamClient.selfId.orEmpty() + + private var roleJob: Job? = null + private var leaving = false + /** Joining or in a room: room events apply (some arrive before join returns). */ + private var inRoom = false + + /** Room-wide play state from the last anchor (null until the first one). */ + private var lastHostIsPlaying: Boolean? = null + + /** Our playback speed before we joined, restored when sync stops changing it. */ + private var speedBeforeJam: Float? = null + + // Latest room state from the host (kept in any role, applied while a guest) + private var roomQueue: JamQueue? = null + private var anchor: JamAnchor? = null + private var lastAppliedItems: List = emptyList() + private val corrector = JamDriftCorrector() + private val syncMutex = Mutex() + private var appliedRate: Float? = null + /** How far ahead to aim a seek so playback resumes on time; learned from each seek. */ + private var seekLeadMs = INITIAL_SEEK_LEAD_MS + private var lastCorrectionWasSeek = false + /** Don't correct before this (a seek or track change is still settling). */ + private var holdSyncUntilMs = 0L + private val pinnedSources = mutableSetOf() + /** Bumped on every anchor and queue from the host; wakes an idle guest sync loop. */ + private val roomVersion = MutableStateFlow(0) + + // Position tracking, used to extrapolate the player's coarse position reports + // and to tell user seeks apart from playback. + private var positionSampleMs = 0L + private var positionSampledAtMs = 0L + private var positionAdvancing = false + private var ownSeekUntilMs = 0L + private var entryChangedAtMs = 0L + /** The song [positionSampleMs] belongs to; a new song starts the tracking from zero. */ + private var positionEntryKey: String? = null + + /** Host: its sources for the next songs, sent with every anchor. */ + private var upcomingSources: List = emptyList() + + private val anchorRequests = Channel(Channel.CONFLATED) + + // The host's last anchor, to notice its own player drifting away from it. + private var sentAnchor: JamAnchor? = null + private var sentAnchorAtMs = 0L + private val hostDrifts = ArrayDeque() + + init { + jamClient.isConnected + .onEach { _isConnected.value = it } + .launchIn(scope) + jamClient.connectionError + .onEach { _connectionError.value = it } + .launchIn(scope) + jamClient.events + .onEach { onEvent(it) } + .launchIn(scope) + audioPlayerQueue.currentQueueEntryFlow + .onEach { onEntryChanged(it?.jamKey()) } + .launchIn(scope) + audioPlayer.positionFlow + .onEach { onPosition(it.inWholeMilliseconds) } + .launchIn(scope) + // Extrapolation restarts from wherever the player was when it started or stopped + // moving. (Not from positionFlow's value: a seek's report must stay a jump for onPosition.) + audioPlayer.playerStateFlow + .onEach { state -> + positionSampleMs = localPositionMs() + positionSampledAtMs = now() + positionAdvancing = state == PlayerState.PLAYING + } + .launchIn(scope) + // A guest's own play/pause (UI, notification, headset) goes to the host, which + // applies it for the whole room. Intent that matches the room's state came from + // following the host, so it isn't echoed back. + playIntent + .onEach { playing -> + val roomPlaying = lastHostIsPlaying + if (_role.value == JamRole.Guest && !leaving && roomPlaying != null && playing != roomPlaying) { + jamClient.sendCommand(JamCommand.SetPlaying(playing)) + } + } + .launchIn(scope) + } + + // ---------- Session lifecycle ---------- + + suspend fun createRoom(): Result { + val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server))) + val deviceKey = deviceKey() + localDisplayName = participantName(deviceKey) + enter() + + // A fresh code is almost never taken; retry the rare collision with another one. + var result: Result + var code: String + var attempts = 0 + do { + code = JamRoomCode.generate() + result = jamClient.connect( + serverUrl, code, jamParticipantKey(deviceKey, code), localDisplayName, create = true, + deviceDigest = jamDeviceDigest(code)) + val taken = (result.exceptionOrNull() as? JamRoomException)?.code == "room_taken" + } while (taken && ++attempts < CREATE_ATTEMPTS) + + val welcome = result.getOrElse { + inRoom = false + return Result.failure(it) + } + start(code, welcome) + return Result.success(code) + } + + suspend fun joinRoom(code: String): Result { + val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server))) + val normalized = JamRoomCode.normalize(code) + if (!JamRoomCode.isValid(normalized)) { + return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code))) + } + val deviceKey = deviceKey() + localDisplayName = participantName(deviceKey) + enter() + + val welcome = jamClient.connect( + serverUrl, normalized, jamParticipantKey(deviceKey, normalized), localDisplayName, create = false, + deviceDigest = jamDeviceDigest(normalized)) + .getOrElse { + inRoom = false + return Result.failure(it) + } + start(normalized, welcome) + return Result.success(Unit) + } + + suspend fun leaveRoom() { + leaving = true + jamClient.disconnect() + reset() + } + + private fun enter() { + inRoom = true + roomQueue = null + anchor = null + } + + private suspend fun start(code: String, welcome: JamRoomEvent.Welcome) { + leaving = false + lastHostIsPlaying = null + speedBeforeJam = audioPlayer.playbackSpeedFlow.value + _roomCode.value = code + _connectionError.value = null + persistLastCode(code) + // Frames that arrived while the clock synced are newer than the welcome's snapshot. + onWelcome(welcome.copy(queue = roomQueue ?: welcome.queue, anchor = anchor ?: welcome.anchor)) + } + + private suspend fun reset() { + inRoom = false + audioPlayerQueue.setEndlessPlaybackSuppressed(false) + upcomingSources = emptyList() + roleJob?.cancel() + roleJob = null + restoreSpeed() + _role.value = null + _roomCode.value = null + _participants.value = emptyList() + _shuffleEnabled.value = false + _isConnected.value = false + roomQueue = null + anchor = null + lastAppliedItems = emptyList() + lastHostIsPlaying = null + pinnedSources.clear() + corrector.reset() + speedBeforeJam = null + } + + // ---------- Controls (called from the UI) ---------- + + fun skipNext() = control(JamCommand.SkipNext) + + fun skipPrevious() = control(JamCommand.SkipPrevious) + + fun jumpTo(index: Int) = control(JamCommand.JumpTo(index)) + + /** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */ + fun toggleShuffle() { + if (_role.value != JamRole.Host) return + scope.launch { + runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) } + } + } + + suspend fun suggestTrack(track: MetadataTrack) { + jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = participantClientId)) + } + + suspend fun suggestPlaylist(tracks: List) { + if (tracks.isEmpty()) return + jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = participantClientId)) + } + + suspend fun kickParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + jamClient.kick(participantId, ban = false) + } + + suspend fun banParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + jamClient.kick(participantId, ban = true) + } + + /** The host applies its own controls; a guest's go to the host. */ + private fun control(command: JamCommand) { + scope.launch { + if (_role.value == JamRole.Host) applyCommand(command, from = participantClientId) else jamClient.sendCommand(command) + } + } + + // ---------- Room events ---------- + + private suspend fun onEvent(event: JamRoomEvent) { + if (!inRoom) return + when (event) { + is JamRoomEvent.Welcome -> onWelcome(event) + is JamRoomEvent.Presence -> { + onMembership(event.host, event.participants) + // The server keeps anchors in memory only; a joiner of a room that was + // asleep gets none in its welcome, so the host answers every join. + if (_role.value == JamRole.Host) publishAnchor() + } + is JamRoomEvent.Queue -> { + roomQueue = event.queue + if (_role.value == JamRole.Guest) applyQueue(event.queue) + } + is JamRoomEvent.Anchor -> { + anchor = event.anchor + if (_role.value == JamRole.Guest) onAnchor(event.anchor) + } + // [JamRoomEvent.Command.from] is the server's word for who sent it; the body is the sender's. + is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command, event.from) + is JamRoomEvent.Closed -> { + log.i { "Left jam room: ${event.code}" } + reset() + _connectionError.value = event.message + } + } + } + + /** First join and every reconnect: the server's view of the room replaces ours. */ + private fun onWelcome(welcome: JamRoomEvent.Welcome) { + welcome.queue?.let { roomQueue = it } + welcome.anchor?.let { anchor = it } + onMembership(welcome.host, welcome.participants, force = true) + } + + private fun onMembership(host: String?, participants: List, force: Boolean = false) { + _participants.value = participants + val role = if (host != null && host == jamClient.selfId) JamRole.Host else JamRole.Guest + // A (re)welcome restarts the role so a guest re-applies the room's state. + if (role == _role.value && !force) return + log.i { "Jam role: ${_role.value} -> $role" } + _role.value = role + roleJob?.cancel() + roleJob = scope.launch { + if (role == JamRole.Host) runHost() else runGuest() + } + } + + // ---------- Host ---------- + + private suspend fun runHost() { + restoreSpeed() + audioPlayerQueue.setEndlessPlaybackSuppressed(false) + sentAnchor = null + upcomingSources = emptyList() + kotlinx.coroutines.coroutineScope { + combine(audioPlayerQueue.queueFlow, audioPlayer.shuffleModeFlow) { queue, shuffle -> + JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle) + } + .distinctUntilChanged() + // At most one upload a second (the server's limit), always the latest queue. + .conflate() + .onEach { queue -> + _shuffleEnabled.value = queue.shuffleEnabled + // After a reconnect the server already has it: skip the re-upload. + if (queue != roomQueue) { + roomQueue = queue + jamClient.sendQueue(queue) + delay(QUEUE_MIN_GAP_MS) + } + } + .launchIn(this) + + // Anchors are built when sent, so coalescing a burst of requests (rapid skips) + // loses nothing; it keeps the host under the server's frame budget. + launch { + for (request in anchorRequests) { + sendAnchor() + delay(ANCHOR_MIN_GAP_MS) + } + } + + combine( + audioPlayerQueue.currentQueueEntryFlow, + playIntent, + audioPlayer.playerStateFlow, + audioPlayer.playbackSpeedFlow) { _, _, _, _ -> } + .onEach { publishAnchor() } + .launchIn(this) + + // Share the next songs' sources as soon as a song starts: a guest's player loads + // the next song long before it plays, and must find the host's recording by then. + launch { + combine( + audioPlayerQueue.currentQueueEntryFlow.map { it?.jamKey() }.distinctUntilChanged(), + audioPlayerQueue.queueFlow) { key, queue -> key to queue } + .collectLatest { (key, queue) -> + val start = queue.indexOfFirst { it.jamKey() == key } + 1 + if (start <= 0) return@collectLatest + val upcoming = queue.drop(start).take(UPCOMING_SOURCES) + .filterIsInstance() + .mapNotNull { entry -> + hostSource(entry.track, match = true)?.let { JamTrackSource(entry.track.id, it) } + } + if (upcoming != upcomingSources) { + upcomingSources = upcoming + publishAnchor() + } + } + } + + // Drift only builds up while playing; a paused host checks nothing. + launch { + while (isActive) { + audioPlayer.playerStateFlow.first { it == PlayerState.PLAYING } + delay(HOST_DRIFT_CHECK_INTERVAL_MS) + checkHostDrift() + } + } + } + } + + /** + * Guests extrapolate the last anchor with the shared clock, so it stays valid as long + * as the host's player keeps pace with its own clock. When it doesn't (audio clock + * drift, a stall the player didn't report as buffering), re-anchor. Readings are + * smoothed like a guest's, since players report position coarsely. + */ + private suspend fun checkHostDrift() { + val last = sentAnchor ?: return + val now = now() + if (now - sentAnchorAtMs >= HOST_ANCHOR_HEARTBEAT_MS) { + publishAnchor() + return + } + if (!last.advancing || audioPlayer.playerStateFlow.value != PlayerState.PLAYING) { + hostDrifts.clear() + return + } + hostDrifts.addLast(localPositionMs() - last.positionAt(jamClient.clock.serverTime(now))) + while (hostDrifts.size > HOST_DRIFT_WINDOW) hostDrifts.removeFirst() + val smoothed = hostDrifts.sorted()[hostDrifts.size / 2] + if (hostDrifts.size == HOST_DRIFT_WINDOW && + abs(smoothed) > HOST_REANCHOR_DRIFT_MS && + now - sentAnchorAtMs >= HOST_REANCHOR_MIN_INTERVAL_MS + ) { + log.d { "Jam host drifted ${smoothed}ms from its anchor; re-anchoring" } + publishAnchor() + } + } + + /** Asks the host loop to send a fresh anchor (coalesced, see [runHost]). */ + private fun publishAnchor() { + anchorRequests.trySend(Unit) + } + + private suspend fun sendAnchor() { + if (_role.value != JamRole.Host) return + val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return + onEntryChanged(current.jamKey()) + val playing = playIntent.value + val next = JamAnchor( + trackKey = current.jamKey(), + index = audioPlayerQueue.queueFlow.value.indexOfFirst { it.jamKey() == current.jamKey() }, + positionMs = localPositionMs(), + serverTimeMs = jamClient.clock.serverTime(now()), + playing = playing, + advancing = playing && audioPlayer.playerStateFlow.value == PlayerState.PLAYING, + rate = audioPlayer.playbackSpeedFlow.value, + source = (current as? QueueEntry.StreamingTrack)?.let { hostSource(it.track, match = false) }, + upcoming = upcomingSources) + sentAnchor = next + sentAnchorAtMs = now() + hostDrifts.clear() + jamClient.sendAnchor(next) + } + + /** + * The source the host's audio plugin matched to [track], so guests can use the same one. + * With [match], a track not matched yet is matched now (as the player would when it got + * there); the current song is being matched by the player already. + */ + private suspend fun hostSource(track: MetadataTrack, match: Boolean): PluginTaggedAudioSource? { + val plugin = audioPluginSource.selectedAudioPlugin.value ?: return null + suspend fun saved() = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull() + val source = saved() ?: if (match) { + runCatching { streamingUrlRepository.resolveStreamInfo(track) } + saved() + } else { + null + } + return source?.let { PluginTaggedAudioSource(plugin.pluginId, it) } + } + + private suspend fun applyCommand(command: JamCommand, from: String) { + runCatching { + when (command) { + // Applied locally; the anchor carries the change to every guest. + is JamCommand.SetPlaying -> if (command.playing) audioPlayer.play() else audioPlayer.pause() + JamCommand.SkipNext -> audioPlayer.skipToNext() + JamCommand.SkipPrevious -> audioPlayer.skipToPrevious() + is JamCommand.JumpTo -> audioPlayer.jumpTo(command.index) + is JamCommand.SeekTo -> { + seek(command.positionMs) + publishAnchor() + } + is JamCommand.Suggest -> acceptSuggestion(command.items, from) + } + }.onFailure { log.w(it) { "Failed to apply jam command $command" } } + } + + /** Suggestions come from other devices: only clean streaming items, credited to the actual sender. */ + private suspend fun acceptSuggestion(items: List, from: String) { + val accepted = items.asSequence() + .mapNotNull { it.sanitized() } + .take(MAX_SUGGESTED_ITEMS) + .map { it.copy(addedBy = from) } + .toList() + if (accepted.isEmpty()) return + log.i { "Accepting ${accepted.size} suggested item(s) into the jam queue" } + audioPlayerQueue.addAllToQueue(accepted.map { it.toQueueEntry() }) + } + + // ---------- Guest ---------- + + private suspend fun runGuest() { + // Guests follow the host's order through anchors; a local shuffle would only + // make auto-advance pick a different track for a moment. + runCatching { audioPlayer.shuffle(false) } + audioPlayerQueue.setEndlessPlaybackSuppressed(true) + corrector.reset() + // The host's recordings first, so the queue's first load already uses them. + anchor?.let { pinSources(it) } + roomQueue?.let { applyQueue(it) } + anchor?.let { onAnchor(it) } + while (true) { + // Captured before the check, so a change during it still wakes the wait below. + val seenRoom = roomVersion.value + val seenState = audioPlayer.playerStateFlow.value + val seenQueue = audioPlayerQueue.queueFlow.value + val status = syncMutex.withLock { + runCatching { syncOnce() } + .onFailure { log.w(it) { "Jam sync failed" } } + .getOrDefault(SyncStatus.Busy) + } + when (status) { + // While playing the CPU is awake for audio anyway, so a fast check is free + // and catches drift early (slower checks made seeks settle visibly later). + SyncStatus.Busy, SyncStatus.InSync -> delay(SYNC_INTERVAL_MS) + // Sleep until the room or our player changes (the timeout is a safety net). + SyncStatus.Idle -> withTimeoutOrNull(IDLE_RECHECK_MS) { + merge( + roomVersion.filter { it != seenRoom }, + audioPlayer.playerStateFlow.filter { it != seenState }, + audioPlayerQueue.queueFlow.filter { it !== seenQueue }).first() + } + } + } + } + + private enum class SyncStatus { + /** Correcting or settling: check again soon. */ + Busy, + /** Playing in sync: drift builds up slowly. */ + InSync, + /** Nothing to follow until the room or the player changes. */ + Idle, + } + + private suspend fun onAnchor(next: JamAnchor) { + anchor = next + roomVersion.value++ + // Play/pause is room-wide: follow every change of the room's play state. The room + // state is recorded first, so the resulting local intent isn't sent back as a command. + if (next.playing != lastHostIsPlaying) { + lastHostIsPlaying = next.playing + if (next.playing != playIntent.value) { + runCatching { if (next.playing) audioPlayer.play() else audioPlayer.pause() } + .onFailure { log.w(it) { "Failed to follow the host's play state" } } + } + } + pinSources(next) + } + + private suspend fun pinSources(anchor: JamAnchor) { + anchor.source?.let { pinSource(anchor.trackKey, it) } + anchor.upcoming.forEach { pinSource(it.trackKey, it.source) } + } + + private suspend fun applyQueue(queue: JamQueue) { + roomVersion.value++ + _shuffleEnabled.value = queue.shuffleEnabled + // The host's local files can't play here, and anything else is cleaned first. + val items = queue.items.mapNotNull { it.sanitized() } + if (items == lastAppliedItems) return + val previous = lastAppliedItems + lastAppliedItems = items + // Sources that arrived before the queue had their tracks, before the queue loads them. + anchor?.let { pinSources(it) } + + // The host only appended items (e.g. accepted suggestions): merge them in + // without disturbing playback. + val current = audioPlayerQueue.currentQueueEntryFlow.value + if (previous.isNotEmpty() && items.size > previous.size && + items.take(previous.size) == previous && current != null + ) { + runCatching { audioPlayerQueue.addAllToQueue(items.drop(previous.size).map { it.toQueueEntry() }) } + .onFailure { log.w(it) { "Failed to append jam queue items" } } + return + } + + val room = anchor + val startIndex = room?.let { a -> items.indexOfFirst { it.key == a.trackKey } }?.takeIf { it >= 0 } + ?: items.indexOfFirst { it.key == current?.jamKey() }.takeIf { it >= 0 } + ?: 0 + holdSync(TRACK_CHANGE_HOLD_MS) + runCatching { + audioPlayerQueue.load( + entries = items.map { it.toQueueEntry() }, + autoPlay = room?.playing ?: false, + startPosition = startIndex) + }.onFailure { log.w(it) { "Failed to apply jam queue" } } + } + + /** One step of following the room: right track, then right position. */ + private suspend fun syncOnce(): SyncStatus { + if (_role.value != JamRole.Guest || leaving) return SyncStatus.Idle + val room = anchor ?: return SyncStatus.Idle + val now = now() + if (now < holdSyncUntilMs) return SyncStatus.Busy + + val current = audioPlayerQueue.currentQueueEntryFlow.value + onEntryChanged(current?.jamKey()) + if (current?.jamKey() != room.trackKey) { + val queue = audioPlayerQueue.queueFlow.value + val index = queue.indexOfFirst { it.jamKey() == room.trackKey } + if (index < 0) return SyncStatus.Idle // The queue with this track hasn't arrived yet. + if (finishedSongFirst(queue, current, index, room, now)) return SyncStatus.Busy + log.d { "Jam sync: switching to queue index $index" } + holdSync(TRACK_CHANGE_HOLD_MS) + corrector.reset() + audioPlayerQueue.jumpTo(index, autoPlay = room.playing) + return SyncStatus.Busy + } + + val expected = room.positionAt(jamClient.clock.serverTime(now)) + val actual = localPositionMs() + + if (!room.playing) { + // Paused room: line up once, so everyone resumes from the same spot. + if (abs(expected - actual) > PAUSED_ALIGN_THRESHOLD_MS) { + seek(expected) + return SyncStatus.Busy + } + return SyncStatus.Idle + } + // A buffering host isn't moving; guests wait for its next anchor instead of + // chasing a position that is about to be corrected. A buffering guest wakes + // up when its player starts playing. + if (!room.advancing || audioPlayer.playerStateFlow.value != PlayerState.PLAYING) return SyncStatus.Idle + val durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds + if (expected < 0 || (durationMs > 0 && expected >= durationMs - END_GUARD_MS)) return SyncStatus.Busy + + val drift = expected - actual + if (lastCorrectionWasSeek) { + // Whatever is left after a seek is how far off the lead was. + seekLeadMs = (seekLeadMs + drift / 2).coerceIn(0L, MAX_SEEK_LEAD_MS) + lastCorrectionWasSeek = false + } + + return when (val correction = corrector.correct(expected, actual, room.rate, seekLeadMs)) { + JamCorrection.None -> { + // Exactly the room's rate (normally 1x): any other rate makes the player + // time-stretch, which costs CPU for as long as it lasts. + setRate(room.rate) + SyncStatus.InSync + } + is JamCorrection.Nudge -> { + setRate(correction.rate) + SyncStatus.Busy + } + is JamCorrection.Seek -> { + log.d { "Jam sync: ${drift}ms off, seeking (lead ${seekLeadMs}ms)" } + setRate(room.rate) + seek(correction.positionMs) + holdSync(SEEK_HOLD_MS) + lastCorrectionWasSeek = true + SyncStatus.Busy + } + } + } + + /** + * Our song ended a moment before the host's (players and recordings differ slightly in + * length), so we're on the song after the room's. Wait for the host to get there + * instead of going back to replay the ending. The room has to be near the end of its + * song: a host that went back a song is followed right away. + */ + private fun finishedSongFirst( + queue: List, + current: QueueEntry?, + roomIndex: Int, + room: JamAnchor, + now: Long): Boolean { + if (!room.playing || current == null) return false + if (queue.indexOfFirst { it.jamKey() == current.jamKey() } != roomIndex + 1) return false + if (now - entryChangedAtMs > NEXT_SONG_WAIT_MS) return false + val roomSongMs = roomQueue?.items?.firstOrNull { it.key == room.trackKey }?.durationMs + ?: (queue[roomIndex] as? QueueEntry.StreamingTrack)?.track?.durationMs + ?: return false + return roomSongMs > 0 && room.positionAt(jamClient.clock.serverTime(now)) >= roomSongMs - NEAR_SONG_END_MS + } + + /** + * Uses the host's audio source for [trackKey] when we use the same audio plugin, so + * both sides stream the same recording. Uploads of one song differ in intro length, + * which no amount of clock sync can fix. Upcoming songs are pinned well before our + * player loads them; a song already playing from another recording is reloaded. + */ + private suspend fun pinSource(trackKey: String, tagged: PluginTaggedAudioSource) { + val pin = "$trackKey|${tagged.source.id}" + if (pin in pinnedSources) return + val plugin = audioPluginSource.selectedAudioPlugin.value ?: return + if (plugin.pluginId != tagged.pluginId) return + // Not in the room's queue yet: tried again when the queue arrives (applyQueue). + val track = roomTrack(trackKey) ?: return + pinnedSources += pin + val mine = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull() + if (mine == tagged.source) return + + log.i { "Jam: using the host's source ${tagged.source.id} for $trackKey" } + runCatching { + trackSources.saveTrackSource(track, tagged.source, plugin.pluginId) + streamingUrlRepository.invalidateCachedStreamUrl(trackKey) + // A cached copy of another recording would be played instead. + evictCachedAudio(trackKey) + if (audioPlayerQueue.currentQueueEntryFlow.value?.jamKey() == trackKey) reloadForNewSource(trackKey) + }.onFailure { log.w(it) { "Failed to switch to the host's audio source" } } + } + + /** [trackKey]'s track, from our queue or (before it has loaded) the room's. */ + private fun roomTrack(trackKey: String): MetadataTrack? = + audioPlayerQueue.queueFlow.value + .firstNotNullOfOrNull { (it as? QueueEntry.StreamingTrack)?.track?.takeIf { t -> t.id == trackKey } } + ?: roomQueue?.items?.firstOrNull { it.trackId == trackKey }?.sanitized() + ?.let { (it.toQueueEntry() as? QueueEntry.StreamingTrack)?.track } + + /** + * Plays the current song again from the newly pinned source. Jumping to the song that's + * playing only seeks within what's already loaded on Android (ExoPlayer), so the queue + * is reloaded at this song instead. + */ + private suspend fun reloadForNewSource(trackKey: String) = syncMutex.withLock { + val queue = audioPlayerQueue.queueFlow.value + val index = queue.indexOfFirst { it.jamKey() == trackKey }.takeIf { it >= 0 } ?: return@withLock + holdSync(TRACK_CHANGE_HOLD_MS) + positionEntryKey = null + audioPlayerQueue.load(queue, autoPlay = anchor?.playing ?: true, startPosition = index) + onEntryChanged(trackKey) + } + + private suspend fun setRate(rate: Float) { + val rounded = (rate * 200).toInt() / 200f + if (appliedRate == rounded) return + // Only while playing: on iOS a non-zero rate also starts playback. + if (audioPlayer.playerStateFlow.value != PlayerState.PLAYING) return + runCatching { audioPlayer.setPlaybackSpeed(rounded) } + .onSuccess { appliedRate = rounded } + } + + private suspend fun restoreSpeed() { + if (appliedRate == null) return + appliedRate = null + val speed = speedBeforeJam ?: 1f + runCatching { audioPlayer.setPlaybackSpeed(speed) } + } + + private fun holdSync(durationMs: Long) { + holdSyncUntilMs = maxOf(holdSyncUntilMs, now() + durationMs) + } + + // ---------- Position tracking ---------- + + /** + * A new song starts from zero. Without this, the old song's last position would carry + * over: a song that ends on its own doesn't make the player report a jump like a skip + * does, and its next report can still be the old song's. + */ + private fun onEntryChanged(key: String?) { + if (key == positionEntryKey) return + positionEntryKey = key + entryChangedAtMs = now() + positionSampleMs = 0 + positionSampledAtMs = entryChangedAtMs + positionAdvancing = audioPlayer.playerStateFlow.value == PlayerState.PLAYING + corrector.reset() + } + + /** The player's position now, extrapolated from its last (coarse) report. */ + private fun localPositionMs(): Long { + if (!positionAdvancing) return positionSampleMs + val elapsed = now() - positionSampledAtMs + return positionSampleMs + (elapsed * audioPlayer.playbackSpeedFlow.value).toLong() + } + + private suspend fun seek(positionMs: Long) { + ownSeekUntilMs = now() + OWN_SEEK_WINDOW_MS + audioPlayer.seekTo(positionMs.coerceAtLeast(0).milliseconds) + positionSampleMs = positionMs + positionSampledAtMs = now() + } + + private fun onPosition(positionMs: Long) { + val now = now() + onEntryChanged(audioPlayerQueue.currentQueueEntryFlow.value?.jamKey()) + // Right after a song change, a report can still be the old song's position. + val sinceChange = now - entryChangedAtMs + if (sinceChange < TRACK_CHANGE_HOLD_MS && + positionMs > sinceChange * audioPlayer.playbackSpeedFlow.value + STALE_REPORT_SLACK_MS + ) return + // A seek moves the position away from both the last report and the extrapolation; + // a stall (or a long pause) only from the extrapolation. + val jumped = abs(positionMs - positionSampleMs) > USER_SEEK_THRESHOLD_MS && + abs(positionMs - localPositionMs()) > USER_SEEK_THRESHOLD_MS && + now > ownSeekUntilMs && + now - entryChangedAtMs > TRACK_CHANGE_HOLD_MS + positionSampleMs = positionMs + positionSampledAtMs = now + if (!jumped || _roomCode.value == null || leaving) return + + // The user seeked: it applies to the whole room. + when (_role.value) { + JamRole.Host -> publishAnchor() + JamRole.Guest -> { + holdSync(SEEK_HOLD_MS * 2) + jamClient.sendCommand(JamCommand.SeekTo(positionMs)) + } + null -> Unit + } + } + + // ---------- Helpers ---------- + + private fun serverUrl(): String? = + settingsRepository.userSettings.value.jamServerUrl.trim().ifBlank { null } + + private suspend fun participantName(deviceKey: String): String = + JamNames.normalize(settingsRepository.userSettings.value.jamParticipantName) + .take(JamNames.MAX_LENGTH) + .ifBlank { JamNames.generate(deviceKey) } + + /** The name this device gets when the user hasn't picked one; the same every time. */ + suspend fun defaultParticipantName(): String = JamNames.generate(deviceKey()) + + /** Saves the user's own name; blank goes back to [defaultParticipantName]. */ + suspend fun setParticipantName(name: String) { + val settings = settingsRepository.userSettings.value + settingsRepository.updateSettings( + settings.copy(jamParticipantName = JamNames.normalize(name).take(JamNames.MAX_LENGTH))) + } + + /** This install's jam secret, created on first use. */ + private suspend fun deviceKey(): String { + val settings = settingsRepository.userSettings.value + if (settings.jamDeviceKey.isNotBlank()) return settings.jamDeviceKey + val generated = JamRoomCode.newClientKey() + runCatching { settingsRepository.updateSettings(settings.copy(jamDeviceKey = generated)) } + .onFailure { log.w(it) { "Failed to save the jam device key" } } + return generated + } + + private fun persistLastCode(code: String) { + scope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + if (settings.lastJamCode != code) { + settingsRepository.updateSettings(settings.copy(lastJamCode = code)) + } + } + } + } + + private fun QueueEntry.jamKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> JamMediaItem.localTrackKey(url) + } + + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { + trackId.isNotBlank() -> QueueEntry.StreamingTrack( + track = MetadataTrack( + id = trackId, + title = title, + durationMs = durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) + ), + album = null, + // Keep the cover art flowing to participants — the wire carries it + // as coverUrl, so mirror it back into the reconstructed thumbnails. + thumbnails = coverUrl.takeIf { it.isNotBlank() } + ?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) }, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = null), + url = "", + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + addedBy = addedBy) + + else -> QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = durationMs, + album = album.ifBlank { null }, + coverBytes = null, + url = url, + addedBy = addedBy) + } + + private fun now(): Long = Clock.System.now().toEpochMilliseconds() + + companion object { + private const val CREATE_ATTEMPTS = 3 + /** Keeps the host under the server's budgets (see `src/protocol.ts` in the jam-server repository). */ + private const val QUEUE_MIN_GAP_MS = 1_000L + private const val ANCHOR_MIN_GAP_MS = 250L + private const val MAX_SUGGESTED_ITEMS = 500 + /** How many next songs the host shares its sources for. */ + private const val UPCOMING_SOURCES = 2 + /** How long a guest that finished a song first waits for the host to finish it too. */ + private const val NEXT_SONG_WAIT_MS = 15_000L + /** "Near the end" of the room's song, allowing for recordings of different lengths. */ + private const val NEAR_SONG_END_MS = 15_000L + private const val STALE_REPORT_SLACK_MS = 1_500L + private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L + private const val HOST_DRIFT_WINDOW = 5 + /** Below the guests' dead band, so the host's own error never adds up to a correction. */ + private const val HOST_REANCHOR_DRIFT_MS = 80L + private const val HOST_REANCHOR_MIN_INTERVAL_MS = 10_000L + /** A safety net only (e.g. a lost frame); costs one message a minute. */ + private const val HOST_ANCHOR_HEARTBEAT_MS = 60_000L + private const val SYNC_INTERVAL_MS = 500L + private const val IDLE_RECHECK_MS = 30_000L + private const val INITIAL_SEEK_LEAD_MS = 300L + private const val MAX_SEEK_LEAD_MS = 3_000L + private const val SEEK_HOLD_MS = 1_500L + private const val TRACK_CHANGE_HOLD_MS = 2_000L + private const val PAUSED_ALIGN_THRESHOLD_MS = 1_500L + private const val USER_SEEK_THRESHOLD_MS = 2_500L + private const val OWN_SEEK_WINDOW_MS = 1_500L + /** Near a track's end the next anchor (new track) is due; don't seek into the outro. */ + private const val END_GUARD_MS = 1_000L + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt new file mode 100644 index 00000000..4a793b51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavTransitions.kt @@ -0,0 +1,101 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.ui.unit.IntOffset +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene + +private const val TRANSITION_DURATION_MILLIS = 300 +private const val FADE_THROUGH_OUT_MILLIS = 90 +private const val FADE_THROUGH_SCALE = 0.94f +private const val SLIDE_FRACTION = 0.25f +private const val BEHIND_SLIDE_FRACTION = 0.1f +private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + +// NavEntry.contentKey defaults to key.toString() +private val topLevelContentKeys = TOP_LEVEL_ROUTES.map { it.toString() }.toSet() + +/** + * The tab a scene belongs to: the last top-level route in its stack. Switching tabs changes + * this, while pushing/popping pages inside a tab doesn't. + */ +private fun Scene.tabRoot(): Any? = + (previousEntries + entries).lastOrNull { it.contentKey in topLevelContentKeys }?.contentKey + +private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean = + initialState.tabRoot() != targetState.tabRoot() + +/** Material fade-through: the old tab fades out, the new one fades in while scaling up slightly. */ +private fun fadeThrough(): ContentTransform { + val enter = fadeIn( + tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate) + ) + scaleIn( + tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate), + initialScale = FADE_THROUGH_SCALE + ) + return enter togetherWith fadeOut(tween(FADE_THROUGH_OUT_MILLIS)) +} + +/** + * Pushed/popped pages inside a tab: the page on top slides + fades while the page behind + * drifts slightly the other way. On pop the leaving page must stay on top (negative target + * z-index), otherwise the page being revealed is drawn over it and its exit is never seen. + */ +private fun AnimatedContentTransitionScope>.stackTransition( + forward: Boolean +): ContentTransform { + if (isTabSwitch()) return fadeThrough() + + val slide = tween(TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate) + val enter: EnterTransition + val exit: ExitTransition + if (forward) { + enter = slideInHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } + + fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + exit = slideOutHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } + + fadeOut(tween(TRANSITION_DURATION_MILLIS / 2)) + } else { + enter = slideInHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } + + fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + exit = slideOutHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } + + fadeOut(tween(TRANSITION_DURATION_MILLIS * 2 / 3)) + } + return ContentTransform(enter, exit, targetContentZIndex = if (forward) 0f else -1f) +} + +val navPushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { stackTransition(forward = true) } + +val navPopTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { stackTransition(forward = false) } + +val navPredictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { stackTransition(forward = false) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationCommands.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationCommands.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt index 7041d557..4cd57fb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationCommands.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationCommands.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.navigation +package org.spotube.spotube.core.navigation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt new file mode 100644 index 00000000..0410a281 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -0,0 +1,211 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.navigation + +import androidx.navigation3.runtime.NavKey +import org.spotube.spotube.modules.album.AlbumScreen +import org.spotube.spotube.modules.artist.ArtistScreen +import org.spotube.spotube.modules.blacklist.BlacklistScreen +import org.spotube.spotube.modules.devices.DevicesScreen +import org.spotube.spotube.modules.devices.RemoteControlScreen +import org.spotube.spotube.modules.jam.JamScreen +import org.spotube.spotube.modules.home.HomeScreen +import org.spotube.spotube.modules.library.LibraryScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel +import org.spotube.spotube.modules.lyrics.LyricsScreen +import org.spotube.spotube.modules.playlist.PlaylistScreen +import org.spotube.spotube.modules.plugin.PluginScreen +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuard +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen +import org.spotube.spotube.modules.search.SearchScreen +import org.spotube.spotube.modules.settings.SettingsScreen +import org.spotube.spotube.modules.settings.JamSettingsScreen +import org.spotube.spotube.modules.welcome.WelcomeScreen +import kotlinx.serialization.Serializable +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.dsl.singleOf +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module +import org.koin.dsl.navigation3.navigation + +@Serializable +sealed interface Routes : NavKey { + @Serializable + data object Home : Routes + + @Serializable + data object Library : Routes + + @Serializable + data object Search : Routes + + @Serializable + data object Settings : Routes + + @Serializable + data object Plugins : Routes + + @Serializable + data object WebView : Routes + + @Serializable + data object Lyrics : Routes + + @Serializable + data class Playlist(val playlistId: String) : Routes + + @Serializable + data class Artist(val artistId: String) : Routes + + @Serializable + data class Album(val albumId: String) : Routes + + @Serializable + data object SavedTracks : Routes + + @Serializable + data object Blacklist : Routes + + @Serializable + data object RemoteControl : Routes + + @Serializable + data object Devices : Routes + + @Serializable + data object Jam : Routes + + @Serializable + data object JamSettings : Routes + + @Serializable + data object Welcome : Routes + + @Serializable + data class LocalFolder(val folderPath: String) : Routes +} + +@OptIn(KoinExperimentalAPI::class) +val navigationModule = module { + singleOf(::NavigationCommands) + navigation { + MetadataPluginGuard { + HomeScreen(viewModel = koinViewModel()) + } + } + navigation { + MetadataPluginGuard { + SearchScreen() + } + } + navigation { + // Not guarded as a whole: Local tracks and Downloads work without a plugin. + LibraryScreen() + } + navigation { + LocalFolderScreen( + viewModel = koinViewModel( + key = it.folderPath, + parameters = { parametersOf(it.folderPath) } + ), + onBack = { get().pop() }) + } + navigation { + SettingsScreen(settingsViewModel = koinViewModel()) + } + navigation { + PluginScreen(viewModel = koinViewModel()) + } + navigation {} + navigation { + MetadataPluginGuard(backButton = true) { + PlaylistScreen( + playlistId = it.playlistId, + viewModel = koinViewModel( + key = it.playlistId, + parameters = { parametersOf(it.playlistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + MetadataPluginGuard(backButton = true) { + ArtistScreen( + viewModel = koinViewModel( + key = it.artistId, + parameters = { parametersOf(it.artistId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + MetadataPluginGuard(backButton = true) { + AlbumScreen( + albumId = it.albumId, + viewModel = koinViewModel( + key = it.albumId, + parameters = { parametersOf(it.albumId) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + LyricsScreen(viewModel = koinViewModel()) + } + navigation { + MetadataPluginGuard(backButton = true) { + SavedTracksScreen( + viewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) } + ), + audioPlayerQueue = get(), + audioPlayer = get(), + navigationCommands = get()) + } + } + navigation { + BlacklistScreen() + } + navigation { + DevicesScreen() + } + navigation { + RemoteControlScreen( + onDisconnect = { get().pop() } + ) + } + navigation { + JamScreen(navigationCommands = get()) + } + navigation { + JamSettingsScreen(viewModel = koinViewModel()) + } + navigation { + WelcomeScreen(viewModel = koinViewModel()) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index 7ef24dec..3dc7461e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.navigation +package org.spotube.spotube.core.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -26,7 +26,6 @@ import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -35,18 +34,19 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer import androidx.savedstate.serialization.SavedStateConfiguration -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import kotlinx.serialization.PolymorphicSerializer import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import org.koin.core.component.KoinComponent -val TOP_LEVEL_ROUTES = setOf( +val TOP_LEVEL_ROUTES: Set = setOf( Routes.Home, Routes.Search, Routes.Library, - Routes.Settings -) + Routes.Settings, + // Bootstrap destination: needs its own back stack when the app starts on it. + Routes.Welcome) val serializersConfig = SavedStateConfiguration { serializersModule = SerializersModule { @@ -61,6 +61,10 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Artist::class, Routes.Artist.serializer()) subclass(Routes.Album::class, Routes.Album.serializer()) subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) + subclass(Routes.Devices::class, Routes.Devices.serializer()) + subclass(Routes.Jam::class, Routes.Jam.serializer()) + subclass(Routes.Welcome::class, Routes.Welcome.serializer()) + subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer()) } } } @@ -119,9 +123,7 @@ fun NavigationState.toEntries( ): SnapshotStateList> { val decoratedEntries = backStacks.mapValues { (_, stack) -> val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() - ) + rememberSaveableStateHolderNavEntryDecorator()) rememberDecoratedNavEntries( backStack = stack, entryDecorators = decorators, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt new file mode 100644 index 00000000..1157bec2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt @@ -0,0 +1,60 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +/** + * The single place the app builds Ktor [HttpClient]s. + * + * It owns one client on the platform's default engine (Darwin on iOS, OkHttp on + * Android/desktop), so every client shares that engine's connection pool and + * no code path can accidentally pick an engine that doesn't work on a platform. + * + * - [default] and [json] are shared: use them directly and never close them. + * - [create] derives a client with extra configuration (timeouts, WebSockets, + * default request, …). It reuses the shared engine; the caller owns the + * returned client and may close it without affecting anyone else. + */ +class HttpClientFactory : AutoCloseable { + private val root = HttpClient() + + /** Plain client with no plugins installed. */ + val default: HttpClient get() = root + + /** Client that (de)serializes JSON bodies via [HttpJson]. */ + val json: HttpClient by lazy { + create { install(ContentNegotiation) { json(HttpJson) } } + } + + fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block) + + override fun close() { + root.close() + } +} + +/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */ +val HttpJson = Json { + ignoreUnknownKeys = true + isLenient = true +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt similarity index 69% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt index 36009bd8..91fe54a2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import kotlinx.serialization.Serializable @Serializable @@ -49,5 +49,14 @@ data class MediaStream( val container: String, val bitrate: Int, val isAudio: Boolean, - val isVideo: Boolean, -) \ No newline at end of file + val isVideo: Boolean) + +/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */ +internal fun normalizeCodec(codec: String?): String { + val value = codec.orEmpty() + return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value +} + +/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */ +internal fun normalizeContainer(suffix: String): String = + if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt new file mode 100644 index 00000000..d624f10b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt @@ -0,0 +1,31 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.newpipe + +enum class SearchKind { + /** YouTube Music "Songs" catalogue. */ + Songs, + + /** Regular YouTube videos; covers tracks that are not in the Music songs catalogue. */ + Videos +} + +expect class NewPipeService() { + suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List + suspend fun getVideoInfo(id: String): VideoInfo +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt index 17ac0708..07f002c0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/paths/Paths.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") expect class Paths() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt similarity index 68% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt index 81594eda..6a425968 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/playback/CollectionPlaybackHelper.kt @@ -15,34 +15,35 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.playback +package org.spotube.spotube.core.playback -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.album.AlbumRepository -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.playlist.PlaylistRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.album.AlbumRepository +import org.spotube.spotube.modules.artist.ArtistRepository +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.playlist.PlaylistRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.core.remote.RemoteCollectionType class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, private val playlistRepository: PlaylistRepository, private val savedTracksRepository: SavedTracksRepository, + private val artistRepository: ArtistRepository, private val audioPlayerQueue: AudioPlayerQueue, - private val blacklistRepository: BlacklistRepository, -) { - suspend fun playAlbum(albumId: String) { - if (audioPlayerQueue.isAlbumPlaying(albumId)) return - val entries = fetchAllAlbumTracks(albumId) + private val blacklistRepository: BlacklistRepository) { + suspend fun playAlbum(albumId: String, shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return + val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it } if (entries.isNotEmpty()) { audioPlayerQueue.load( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } } @@ -51,8 +52,14 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) + } + } + + suspend fun playAlbumNext(albumId: String) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) } } @@ -77,20 +84,18 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } - suspend fun playPlaylist(playlistId: String) { - if (audioPlayerQueue.isPlaylistPlaying(playlistId)) return - val entries = fetchAllPlaylistTracks(playlistId) + suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isPlaylistPlaying(playlistId)) return + val entries = fetchAllPlaylistTracks(playlistId).let { if (shuffle) it.shuffled() else it } if (entries.isNotEmpty()) { audioPlayerQueue.load( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } } @@ -99,8 +104,14 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) + } + } + + suspend fun playPlaylistNext(playlistId: String) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) } } @@ -125,20 +136,43 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } - suspend fun playSavedTracks() { - if (audioPlayerQueue.isSavedTracksPlaying()) return - val entries = fetchAllSavedTracks() + suspend fun playSavedTracks(shuffle: Boolean = false) { + if (!shuffle && audioPlayerQueue.isSavedTracksPlaying()) return + val entries = fetchAllSavedTracks().let { if (shuffle) it.shuffled() else it } if (entries.isNotEmpty()) { audioPlayerQueue.load( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) + } + } + + suspend fun playArtistTopTracks(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + } + + suspend fun addArtistTopTracksToQueue(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue(entries) + } + } + + suspend fun playArtistTopTracksNext(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) } } @@ -147,8 +181,7 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } } @@ -173,8 +206,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } private suspend fun fetchAllAlbumTracks(albumId: String): List { @@ -222,6 +254,21 @@ class CollectionPlaybackHelper( } } + /** + * Resolves the tracks of a collection without loading them into the local + * queue — used to suggest a collection into a jam session from a guest. + */ + suspend fun resolveCollectionTracks(type: RemoteCollectionType, id: String): List = + when (type) { + RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks() + RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks() + RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks() + RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks() + } + + private fun List.asTracks(): List = + mapNotNull { (it as? QueueEntry.StreamingTrack)?.track } + private suspend fun fetchAllSavedTracks(): List { val allTracks = mutableListOf() var pagination = savedTracksRepository.getSavedTracks() @@ -243,11 +290,19 @@ class CollectionPlaybackHelper( } } + private suspend fun fetchArtistTopTracks(artistId: String): List { + val tracks = artistRepository.topTracks(artistId).orEmpty() + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return tracks + .filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + } + private fun isTrackBlacklisted( track: MetadataTrack, blacklistedTrackIds: Set, - blacklistedArtistIds: Set, - ): Boolean { + blacklistedArtistIds: Set): Boolean { return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt new file mode 100644 index 00000000..b5667d1f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/ConnectionRequestDialog.kt @@ -0,0 +1,95 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.millennium.composables.MillenniumAlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.remote_request_allow +import spotube.composeapp.generated.resources.remote_request_allow_always +import spotube.composeapp.generated.resources.remote_request_deny +import spotube.composeapp.generated.resources.remote_request_message +import spotube.composeapp.generated.resources.remote_request_title + +@Composable +fun ConnectionRequestDialogHost() { + val handler: RemoteControlHandler = koinInject() + var pendingRequest by remember { mutableStateOf(null) } + + LaunchedEffect(handler) { + handler.incomingConnectionRequests.collect { request -> + pendingRequest = request + } + } + + pendingRequest?.let { request -> + MillenniumAlertDialog( + visible = true, + onDismissRequest = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + title = { Text(stringResource(Res.string.remote_request_title)) }, + description = { + Text(stringResource(Res.string.remote_request_message, request.deviceName)) + }, + ) { + MillenniumButton( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) + pendingRequest = null + }, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.remote_request_allow_always)) + } + MillenniumButton( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.remote_request_deny)) + } + MillenniumButton( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) + pendingRequest = null + }) { + Text(stringResource(Res.string.remote_request_allow)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt new file mode 100644 index 00000000..f6f474c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt @@ -0,0 +1,245 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.DefaultWebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_error_connection_denied +import spotube.composeapp.generated.resources.devices_error_connection_failed +import spotube.composeapp.generated.resources.devices_error_connection_lost +import spotube.composeapp.generated.resources.devices_error_send_failed + +/** + * WebSocket client for controlling a remote Spotube instance. + * Connects to the remote device's `/control` endpoint and sends commands. + */ +class RemoteControlClient(httpClientFactory: HttpClientFactory) { + private val logger = Logger.withTag("RemoteControlClient") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val httpClient = httpClientFactory.create { + install(WebSockets) + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 30_000 + } + } + + private var session: DefaultWebSocketSession? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _latestPlayerState = MutableStateFlow(null) + val latestPlayerState: StateFlow = _latestPlayerState.asStateFlow() + + private val _latestQueue = MutableStateFlow(null) + val latestQueue: StateFlow = _latestQueue.asStateFlow() + + /** + * [deviceName] identifies this controller to the host; [remoteDeviceName] is the + * host's advertised name, kept only so the UI can show what it is controlling. + */ + suspend fun connect( + host: String, + port: Int, + deviceId: String, + deviceName: String, + remoteDeviceName: String = "") { + if (_connectionState.value is ConnectionState.Connected) { + logger.w { "Already connected" } + return + } + + // Drop a stale attempt (e.g. one still waiting on the host's permission prompt). + closeSession() + _connectionState.value = ConnectionState.Connecting + try { + session = httpClient.webSocketSession { + url("ws://$host:$port/control") + header("X-Device-Id", deviceId) + header("X-Device-Name", deviceName) + } + + logger.i { "WebSocket connected to $host:$port, waiting for authorization..." } + + // Start receiving messages in a separate coroutine + scope.launch { + receiveLoop(host, port, remoteDeviceName) + } + } catch (e: Exception) { + logger.e(e) { "Failed to connect to $host:$port" } + closeSession() + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed) + } + } + + private suspend fun receiveLoop(host: String, port: Int, remoteDeviceName: String) { + val currentSession = session ?: return + logger.d { "Starting receive loop for $host:$port" } + try { + for (frame in currentSession.incoming) { + when (frame) { + is Frame.Text -> { + val text = frame.readText() + logger.d { "Received frame: $text" } + try { + val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + logger.d { "Parsed event: $event" } + when (event) { + is RemoteControlEvent.Connected -> { + logger.i { "Connection authorized by server" } + _connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName) + } + is RemoteControlEvent.WaitingForPermission -> { + logger.i { "Waiting for permission: ${event.message}" } + // Keep showing connecting state + } + else -> { + // Only store state updates after connection is established + if (_connectionState.value is ConnectionState.Connected) { + when (event) { + is RemoteControlEvent.PlayerState -> { + _latestPlayerState.value = event + } + is RemoteControlEvent.QueueUpdated -> { + _latestQueue.value = event + } + else -> {} + } + } + } + } + } catch (e: Exception) { + logger.w(e) { "Failed to parse message: $text" } + } + } + else -> {} + } + } + // Ktor consumes Close frames itself and just ends `incoming`, so the server + // closing the socket (denied request, remote control turned off, host quit) + // only shows up here. Without this the state stayed Connecting/Connected forever + // and every later connect attempt was swallowed. + val reason = currentSession.closeReason.await() + logger.i { "WebSocket closed by server: $reason" } + onServerClosed(currentSession, reason) + } catch (e: Exception) { + logger.e(e) { "Error in receive loop" } + if (session === currentSession) { + session = null + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost) + } + } + } + + private fun onServerClosed(closedSession: DefaultWebSocketSession, reason: CloseReason?) { + // disconnect() (or a newer connect()) already replaced this session; nothing to report. + if (session !== closedSession) return + session = null + val wasAuthorized = _connectionState.value is ConnectionState.Connected + _connectionState.value = when { + // The host refuses with VIOLATED_POLICY before authorizing: the user hit Deny + // (or dismissed the prompt), or remote control is turned off over there. + !wasAuthorized && reason?.knownReason == CloseReason.Codes.VIOLATED_POLICY -> + ConnectionState.Error(null, Res.string.devices_error_connection_denied) + wasAuthorized -> ConnectionState.Disconnected + else -> ConnectionState.Error(reason?.message?.ifBlank { null }, Res.string.devices_error_connection_failed) + } + } + + suspend fun sendCommand(command: RemoteControlCommand) { + val currentSession = session ?: run { + logger.w { "Not connected" } + return + } + + val envelope = CommandEnvelope( + commandId = randomShortId(), + command = command) + + try { + val text = json.encodeToString(CommandEnvelope.serializer(), envelope) + currentSession.send(Frame.Text(text)) + logger.d { "Sent command: $command" } + } catch (e: Exception) { + logger.e(e) { "Failed to send command" } + _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_send_failed) + } + } + + suspend fun disconnect() { + closeSession() + _connectionState.value = ConnectionState.Disconnected + logger.i { "Disconnected" } + } + + /** Detaches before closing so the receive loop doesn't report this as a server close. */ + private suspend fun closeSession() { + val current = session ?: return + session = null + runCatching { current.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) } + } + + private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } + } +} + +sealed interface ConnectionState { + data object Disconnected : ConnectionState + data object Connecting : ConnectionState + data class Connected(val host: String, val port: Int, val deviceName: String = "") : ConnectionState + /** + * [message] is the raw (untranslated) cause when available; [fallback] is the + * localized description to show when it isn't. + */ + data class Error(val message: String?, val fallback: StringResource) : ConnectionState +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt new file mode 100644 index 00000000..fc5a672d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt @@ -0,0 +1,457 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.PlayerState as AudioPlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.settings.SettingsRepository +import io.ktor.server.websocket.WebSocketServerSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlin.coroutines.resume +import kotlin.time.TimeSource +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent + +class RemoteControlHandler( + private val settingsRepository: SettingsRepository, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent { + val logger by injectLogger() + + val incomingConnectionRequests = MutableSharedFlow( + extraBufferCapacity = 16) + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val pendingRequestResolutions = mutableMapOf Unit>() + + suspend fun handleConnection(session: WebSocketServerSession) { + val settings = settingsRepository.userSettings.first() + if (!settings.allowRemoteControl) { + logger.w { "Rejecting remote control connection: remote control is disabled" } + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Remote control is disabled")) + return + } + + val deviceId = session.call.request.headers["X-Device-Id"] + val deviceName = session.call.request.headers["X-Device-Name"] ?: "Unknown" + + val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices + + if (!isAllowed) { + // Send waiting for permission message + val waitingMessage = RemoteControlEvent.WaitingForPermission( + "Waiting for permission from $deviceName..." + ) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage))) + + val request = ConnectionRequest( + deviceId = deviceId ?: "unknown", + deviceName = deviceName, + sessionId = session.call.request.headers["X-Request-Id"] + ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}") + incomingConnectionRequests.emit(request) + val response = waitForRequestResolution(request.sessionId) + if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Connection denied")) + return + } + if (response == ConnectionRequestResponse.AllowAlways && deviceId != null) { + settingsRepository.updateSettings( + settings.copy( + allowedRemoteDevices = (settings.allowedRemoteDevices + deviceId).distinct() + ) + ) + logger.i { "Device $deviceId added to always-allowed devices" } + } + } + + // Send connected message + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected))) + logger.i { "Remote control connection established from $deviceName ($deviceId)" } + + // Broadcast initial player state so the controller shows current track info + broadcastState(session) + broadcastQueue(session) + + try { + // Periodically push player state so the controller's progress bar + // stays in sync even when no commands are being sent. + coroutineScope { + launch { + while (isActive) { + delay(1_000) + broadcastState(session) + } + } + handleControlLoop(session) + } + } catch (e: Exception) { + logger.w(e) { "Error in remote control session" } + } finally { + logger.d { "Closing session in finally block" } + session.close() + } + } + + fun resolveConnectionRequest(sessionId: String, response: ConnectionRequestResponse) { + pendingRequestResolutions.remove(sessionId)?.invoke(response) + } + + private suspend fun waitForRequestResolution(sessionId: String): ConnectionRequestResponse { + return suspendCancellableCoroutine { continuation -> + pendingRequestResolutions[sessionId] = { response -> + if (continuation.isActive) { + continuation.resume(response) + } + } + continuation.invokeOnCancellation { + pendingRequestResolutions.remove(sessionId) + } + } + } + + private suspend fun handleControlLoop(session: WebSocketServerSession) { + logger.d { "Starting control loop for session" } + for (frame in session.incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + logger.d { "Received command: $text" } + try { + val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) + handleCommand(session, envelope) + } catch (e: Exception) { + logger.w(e) { "Failed to parse remote control command" } + sendError(session, "Invalid command: ${e.message}") + } + } + } + logger.d { "Control loop exited normally" } + } + + private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { + when (val command = envelope.command) { + is RemoteControlCommand.Play -> { + handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle) + } + is RemoteControlCommand.Pause -> { + audioPlayer.pause() + } + is RemoteControlCommand.TogglePlayPause -> { + val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING + if (isPlaying) { + audioPlayer.pause() + waitForPlaybackState(expectPlaying = false) + } else { + audioPlayer.play() + waitForPlaybackState(expectPlaying = true) + } + } + is RemoteControlCommand.Seek -> { + audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + } + is RemoteControlCommand.SetVolume -> { + audioPlayer.setVolume(command.volume) + } + is RemoteControlCommand.SkipNext -> { + audioPlayer.skipToNext() + } + is RemoteControlCommand.SkipPrevious -> { + audioPlayer.skipToPrevious() + } + is RemoteControlCommand.SetShuffle -> { + audioPlayer.shuffle(command.enabled) + } + is RemoteControlCommand.SetLoopMode -> { + val loopState = when (command.mode) { + "none" -> LoopState.NONE + "one" -> LoopState.ONE + "all" -> LoopState.ALL + else -> { + sendError(session, "Invalid loop mode: ${command.mode}") + return + } + } + audioPlayer.loop(loopState) + } + is RemoteControlCommand.AddToQueue -> { + handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue) + } + is RemoteControlCommand.PlayNext -> { + handleCollectionSource(command.source, RemoteCollectionAction.PlayNext) + } + is RemoteControlCommand.PlayTrack -> { + audioPlayerQueue.load( + entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + is RemoteControlCommand.AddTrackToQueue -> { + audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) + } + is RemoteControlCommand.PlayTrackNext -> { + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = ""))) + } + is RemoteControlCommand.AddTracksToQueue -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllToQueue(entries) + } + is RemoteControlCommand.PlayTracksNext -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllAfterCurrent(entries) + } + is RemoteControlCommand.PlayIndex -> { + audioPlayerQueue.jumpTo(command.index) + } + is RemoteControlCommand.RemoveFromQueue -> { + val queue = audioPlayerQueue.queueFlow.value + val entry = queue.firstOrNull { candidate -> + when (candidate) { + is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl + is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl + } + } + if (entry != null) { + audioPlayerQueue.removeFromQueue(entry) + } else { + logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" } + } + } + } + sendAck(session, envelope.commandId) + broadcastState(session) + broadcastQueue(session) + } + + private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId)) + session.send(Frame.Text(text)) + } + + private suspend fun sendError(session: WebSocketServerSession, message: String) { + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message)) + session.send(Frame.Text(text)) + } + + /** + * Player state changes are applied asynchronously (e.g. ExoPlayer listener + * callbacks posted to the main looper), so after play/pause we poll until + * [playerStateFlow] reflects the expected state before broadcasting it back + * to the controller. Otherwise the client would see a stale (inverted) icon. + */ + private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) { + val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds + while (timeoutAt.hasNotPassedNow()) { + if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return + delay(25) + } + } + + /** + * Resolves a `spotube://` collection source URI (playlist/album/artist top + * tracks/saved tracks) and applies the requested action on the remote queue. + */ + private suspend fun handleCollectionSource( + source: String, + action: RemoteCollectionAction, + shuffle: Boolean = false) { + logger.d { "Remote collection $action for source: $source" } + when { + source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { + val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) + } + } + + source.startsWith(COLLECTION_ALBUM_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) + } + } + + source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id) + } + } + + source == COLLECTION_SAVED_TRACKS -> { + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + RemoteCollectionAction.PlayNext -> { + // Saved tracks "play next" is not supported; add to queue instead + collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + else -> logger.w { "Unknown remote collection source: $source" } + } + } + + private suspend fun broadcastState(session: WebSocketServerSession) { + val current = audioPlayerQueue.currentQueueEntryFlow.value + val state = RemoteControlEvent.PlayerState( + isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds, + volume = audioPlayer.volumeFlow.value, + shuffleEnabled = audioPlayer.shuffleModeFlow.value, + loopMode = audioPlayer.loopStateFlow.value.name.lowercase(), + currentTrackId = current?.mediaKey(), + currentTrackTitle = current?.titleOrNull(), + currentTrackArtists = current?.artistsOrNull(), + currentTrackAlbum = current?.albumOrNull(), + currentTrackCoverUrl = current?.artworkUrl) + val text = json.encodeToString(RemoteControlEvent.serializer(), state) + session.send(Frame.Text(text)) + } + + private suspend fun broadcastQueue(session: WebSocketServerSession) { + val queue = audioPlayerQueue.queueFlow.value + val current = audioPlayerQueue.currentQueueEntryFlow.value + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesCurrent(current) } + } else { + -1 + } + val event = RemoteControlEvent.QueueUpdated( + entries = queue.map { it.toRemoteQueueEntry() }, + currentIndex = currentIndex) + val text = json.encodeToString(RemoteControlEvent.serializer(), event) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> { + this.track.id == current.track.id + } + + this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> { + this.url == current.url && this.name == current.name + } + + else -> false + } + } + + private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) { + is QueueEntry.StreamingTrack -> RemoteQueueEntry( + mediaUrl = track.id, + trackId = track.id, + title = track.title, + artists = track.artists.joinToString(", ") { artist -> artist.name }, + album = track.album?.title, + coverUrl = artworkUrl, + durationMs = track.durationMs) + + is QueueEntry.LocalTrack -> RemoteQueueEntry( + mediaUrl = url, + trackId = url, + title = name, + artists = artists.joinToString(", "), + album = album, + coverUrl = null, + durationMs = duration) + } + + private fun QueueEntry.mediaKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.titleOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.title + is QueueEntry.LocalTrack -> name + } + + private fun QueueEntry.artistsOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.artists.joinToString(", ") { artist -> artist.name } + is QueueEntry.LocalTrack -> artists.joinToString(", ") + } + + private fun QueueEntry.albumOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.album?.title + is QueueEntry.LocalTrack -> album + } +} + +@Serializable +data class CommandEnvelope( + val commandId: String, + val command: RemoteControlCommand) + +private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" +private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" +private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/" +private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks" + +enum class RemoteCollectionAction { + Play, + AddToQueue, + PlayNext, +} + +data class ConnectionRequest( + val deviceId: String, + val deviceName: String, + val sessionId: String) + +enum class ConnectionRequestResponse { + Allow, + AllowAlways, + Deny, +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt new file mode 100644 index 00000000..f3d95cde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlProtocol.kt @@ -0,0 +1,147 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.remote + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class RemoteControlCommand { + @Serializable + @SerialName("play") + data class Play(val source: String, val shuffle: Boolean = false) : RemoteControlCommand() + + @Serializable + @SerialName("pause") + data object Pause : RemoteControlCommand() + + @Serializable + @SerialName("togglePlayPause") + data object TogglePlayPause : RemoteControlCommand() + + @Serializable + @SerialName("seek") + data class Seek(val positionMs: Long) : RemoteControlCommand() + + @Serializable + @SerialName("setVolume") + data class SetVolume(val volume: Float) : RemoteControlCommand() + + @Serializable + @SerialName("skipNext") + data object SkipNext : RemoteControlCommand() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : RemoteControlCommand() + + @Serializable + @SerialName("setShuffle") + data class SetShuffle(val enabled: Boolean) : RemoteControlCommand() + + @Serializable + @SerialName("setLoopMode") + data class SetLoopMode(val mode: String) : RemoteControlCommand() + + @Serializable + @SerialName("addToQueue") + data class AddToQueue(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playNext") + data class PlayNext(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playTrack") + data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTrackToQueue") + data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("playTrackNext") + data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTracksToQueue") + data class AddTracksToQueue(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playTracksNext") + data class PlayTracksNext(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playIndex") + data class PlayIndex(val index: Int) : RemoteControlCommand() + + @Serializable + @SerialName("removeFromQueue") + data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() +} + +@Serializable +sealed class RemoteControlEvent { + @Serializable + @SerialName("connected") + data object Connected : RemoteControlEvent() + + @Serializable + @SerialName("waitingForPermission") + data class WaitingForPermission(val message: String) : RemoteControlEvent() + + @Serializable + @SerialName("playerState") + data class PlayerState( + val isPlaying: Boolean, + val positionMs: Long, + val durationMs: Long, + val volume: Float, + val shuffleEnabled: Boolean, + val loopMode: String, + val currentTrackId: String?, + val currentTrackTitle: String?, + val currentTrackArtists: String?, + val currentTrackAlbum: String?, + val currentTrackCoverUrl: String?) : RemoteControlEvent() + + @Serializable + @SerialName("queueUpdated") + data class QueueUpdated( + val entries: List, + val currentIndex: Int) : RemoteControlEvent() + + @Serializable + @SerialName("ack") + data class Ack(val commandId: String) : RemoteControlEvent() + + @Serializable + @SerialName("error") + data class Error(val message: String) : RemoteControlEvent() +} + +@Serializable +data class RemoteQueueEntry( + val mediaUrl: String, + val trackId: String, + val title: String, + val artists: String, + val album: String?, + val coverUrl: String?, + val durationMs: Long) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt new file mode 100644 index 00000000..c0c19ae6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt @@ -0,0 +1,218 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.remote + +import co.touchlab.kermit.Logger +import com.appstractive.dnssd.NetService +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * Advertises this device on the local network via DNS-SD so that other Spotube + * instances can discover and control it. Advertises only while the + * "Allow remote control" setting is enabled and the local playback server is + * listening on the LAN (0.0.0.0). + * + * Registration is retried with backoff: NsdManager is flaky right after a cold + * start, and a single registration attempt is bounded by a short timeout so a + * stalled platform callback can't wedge a dispatcher thread for long. + */ +class RemoteControlService( + private val settingsRepository: SettingsRepository, + private val discoveryService: DeviceDiscoveryService, + private val localServer: LocalServer) { + private val log = Logger.withTag("RemoteControlService") + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _localDeviceId = MutableStateFlow("") + val localDeviceId: StateFlow = _localDeviceId.asStateFlow() + + private var advertisedService: NetService? = null + + /** A registration attempt that may have been left pending by the platform. */ + private var pendingService: NetService? = null + + private var registerJob: Job? = null + private var cleanupJob: Job? = null + + init { + // Ensure a stable device id exists and is persisted up front, so discovery + // can reliably filter out this device's own advertisement. + scope.launch { + _localDeviceId.value = resolveDeviceId() + } + scope.launch { + combine( + settingsRepository.userSettings, + localServer.port) { settings, port -> settings to port } + .distinctUntilChanged() + .collect { (settings, port) -> + if (settings.allowRemoteControl && port != null) { + if (registerJob?.isActive != true) { + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + } else { + registerJob?.cancel() + registerJob = null + stopAdvertising() + } + } + } + } + + /** + * The service name this device advertises under, derived deterministically + * from settings so discovery can match it against the local advertisement. + */ + fun advertisedName(): String { + val deviceId = _localDeviceId.value.ifBlank { + settingsRepository.userSettings.value.remoteControlDeviceId + } + val configured = settingsRepository.userSettings.value.remoteControlDeviceName + return configured.ifBlank { "Spotube-${deviceId.take(6)}" } + } + + /** + * Kicks off (or restarts) the advertising loop. Used when the local-network + * permission is granted at runtime after earlier attempts failed. + */ + fun retryAdvertising() { + val settings = settingsRepository.userSettings.value + val port = localServer.port.value + if (!settings.allowRemoteControl || port == null) return + registerJob?.cancel() + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + + private suspend fun registerLoop(name: String, port: Int) { + val deviceId = resolveDeviceId() + _localDeviceId.value = deviceId + val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } + + var attempt = 0 + while (advertisedService == null && coroutineContext.isActive) { + attempt++ + // The user may have toggled the setting off during backoff. + if (!settingsRepository.userSettings.value.allowRemoteControl) return + val service = discoveryService.createService( + name = serviceName, + port = port, + deviceId = deviceId) + pendingService = service + try { + service.register(timeoutInMs = REGISTER_TIMEOUT_MS) + pendingService = null + advertisedService = service + log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + pendingService = null + log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + // The library leaks the platform registration on timeout. Once the + // platform eventually completes it (success), isRegistered flips + // and unregister() will actually remove it — keep trying until then. + scheduleCleanup(service) + delay(retryDelayMs(attempt)) + } + } + } + + /** + * Repeatedly tries to unregister a service whose registration attempt failed. + * The library's `unregister()` is a no-op while the platform hasn't completed + * the registration, so poll until it has (or give up after a while). + */ + private fun scheduleCleanup(service: NetService?) { + if (service == null) return + cleanupJob?.cancel() + cleanupJob = scope.launch { + repeat(REGISTER_CLEANUP_TRIES) { + delay(1_000) + runCatching { service.unregister() } + } + } + } + + private suspend fun stopAdvertising() { + registerJob?.cancel() + registerJob = null + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null + if (advertisedService != null) { + runCatching { advertisedService?.unregister() } + advertisedService = null + log.i { "Stopped advertising remote control service" } + } + } + + private suspend fun resolveDeviceId(): String { + val settings = settingsRepository.userSettings.first() + if (settings.remoteControlDeviceId.isNotBlank()) { + return settings.remoteControlDeviceId + } + val generated = buildString(16) { + val chars = "0123456789abcdef" + repeat(16) { append(chars[Random.nextInt(chars.length)]) } + } + settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) + return generated + } + + private fun retryDelayMs(attempt: Int): Long = when { + attempt >= 6 -> 5 * 60_000L + attempt >= 3 -> 30_000L + else -> 5_000L + } + + companion object { + // Generous enough that the library's timeout (which leaks the platform + // registration) rarely fires on a working system — registration callbacks + // normally arrive within a second. + private const val REGISTER_TIMEOUT_MS = 10_000L + + // How long to keep polling unregister() on a failed service, in seconds. + private const val REGISTER_CLEANUP_TRIES = 15 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt new file mode 100644 index 00000000..e7965797 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt @@ -0,0 +1,435 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.remote + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getPluralString +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_queue_added_track +import spotube.composeapp.generated.resources.jam_queue_added_tracks + +enum class PlaybackDestinationAction { + Play, + AddToQueue, + PlayNext, +} + +enum class RemoteCollectionType { + Playlist, + Album, + ArtistTopTracks, + SavedTracks, +} + +/** + * A playback request awaiting a destination choice (local device vs a connected + * remote device). [title] is the content label shown in the picker dialog. + */ +sealed interface PlaybackDestinationRequest { + val title: String + val action: PlaybackDestinationAction + + data class Collection( + override val title: String, + override val action: PlaybackDestinationAction, + val type: RemoteCollectionType, + val id: String, + val startTrack: MetadataTrack? = null, + val shuffle: Boolean = false) : PlaybackDestinationRequest + + data class Track( + override val title: String, + override val action: PlaybackDestinationAction, + val track: MetadataTrack) : PlaybackDestinationRequest + + data class Tracks( + override val title: String, + override val action: PlaybackDestinationAction, + val tracks: List) : PlaybackDestinationRequest +} + +/** + * Identifies a whole-collection action (e.g. "shuffle album X") while its tracks are being + * fetched and queued locally, so the button that started it can show a loading state. + */ +data class CollectionPlaybackKey( + val type: RemoteCollectionType, + val id: String, + val action: PlaybackDestinationAction, + val shuffle: Boolean = false) + +/** Loading flags for a collection screen's play / shuffle / add-to-queue buttons. */ +data class CollectionPlaybackUiState( + val isPlayLoading: Boolean = false, + val isShuffleLoading: Boolean = false, + val isAddToQueueLoading: Boolean = false) + +/** + * Routes playback actions (play / add to queue / play next) to either the local + * device or a connected remote device. When a remote device is connected the + * user is shown a destination picker; otherwise the action runs locally. + */ +class RemotePlaybackController( + private val remoteControlClient: RemoteControlClient, + private val collectionPlaybackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, + private val jamRoomService: JamRoomService) : KoinComponent { + private val logger = Logger.withTag("RemotePlaybackController") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _pendingRequest = MutableStateFlow(null) + val pendingRequest: StateFlow = _pendingRequest.asStateFlow() + + /** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */ + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + + /** Collection actions currently running locally; see [CollectionPlaybackKey]. */ + private val _busyCollections = MutableStateFlow>(emptySet()) + val busyCollections: StateFlow> = _busyCollections.asStateFlow() + + fun collectionPlaybackUiState(type: RemoteCollectionType, id: String): Flow = + busyCollections.map { busy -> + CollectionPlaybackUiState( + isPlayLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play) in busy, + isShuffleLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play, shuffle = true) in busy, + isAddToQueueLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.AddToQueue) in busy) + }.distinctUntilChanged() + + fun isRemoteConnected(): Boolean { + return remoteControlClient.connectionState.value is ConnectionState.Connected + } + + // ---------- Collection actions ---------- + + fun requestCollectionPlay( + type: RemoteCollectionType, + id: String, + title: String, + startTrack: MetadataTrack? = null, + shuffle: Boolean = false) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle)) + } + + fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id)) + } + + fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id)) + } + + // ---------- Single track actions ---------- + + fun requestTrackAddToQueue(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track)) + } + + fun requestTrackPlayNext(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track)) + } + + // ---------- Bulk track actions ---------- + + fun requestTracksAddToQueue(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks)) + } + + fun requestTracksPlayNext(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks)) + } + + // ---------- Picker resolution ---------- + + fun playLocally() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeLocally(request) + } + + fun playOnRemote() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeOnRemote(request) + } + + fun dismissPicker() { + _pendingRequest.value = null + } + + // ---------- Jam actions ---------- + + /** + * Adds a single track to the active jam queue. The host applies it to the + * local (shared) queue directly; a guest suggests it to the host through the room server. + */ + fun addTrackToJam(track: MetadataTrack) { + if (jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addToQueue( + QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId) + ) + + JamRole.Guest -> jamRoomService.suggestTrack(track) + null -> return@launch + } + _events.emit(getString(Res.string.jam_queue_added_track)) + } catch (e: Exception) { + logger.e(e) { "Failed to add track to jam session" } + } + } + } + + /** + * Adds multiple tracks to the active jam queue (host applies locally, + * guest suggests to the host). + */ + fun addTracksToJam(tracks: List) { + if (tracks.isEmpty() || jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addAllToQueue( + tracks.map { track -> + QueueEntry.StreamingTrack( + track = track, + url = "", + addedBy = jamRoomService.participantClientId) + } + ) + + JamRole.Guest -> jamRoomService.suggestPlaylist(tracks) + null -> return@launch + } + _events.emit(getPluralString(Res.plurals.jam_queue_added_tracks, tracks.size, tracks.size)) + } catch (e: Exception) { + logger.e(e) { "Failed to add tracks to jam session" } + } + } + } + + // ---------- Internals ---------- + + private fun request(request: PlaybackDestinationRequest) { + if (isRemoteConnected()) { + _pendingRequest.value = request + } else { + executeLocally(request) + } + } + + private fun executeLocally(request: PlaybackDestinationRequest) { + // Starting from a specific track (a tap in a track list) isn't tied to a collection button. + val busyKey = (request as? PlaybackDestinationRequest.Collection) + ?.takeIf { it.startTrack == null } + ?.let { CollectionPlaybackKey(it.type, it.id, it.action, it.shuffle) } + if (busyKey != null && busyKey in _busyCollections.value) return + busyKey?.let { key -> _busyCollections.update { it + key } } + scope.launch { + try { + executeLocallyNow(request) + } catch (e: Exception) { + logger.e(e) { "Failed to play ${request.title} locally" } + } finally { + busyKey?.let { key -> _busyCollections.update { it - key } } + } + } + } + + private suspend fun executeLocallyNow(request: PlaybackDestinationRequest) { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id, request.shuffle) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id, request.shuffle) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } + + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks(request.shuffle) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } + + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) + } + } + } + + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } + + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + } + } + } + + private fun executeOnRemote(request: PlaybackDestinationRequest) { + scope.launch { + try { + val command = when (request) { + is PlaybackDestinationRequest.Collection -> { + val source = when (request.type) { + RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}" + RemoteCollectionType.Album -> "spotube://album/${request.id}" + RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}" + RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" + } + when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) + } + } + + is PlaybackDestinationRequest.Track -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track) + } + + is PlaybackDestinationRequest.Tracks -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks) + } + } + remoteControlClient.sendCommand(command) + logger.i { "Sent remote ${request.action} for ${request.title}" } + } catch (e: Exception) { + logger.e(e) { "Failed to send remote playback command" } + } + } + } + + private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt new file mode 100644 index 00000000..6d58667d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManager.kt @@ -0,0 +1,265 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.scrobble + +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.time.Clock + +/** + * Watches local playback and submits scrobbles through the currently selected scrobble plugin. + * + * Scrobbling follows the standard Last.fm rules, which plugins are expected to honour as well: + * - Tracks shorter than [MIN_TRACK_DURATION_MS] are never scrobbled. + * - The track must be listened to for at least half of its duration, or [MAX_SCROBBLE_THRESHOLD_MS], + * whichever comes first. + * + * Only time during which the player is actively [PlayerState.PLAYING] is counted, so pauses are + * ignored, and forward seeks don't credit unheard audio. The timestamp sent with the scrobble is + * the moment playback of the track started. Each play is scrobbled at most once; replaying or + * repeating the track starts a new scrobble session. + */ +class ScrobbleManager( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val scrobblePluginSource: ScrobblePluginSource, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent { + + private val logger by injectLogger() + + private var activeTrackKey: String? = null + private var playbackStartedAtEpochSeconds: Long? = null + private var listenedMs: Long = 0L + private var lastPositionMs: Long? = null + private var scrobbled = false + + init { + scope.launch { + combine( + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.playerStateFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow) { entry, playerState, position, duration -> + PlaybackSnapshot( + entry = entry, + playerState = playerState, + positionMs = position.inWholeMilliseconds, + playerDurationMs = duration.inWholeMilliseconds) + }.collect(::onPlaybackSnapshot) + } + } + + private fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) { + val entry = snapshot.entry + if (entry == null) { + clearSession() + return + } + + val trackKey = entry.trackKey() + if (trackKey != activeTrackKey) { + beginSession(trackKey, snapshot.positionMs) + return + } + + val previousPositionMs = lastPositionMs + lastPositionMs = snapshot.positionMs + + if (snapshot.playerState != PlayerState.PLAYING) return + if (previousPositionMs == null) { + playbackStartedAtEpochSeconds = playbackStartedAtEpochSeconds ?: nowEpochSeconds() + return + } + + if (playbackStartedAtEpochSeconds == null) { + playbackStartedAtEpochSeconds = nowEpochSeconds() + } + + val deltaMs = snapshot.positionMs - previousPositionMs + if (deltaMs < 0) { + // A backward seek to the very beginning means the play started over + // (e.g. repeat one), so allow it to be scrobbled again. + if (snapshot.positionMs <= RESTART_POSITION_THRESHOLD_MS) { + restartSession(snapshot.positionMs) + } + return + } + + // A jump larger than a couple of position ticks is a forward seek or a + // buffering/app-suspension stall. Never credit unplayed audio for it. + if (deltaMs > MAX_COUNTED_POSITION_DELTA_MS) return + + listenedMs += deltaMs + if (scrobbled) return + + val durationMs = entry.durationMs().takeIf { it > 0 } ?: snapshot.playerDurationMs + val thresholdMs = scrobbleThresholdMs(durationMs) ?: return + if (listenedMs < thresholdMs) return + + if (scrobblePluginSource.selectedScrobblePlugin.value == null) { + // No scrobble plugin selected yet: keep the session hot so it can be + // submitted if the user selects one while the track is still playing. + return + } + + scrobbled = true + val track = entry.toScrobbleTrack( + timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), + durationMs = durationMs) + scope.launch { submitScrobble(track) } + } + + private suspend fun submitScrobble(track: ScrobbleTrack) { + val plugin = scrobblePluginSource.selectedScrobblePlugin.value ?: return + try { + plugin.use { scrobbleAPI.scrobble(track) } + logger.d { "Scrobbled \"${track.trackName}\" by ${track.artistName} via ${plugin.pluginId}" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Failed to scrobble \"${track.trackName}\" by ${track.artistName}" } + } + } + + private fun beginSession(trackKey: String, positionMs: Long) { + activeTrackKey = trackKey + restartSession(positionMs) + } + + private fun restartSession(positionMs: Long) { + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = positionMs + scrobbled = false + } + + private fun clearSession() { + activeTrackKey = null + listenedMs = 0L + playbackStartedAtEpochSeconds = null + lastPositionMs = null + scrobbled = false + } + + private fun nowEpochSeconds(): Long = Clock.System.now().epochSeconds + + private fun QueueEntry.trackKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.durationMs(): Long = when (this) { + is QueueEntry.StreamingTrack -> track.durationMs + is QueueEntry.LocalTrack -> duration + } + + private fun QueueEntry.toScrobbleTrack(timestamp: Long, durationMs: Long): ScrobbleTrack { + return when (this) { + is QueueEntry.StreamingTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = track.id, + artistId = track.artists.firstOrNull()?.id.orEmpty(), + albumId = track.album?.id, + trackName = track.title, + artistName = track.artists.joinToString(", ") { it.name }, + albumName = track.album?.title, + streamingProvider = resolveStreamingProvider(track), + durationMs = durationMs) + + is QueueEntry.LocalTrack -> ScrobbleTrack( + timestamp = timestamp, + trackId = url, + artistId = "", + albumId = null, + trackName = name, + artistName = artists.joinToString(", "), + albumName = album, + streamingProvider = LOCAL_PROVIDER, + durationMs = durationMs) + } + } + + /** Maps a track's external URI to the streaming provider key scrobble plugins expect. */ + private fun resolveStreamingProvider(track: MetadataTrack): String { + val host = track.externalUri + ?.substringAfter("://", missingDelimiterValue = "") + ?.substringBefore('/') + ?.substringAfter('@') + ?.substringBefore(':') + ?.lowercase() + ?.removePrefix("www.") + .orEmpty() + + if (host.isBlank()) return UNKNOWN_PROVIDER + + return when { + "spotify" in host -> "spotify" + "apple" in host -> "apple_music" + "music.youtube" in host -> "youtube_music" + "youtube" in host || "youtu.be" in host -> "youtube" + "deezer" in host -> "deezer" + "tidal" in host -> "tidal" + "soundcloud" in host -> "soundcloud" + "amazon" in host -> "amazon_music" + "pandora" in host -> "pandora" + else -> host.substringBefore('.') + } + } + + private data class PlaybackSnapshot( + val entry: QueueEntry?, + val playerState: PlayerState, + val positionMs: Long, + val playerDurationMs: Long) + + companion object { + /** Tracks shorter than this are never scrobbled (Last.fm rule). */ + private const val MIN_TRACK_DURATION_MS = 30_000L + + /** Listening threshold is capped at four minutes (Last.fm rule). */ + private const val MAX_SCROBBLE_THRESHOLD_MS = 4 * 60 * 1000L + + /** Largest position delta credited as listening, covers position polling jitter. */ + private const val MAX_COUNTED_POSITION_DELTA_MS = 2_000L + + /** Positions at or below this are treated as the start of a fresh play. */ + private const val RESTART_POSITION_THRESHOLD_MS = 5_000L + + private const val UNKNOWN_PROVIDER = "unknown" + private const val LOCAL_PROVIDER = "local" + + private fun scrobbleThresholdMs(durationMs: Long): Long? { + if (durationMs < MIN_TRACK_DURATION_MS) return null + return minOf(durationMs / 2, MAX_SCROBBLE_THRESHOLD_MS) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt index 42e431af..9a4775a1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/AlternativeTracksRepository.kt @@ -15,31 +15,26 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.AudioPluginSource import org.koin.core.component.KoinComponent class AlternativeTracksRepository( - private val pluginManager: PluginManager, - private val matchedTracksRepository: MatchedTracksRepository, + private val pluginManager: AudioPluginSource, + private val matchedTracksRepository: TrackSourceRepository, private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, - private val cacheManager: CacheManager, -) : KoinComponent { + private val cacheManager: CacheManager) : KoinComponent { private val logger by injectLogger() suspend fun resolveAlternatives(track: MetadataTrack): List { val trackId = track.id - streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached -> - logger.v { "Using cached alternatives for track $trackId" } - return cached - } val audioPlugin = pluginManager.selectedAudioPlugin.value if (audioPlugin == null) { @@ -47,6 +42,11 @@ class AlternativeTracksRepository( return emptyList() } + streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached -> + logger.v { "Using cached alternatives for track $trackId" } + return cached + } + val sources = runCatching { audioPlugin.use { audioAPI.getStreamsByTrack(track) } }.getOrElse { throwable -> @@ -59,24 +59,30 @@ class AlternativeTracksRepository( return emptyList() } - streamingUrlRepository.cacheAlternatives(trackId, sources) + streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId) logger.d { "Resolved ${sources.size} alternative sources for track $trackId" } return sources } suspend fun getActiveSourceId(track: MetadataTrack): String? { - return matchedTracksRepository.getTrackSource(track)?.id + val audioPlugin = pluginManager.selectedAudioPlugin.value ?: return null + return matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)?.id } suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) { val trackId = track.id + val audioPlugin = pluginManager.selectedAudioPlugin.value + if (audioPlugin == null) { + logger.w { "No audio plugin selected while selecting alternative for track $trackId" } + return + } val basic = when (source) { is AudioSource.Streamed -> source.toBasic() is AudioSource.Basic -> source } logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" } - matchedTracksRepository.saveTrackSource(track, basic) + matchedTracksRepository.saveTrackSource(track, basic, audioPlugin.pluginId) streamingUrlRepository.invalidateCachedStreamUrl(trackId) streamingUrlRepository.invalidateCachedAlternatives(trackId) cacheManager.invalidateCacheEntry(trackId) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt similarity index 76% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt index c5db9b1d..7bab2235 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.http.ContentType import io.ktor.http.Headers import io.ktor.http.HeadersBuilder @@ -51,13 +51,11 @@ data class CacheEntry( val filename: String, val sizeBytes: Long, val createdAtMs: Long, - val contentType: String = "application/octet-stream", -) + val contentType: String = "application/octet-stream") class CacheManager( private val paths: Paths, - private val settingsRepository: SettingsRepository, -) { + private val settingsRepository: SettingsRepository) { private val fileSystem: FileSystem = FileSystem.SYSTEM private val cacheMutex: Mutex = Mutex() private val json: Json = Json { ignoreUnknownKeys = true } @@ -159,16 +157,14 @@ class CacheManager( suspend fun commitCacheEntry( trackId: String, filename: String, - allBytes: ByteArray, - contentType: String, - ) { + sizeBytes: Long, + contentType: String) { val entry = CacheEntry( trackId = trackId, filename = filename, - sizeBytes = allBytes.size.toLong(), + sizeBytes = sizeBytes, createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), - contentType = contentType, - ) + contentType = contentType) cacheMutex.withLock { val index = readCacheIndex() writeCacheIndex(CacheIndex( @@ -200,22 +196,18 @@ class CacheManager( val (start, end) = range val length = end - start + 1 val contentRange = "bytes $start-$end/$fileSize" - fileSystem.source(filePath).buffer().use { source -> - source.skip(start) - val bytes = source.readByteArray(length) - call.respond(object : OutgoingContent.WriteChannelContent() { - override val status: HttpStatusCode = HttpStatusCode.PartialContent - override val contentLength: Long = bytes.size.toLong() - override val contentType: ContentType = entry.toContentType() - override val headers: Headers = HeadersBuilder().apply { - append(HttpHeaders.ContentRange, contentRange) - append(HttpHeaders.AcceptRanges, "bytes") - }.build() - override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { - channel.writeFully(bytes) - } - }) - } + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.PartialContent + override val contentLength: Long = length + override val contentType: ContentType = entry.toContentType() + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.ContentRange, contentRange) + append(HttpHeaders.AcceptRanges, "bytes") + }.build() + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + streamFileRange(fileSystem, filePath, start, length, channel) + } + }) return } call.respond(object : OutgoingContent.NoContent() { @@ -227,19 +219,42 @@ class CacheManager( return } - fileSystem.source(filePath).buffer().use { source -> - val bytes = source.readByteArray() - call.respond(object : OutgoingContent.WriteChannelContent() { - override val status: HttpStatusCode = HttpStatusCode.OK - override val contentLength: Long = bytes.size.toLong() - override val contentType: ContentType = entry.toContentType() - override val headers: Headers = HeadersBuilder().apply { - append(HttpHeaders.AcceptRanges, "bytes") - }.build() - override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { - channel.writeFully(bytes) - } - }) + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.OK + override val contentLength: Long = fileSize + override val contentType: ContentType = entry.toContentType() + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.AcceptRanges, "bytes") + }.build() + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + streamFileRange(fileSystem, filePath, 0, fileSize, channel) + } + }) + } + +} + +private const val STREAM_CHUNK_BYTES = 64 * 1024 + +/** + * Copies [length] bytes from [start] in fixed-size chunks. Cached tracks are tens of MB and the + * player re-requests them on every seek, so they must never be read into the heap whole. + */ +internal suspend fun streamFileRange( + fileSystem: FileSystem, + filePath: Path, + start: Long, + length: Long, + channel: io.ktor.utils.io.ByteWriteChannel) { + fileSystem.source(filePath).buffer().use { source -> + source.skip(start) + val chunk = ByteArray(STREAM_CHUNK_BYTES) + var remaining = length + while (remaining > 0) { + val read = source.read(chunk, 0, minOf(chunk.size.toLong(), remaining).toInt()) + if (read == -1) break + channel.writeFully(chunk, 0, read) + remaining -= read } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt similarity index 64% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index 6725f215..14e4837c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -15,21 +15,25 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import io.ktor.client.HttpClient +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.RemoteControlHandler +import org.spotube.spotube.modules.settings.SettingsViewModel +import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application -import io.ktor.server.cio.CIO +import io.ktor.server.application.install import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.head import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -55,10 +59,18 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, -) : KoinComponent { + private val remoteControlHandler: RemoteControlHandler, + httpClientFactory: HttpClientFactory) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient() + private val httpClient = httpClientFactory.create { + // A stalled upstream connection must not wedge an engine thread + // forever. Only the connect phase is bounded — the proxy streams long + // audio bodies, so request/socket timeouts would cut playback short. + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + } + } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serverMutex = Mutex() @@ -70,8 +82,10 @@ class LocalServer( private val activePort = MutableStateFlow(null) val port = activePort.asStateFlow() + private val activeHost = MutableStateFlow(null) + val baseUrl = activePort.map { port -> - port?.let { "http://$HOST:$it" } + port?.let { "http://$HOST_LOCAL:$it" } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) private val cachedCacheEnabled = MutableStateFlow(false) @@ -84,23 +98,23 @@ class LocalServer( audioPlayerQueue = audioPlayerQueue, isCachingEnabled = { cachedCacheEnabled.value }, activePort = { activePort.value }, - scope = scope, - ) + scope = scope) } companion object { - private const val HOST = "127.0.0.1" + private const val HOST_LOCAL = "127.0.0.1" + private const val HOST_LAN = "0.0.0.0" } init { logger.d { "Starting playback proxy port watcher" } portWatcher = scope.launch { settingsViewModel.settingsState - .mapNotNull { it?.playbackProxyServerPort } + .mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } } .distinctUntilChanged() - .collectLatest { port -> - logger.d { "Observed playback proxy port change to $port" } - restartServer(port) + .collectLatest { (port, allowRemoteControl) -> + logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" } + restartServer(port, allowRemoteControl) } } scope.launch { @@ -129,26 +143,37 @@ class LocalServer( logger.d { "Playback proxy server stopped" } } - private suspend fun restartServer(port: Int) { + private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) { + val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL serverMutex.withLock { - if (serverState.value != null && activePort.value == port) { - logger.v { "Playback proxy server already running on port $port; skipping restart" } + if (serverState.value != null && activePort.value == port && activeHost.value == host) { + logger.v { "Playback proxy server already running on $host:$port; skipping restart" } return } - logger.d { "Restarting playback proxy server on port $port" } + logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" } stopServerLocked() - serverState.value = embeddedServer( - factory = CIO, - host = HOST, - port = port, - module = { configureRoutes() } - ).also { engine -> - engine.start(wait = false) + val bound = runCatching { + startServerWithPortFallback( + host = host, + preferredPort = port, + onBindFailure = { failedPort, cause -> + logger.w(cause) { "Playback proxy could not bind to $host:$failedPort" } + } + ) { configureRoutes() } + }.getOrElse { throwable -> + logger.e(throwable) { "Playback proxy failed to start on $host (requested port $port)" } + return } - activePort.value = port - logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$HOST:$port"}" } + + if (bound.usedFallback) { + logger.w { "Port $port is unavailable; playback proxy is using $host:${bound.port} instead" } + } + serverState.value = bound.engine + activePort.value = bound.port + activeHost.value = host + logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:${bound.port}"}" } } } @@ -165,9 +190,16 @@ class LocalServer( } serverState.value = null activePort.value = null + activeHost.value = null } private fun Application.configureRoutes() { + install(WebSockets) { + pingPeriodMillis = 30_000L + timeoutMillis = 60_000L + maxFrameSize = 10L * 1024 * 1024 + masking = false + } routing { get("/health") { call.respondText("ok") @@ -188,6 +220,10 @@ class LocalServer( get("/segment/{trackId}") { streamProxy.handleSegmentRequest(call) } + + webSocket("/control") { + remoteControlHandler.handleConnection(this) + } } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt new file mode 100644 index 00000000..a93bb012 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt @@ -0,0 +1,75 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.server + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * A matched [AudioSource.Basic] tagged with the id of the audio plugin that produced it. + * The [pluginId] tag lets [MatchedTracksRepository] detect and discard matches that were + * made by a plugin the user is no longer using, since a source id from one plugin (e.g. a + * YouTube video id) is meaningless - or worse, silently wrong - when handed to a different + * plugin's `getStreamsOfAudioSource`. + */ +@Serializable +data class PluginTaggedAudioSource( + val pluginId: String, + val source: AudioSource.Basic) + +/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */ +interface TrackSourceRepository { + suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? + suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) +} + +class MatchedTracksRepository(private val database: Database) : TrackSourceRepository { + /** + * Returns the previously matched source for [track], but only if it was produced by + * [pluginId]. A match saved by a different (or since-removed) plugin is treated as a + * cache miss rather than being handed to the wrong plugin. + */ + override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { + return database.matchedTracksDataStore.data.map { prefs -> + val json = prefs[stringPreferencesKey(track.id)] ?: return@map null + val tagged = try { + Json.decodeFromString(json) + } catch (_: Exception) { + // Legacy entries stored before plugin-tagging was introduced; treat as + // unverifiable and force a fresh match instead of risking a wrong plugin. + return@map null + } + if (tagged.pluginId != pluginId) return@map null + tagged.source + }.first() + } + + override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + database.matchedTracksDataStore.edit { prefs -> + prefs[stringPreferencesKey(track.id)] = + Json.encodeToString(PluginTaggedAudioSource(pluginId, source)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt new file mode 100644 index 00000000..43d44309 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/ServerBinder.kt @@ -0,0 +1,89 @@ +/* + * 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 . + */ + + +package org.spotube.spotube.core.server + +import io.ktor.server.application.Application +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.connector +import io.ktor.server.engine.embeddedServer +import io.ktor.server.application.serverConfig +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val usedFallback: Boolean) + +/** + * Starts the playback proxy on [preferredPort] and confirms the socket is actually bound. + * + * CIO's `start(wait = false)` returns before binding, so a port that is already taken fails + * later on a worker thread and the server looks started while nothing is listening. If the + * preferred port can't be bound, retry on an OS-assigned free port (port 0). + * + * The caller must read [BoundServer.port] instead of assuming [preferredPort]. + * + * `reuseAddress` lets a restarted app rebind its old port while connections from the previous + * run (e.g. the player's) are still in TIME_WAIT. + */ +internal suspend fun startServerWithPortFallback( + host: String, + preferredPort: Int, + onBindFailure: (port: Int, cause: Throwable) -> Unit = { _, _ -> }, + module: Application.() -> Unit +): BoundServer { + val candidates = listOf(preferredPort, 0).distinct() + var lastFailure: Throwable? = null + + // CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the + // process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still + // reaches us through resolvedConnectors(), so it only needs to be contained here. + val swallowBindFailure = CoroutineExceptionHandler { _, _ -> } + + for (candidate in candidates) { + val engine = embeddedServer( + factory = CIO, + rootConfig = serverConfig { + parentCoroutineContext = swallowBindFailure + module { module() } + }, + configure = { + connector { + this.host = host + this.port = candidate + } + reuseAddress = true + }) + try { + engine.start(wait = false) + val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } + return BoundServer(engine, boundPort, usedFallback = candidate != preferredPort) + } catch (e: Throwable) { + runCatching { engine.stop(gracePeriodMillis = 0, timeoutMillis = 500) } + // A failed bind surfaces as the engine job's CancellationException; only rethrow + // when it is the caller's own coroutine that was cancelled. + currentCoroutineContext().ensureActive() + onBindFailure(candidate, e) + lastFailure = e + } + } + throw lastFailure ?: IllegalStateException("No port candidates to bind") +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt similarity index 66% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index a937bc65..338803bc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -15,14 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.server +package org.spotube.spotube.core.server -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger import io.ktor.client.HttpClient +import io.ktor.client.request.prepareRequest import io.ktor.client.request.request import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel @@ -38,21 +39,26 @@ import io.ktor.http.decodeURLQueryComponent import io.ktor.http.encodeURLParameter import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respond +import io.ktor.util.cio.ChannelWriteException +import io.ktor.utils.io.ClosedWriteChannelException import io.ktor.utils.io.copyTo -import io.ktor.utils.io.readRemaining +import io.ktor.utils.io.readAvailable import io.ktor.utils.io.writeFully +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.io.readByteArray import okio.FileSystem +import okio.IOException import okio.Path.Companion.toPath import okio.SYSTEM import okio.buffer import okio.use import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource internal class StreamProxy( private val httpClient: HttpClient, @@ -61,8 +67,7 @@ internal class StreamProxy( private val audioPlayerQueue: AudioPlayerQueue, private val isCachingEnabled: () -> Boolean, private val activePort: () -> Int?, - private val scope: CoroutineScope, -): KoinComponent { + private val scope: CoroutineScope): KoinComponent { val logger by injectLogger() companion object { @@ -88,6 +93,13 @@ internal class StreamProxy( return } + logger.i { + "[Req] $requestMethod /stream/$trackId from=${call.request.local.remoteHost} " + + "version=${call.request.local.version} range=${call.request.headers[HttpHeaders.Range]} " + + "ua=${call.request.headers[HttpHeaders.UserAgent]} " + + "connection=${call.request.headers[HttpHeaders.Connection]} caching=${isCachingEnabled()}" + } + if (isCachingEnabled()) { val cachedEntry = cacheManager.findCachedEntry(trackId) if (cachedEntry != null) { @@ -114,7 +126,7 @@ internal class StreamProxy( logger.d { "Track $trackId is being cached, waiting for cache" } var waited = 0 while (waited < 30) { - delay(200) + delay(200.milliseconds) waited++ val entry = cacheManager.findCachedEntry(trackId) if (entry != null) { @@ -127,8 +139,11 @@ internal class StreamProxy( } return } + // The caching request ended without committing (player disconnected or the + // source was switched); nothing left to wait for. + if (streamingMutex.withLock { trackId !in streamingInProgress }) break } - logger.w { "Timeout waiting for cache of track $trackId, falling through to upstream" } + logger.d { "No cache for track $trackId after waiting, falling through to upstream" } } try { @@ -145,7 +160,13 @@ internal class StreamProxy( } private suspend fun doStreamFromUpstream(call: ApplicationCall, trackId: String, requestMethod: HttpMethod) { + val resolveStart = TimeSource.Monotonic.markNow() val streamInfo = streamingUrlRepository.resolveStreamInfo(trackId) + logger.i { + "[Req] resolved stream for $trackId in ${resolveStart.elapsedNow()}: " + + "protocol=${streamInfo?.protocol} codec=${streamInfo?.codec} container=${streamInfo?.container} " + + "url=${streamInfo?.url?.take(120)}" + } if (streamInfo == null) { logger.w { "Unable to resolve stream for track $trackId" } call.respond(HttpStatusCode.NotFound, "Unable to resolve stream for track $trackId") @@ -245,7 +266,7 @@ internal class StreamProxy( call.respond(object : OutgoingContent.WriteChannelContent() { override val status: HttpStatusCode = HttpStatusCode.OK override val contentType: ContentType = contentType - override val contentLength: Long? = rewrittenManifest.encodeToByteArray().size.toLong() + override val contentLength: Long = rewrittenManifest.encodeToByteArray().size.toLong() override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { channel.writeFully(rewrittenManifest.encodeToByteArray()) @@ -260,18 +281,25 @@ internal class StreamProxy( ) { logger.d { "Proxying segment for track $trackId" } - val response = runCatching { - httpClient.request(segmentUrl) { + runCatching { + httpClient.prepareRequest(segmentUrl) { method = HttpMethod.Get call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + }.execute { response -> respondWithSegment(call, response, trackId) } + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + if (throwable.isClientDisconnect()) { + logger.d { "Player closed the connection while streaming segment of track $trackId" } + return } - }.getOrElse { throwable -> logger.w(throwable) { "Failed to fetch segment for track $trackId" } - call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") - return + runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") } } + } + + private suspend fun respondWithSegment(call: ApplicationCall, response: HttpResponse, trackId: String) { if (response.status.value >= 400) { logger.w { "Segment request failed for track $trackId with status ${response.status.value}" } @@ -305,48 +333,71 @@ internal class StreamProxy( var currentUrl = streamUrl var attemptedRefresh = false - var upstream: HttpResponse? = null + val upstreamStart = TimeSource.Monotonic.markNow() + // `request()` buffers the whole body before returning, which stalls the player until + // the full track is downloaded. `execute {}` hands over the response as soon as headers + // arrive so the body can be streamed. while (true) { - val response = runCatching { - httpClient.request(currentUrl) { + var retryUrl: String? = null + val failure = runCatching { + httpClient.prepareRequest(currentUrl) { method = requestMethod call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + }.execute { response -> + logger.i { + "[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " + + "status=${response.status} version=${response.version} " + + "headers=${response.headers.entries().joinToString { "${it.key}=${it.value}" }}" + } + if (response.status.value < 400) { + respondWithUpstream(call, response, trackId, requestMethod) + return@execute + } + + logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" } + streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl) + + if (!attemptedRefresh) { + val refreshedUrl = + streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url + if (!refreshedUrl.isNullOrBlank() && refreshedUrl != currentUrl) { + retryUrl = refreshedUrl + return@execute + } + } + respondWithUpstream(call, response, trackId, requestMethod) } - }.getOrElse { throwable -> - logger.w(throwable) { "Failed to fetch upstream stream for track $trackId" } - call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream") + }.exceptionOrNull() + + if (failure != null) { + if (failure is CancellationException) throw failure + if (failure.isClientDisconnect()) { + logger.i(failure) { "[Req] player closed the connection while streaming track $trackId" } + return + } + logger.w(failure) { "Failed to fetch upstream stream for track $trackId" } + // Throws if the response already started streaming; nothing more to send then + runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream") } return } - if (response.status.value < 400) { - upstream = response - break - } - - logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" } - streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl) - - if (attemptedRefresh) { - upstream = response - break - } - - val refreshedUrl = - streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url - if (refreshedUrl.isNullOrBlank() || refreshedUrl == currentUrl) { - upstream = response - break - } - + val next = retryUrl ?: return logger.d { "Retrying stream for track $trackId using refreshed URL" } - currentUrl = refreshedUrl + currentUrl = next attemptedRefresh = true } + } + private suspend fun respondWithUpstream( + call: ApplicationCall, + upstream: HttpResponse, + trackId: String, + requestMethod: HttpMethod + ) { if (upstream.status.value >= 400 && isCachingEnabled()) { val cachedEntry = cacheManager.findCachedEntry(trackId) if (cachedEntry != null) { @@ -374,13 +425,15 @@ internal class StreamProxy( if (requestMethod == HttpMethod.Head) { call.respond(object : OutgoingContent.NoContent() { override val status: HttpStatusCode = upstream.status - override val headers: Headers = downstreamHeaders + override val headers: Headers = downstreamHeaders.passthroughForPlayer() override val contentLength: Long? = downstreamContentLength }) return } - val doCache = requestMethod == HttpMethod.Get && isCachingEnabled() + // Only a full 200 response can be cached; 206 partial responses must not be stored as the whole track + val doCache = requestMethod == HttpMethod.Get && isCachingEnabled() && + upstream.status == HttpStatusCode.OK val cacheTrack = if (doCache) getTrackMetadata(trackId) else null val cacheFilename = if (cacheTrack != null) { cacheManager.resolveCacheFilename(cacheTrack, downstreamHeaders[HttpHeaders.ContentType]) @@ -388,41 +441,65 @@ internal class StreamProxy( null } val trackIdForCache = trackId + val expectedLength = downstreamContentLength val cacheContentType = downstreamHeaders[HttpHeaders.ContentType] ?: "application/octet-stream" call.respond(object : OutgoingContent.WriteChannelContent() { override val status: HttpStatusCode = upstream.status - override val headers: Headers = downstreamHeaders + override val headers: Headers = downstreamHeaders.passthroughForPlayer() override val contentLength: Long? = downstreamContentLength override val contentType: ContentType? = downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + val writeStart = TimeSource.Monotonic.markNow() + logger.i { "[Req] start writing body for $trackId status=${upstream.status} cache=${cacheFilename != null} expected=$expectedLength" } + try { + writeBody(channel) + logger.i { "[Req] finished writing body for $trackId in ${writeStart.elapsedNow()}" } + } catch (e: Throwable) { + logger.i(e) { "[Req] body write for $trackId aborted after ${writeStart.elapsedNow()}: ${e::class.simpleName}: ${e.message}" } + throw e + } + } + + private suspend fun writeBody(channel: io.ktor.utils.io.ByteWriteChannel) { if (cacheFilename != null) { val cacheDir = cacheManager.resolveCacheDir() val cacheFile = cacheDir / cacheFilename.toPath() fileSystem.createDirectories(cacheDir) + var completed = false + var totalBytes = 0L try { - val packet = upstream.bodyAsChannel().readRemaining() - val allBytes = packet.readByteArray() - channel.writeFully(allBytes) + val source = upstream.bodyAsChannel() + val buffer = ByteArray(64 * 1024) fileSystem.sink(cacheFile).buffer().use { sink -> - sink.write(allBytes) + while (true) { + val read = source.readAvailable(buffer, 0, buffer.size) + if (read < 0) break + if (read == 0) continue + channel.writeFully(buffer, 0, read) + channel.flush() + sink.write(buffer, 0, read) + totalBytes += read + } } - cacheManager.commitCacheEntry( - trackId = trackIdForCache, - filename = cacheFilename, - allBytes = allBytes, - contentType = cacheContentType, - ) - scope.launch { - cacheManager.evictIfNeeded() + completed = expectedLength == null || expectedLength == totalBytes + if (completed) { + cacheManager.commitCacheEntry( + trackId = trackIdForCache, + filename = cacheFilename, + sizeBytes = totalBytes, + contentType = cacheContentType) + scope.launch { + cacheManager.evictIfNeeded() + } } - } catch (e: Exception) { - if (fileSystem.exists(cacheFile)) { + } finally { + // Player disconnected or stream was incomplete: drop the partial file + if (!completed && fileSystem.exists(cacheFile)) { fileSystem.delete(cacheFile) } - throw e } } else { upstream.bodyAsChannel().copyTo(channel) @@ -488,6 +565,16 @@ internal class StreamProxy( return rewritten } + /** + * True when the failure came from writing to the player's socket (seek, skip or stop closes + * it mid-stream), as opposed to a failure fetching from upstream. + */ + private fun Throwable.isClientDisconnect(): Boolean = + generateSequence(this) { it.cause }.take(10).any { + it is ChannelWriteException || it is ClosedWriteChannelException || + (it is IOException && it.message?.contains("Broken pipe", ignoreCase = true) == true) + } + private fun encodeUrl(url: String): String = url.encodeURLParameter() private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent() @@ -507,4 +594,28 @@ internal class StreamProxy( } }.build() } + +} + +private val PLAYER_PASSTHROUGH_HEADERS = listOf( + HttpHeaders.ContentRange, + HttpHeaders.AcceptRanges, + HttpHeaders.ETag, + HttpHeaders.LastModified, +) + +/** + * The upstream headers a player actually needs. Copying them all makes Ktor emit duplicate + * Content-Length/Content-Type (also set through the OutgoingContent) and forwards hop-by-hop + * headers like `Connection: keep-alive`, which VLC rejects by closing the socket right after + * the response headers. Content-Range and Accept-Ranges must stay: without them the player + * treats a 206 as invalid and the stream as unseekable, and e.g. WebM can't be demuxed. + */ +internal fun Headers.passthroughForPlayer(): Headers { + val upstream = this + return HeadersBuilder().apply { + PLAYER_PASSTHROUGH_HEADERS.forEach { name -> + upstream.getAll(name)?.forEach { value -> append(name, value) } + } + }.build() } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt new file mode 100644 index 00000000..11a8ae60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -0,0 +1,488 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.component.KoinComponent +import kotlin.math.abs +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds + +data class CachedStreamUrl( + val url: String, + val protocol: StreamProtocol, + val expiresAtMs: Long, + val container: String, + val codec: String, + val source: AudioSource.Streamed? = null, + val selectedStream: AudioStream? = null, + val preferredFormat: AudioFormat, + val preferredQuality: AudioQuality, + // The id of the audio plugin that produced this entry. Guards against a stale entry + // (e.g. a YouTube video URL) being served after the user switches to a different + // audio plugin, which would otherwise play back completely unrelated content. + val pluginId: String) + +private data class CachedAlternatives( + val pluginId: String, + val sources: List) + +/** + * Stream preferences for callers other than playback. The downloader passes the user's *download* + * format/quality and needs a file it can fetch with plain HTTP, so DASH/HLS manifests are excluded. + * Such requests bypass the playback stream-URL cache in both directions. + */ +data class StreamRequest( + val format: AudioFormat, + val quality: AudioQuality, + val progressiveOnly: Boolean) + +data class StreamInfo( + val url: String, + val protocol: StreamProtocol, + val codec: String, + val container: String) + +class StreamingUrlRepository( + private val pluginManager: AudioPluginSource, + private val audioPlayerQueue: AudioPlayerQueue, + private val matchedTracksRepository: TrackSourceRepository, + private val settingsRepository: UserSettingsSource, + private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds, + private val platform: PlatformType = currentPlatform) : KoinComponent { + + private val logger by injectLogger() + private val streamUrlCacheMutex = Mutex() + private val streamUrlCache = mutableMapOf() + private val alternativesCacheMutex = Mutex() + private val alternativesCache = mutableMapOf() + + suspend fun resolveStreamInfo( + trackId: String, + forceRefresh: Boolean = false + ): StreamInfo? { + val queueEntry = audioPlayerQueue.queueFlow.value.firstOrNull { + it is QueueEntry.StreamingTrack && it.track.id == trackId + } as QueueEntry.StreamingTrack? + + if (queueEntry == null) { + logger.v { "Track $trackId is not present in current queue" } + return null + } + return resolveStreamInfo(queueEntry.track, forceRefresh) + } + + suspend fun resolveStreamInfo( + track: MetadataTrack, + forceRefresh: Boolean = false, + request: StreamRequest? = null + ): StreamInfo? { + val trackId = track.id + val settings = settingsRepository.userSettings.value + val preferredFormat = request?.format ?: settings.streamingMusicFormat + val preferredQuality = request?.quality ?: settings.streamingMusicQuality + // The URL cache holds what *playback* resolved; a download with other prefs must not + // read it, nor replace it with a stream playback didn't ask for. + val useUrlCache = request == null + + // A built-in audio plugin is always selected, so null means plugins are still loading. + // The player requests the restored queue's track right at startup; failing that request + // leaves the item permanently failed (AVPlayer), so wait for the plugin instead. + val audioPlugin = pluginManager.selectedAudioPlugin.value + ?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) { + logger.i { "[Resolve $trackId] waiting for the audio plugin to load" } + pluginManager.selectedAudioPlugin.filterNotNull().first() + } + if (audioPlugin == null) { + logger.w { "No audio plugin selected while resolving stream for track $trackId" } + return null + } + logger.i { + "[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " + + "prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " + + if (request == null) "(streaming settings)" else "(caller request, progressiveOnly=${request.progressiveOnly})" + } + + fun select(stream: AudioSource.Streamed, origin: String): AudioStream? { + val candidates = if (request?.progressiveOnly == true) { + stream.streams.filter { it.protocol == StreamProtocol.PROGRESSIVE }.also { kept -> + if (kept.size != stream.streams.size) logger.i { + "[Resolve $trackId] dropped ${stream.streams.size - kept.size} manifest (DASH/HLS) candidates; " + + "caller needs a directly downloadable file" + } + } + } else { + stream.streams + } + return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform) + .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } + } + + if (useUrlCache && !forceRefresh) { + getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> + logger.i { + "[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " + + "protocol=${cached.protocol} ${urlSummary(cached.url)}" + } + return cached + } + } + + val source = matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId) + + if (source != null) { + logger.d { "Attempting stream resolution for track $trackId using cached source" } + val stream = runCatching { + audioPlugin.use { + audioAPI.getStreamsOfAudioSource(source) + .firstOrNull() + } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to resolve audio stream for track $trackId using matched source" } + null + } + + if (stream != null) { + logger.d { "Resolved stream for track $trackId using cached source" } + val resolvedStream = select(stream, "matched source ${source.id}") + if (resolvedStream != null) { + val info = resolvedStream.toStreamInfo() + if (useUrlCache) { + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + } + return info + } + } + + logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" } + } + + logger.d { "Attempting stream resolution for track $trackId using track lookup" } + val sources = runCatching { + audioPlugin.use { + audioAPI.getStreamsByTrack(track) + } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to resolve audio stream for track $trackId" } + return null + } + + if (sources.isEmpty()) { + logger.w { "No stream candidates found for track $trackId" } + return null + } + + cacheAlternatives(trackId, sources, audioPlugin.pluginId) + + val stream = runCatching { + audioPlugin.use { + sources.firstNotNullOfOrNull { src -> + when (src) { + is AudioSource.Streamed -> { + matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId) + src + } + + is AudioSource.Basic -> { + matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId) + audioAPI.getStreamsOfAudioSource(src) + .firstOrNull() + } + } + } + } + }.getOrElse { throwable -> + logger.w(throwable) { "Failed to resolve audio stream for track $trackId" } + return null + } + + if (stream == null) { + logger.w { "No stream candidates found for track $trackId" } + return null + } + + logger.d { "Resolved stream for track $trackId via track lookup" } + val resolvedStream = select(stream, "track lookup -> ${stream.id}") + if (resolvedStream != null) { + val info = resolvedStream.toStreamInfo() + if (useUrlCache) { + cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) + } + return info + } + return null + } + + /** Logs every candidate with its ranking key so it's obvious *why* a stream won. */ + private fun logSelection( + trackId: String, + origin: String, + streams: List, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + selected: AudioStream?) { + logger.i { + "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + + "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + + "lowest wins):\n" + + streams.joinToString("\n") { stream -> + val marker = if (stream === selected) " -> " else " " + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " + + "${formatMismatchCount(stream, preferredFormat)}, " + + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" + } + } + when { + selected == null -> logger.w { "[Resolve $trackId] no stream selected" } + !selected.isPlayableOn(platform) -> logger.w { + "[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " + + "${selected.describeForLog()}, which the player will likely reject" + } + formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { + "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + + "got ${selected.codec}/${selected.container} (${selected.protocol})" + } + selected.qualityDistance(preferredQuality) != 0L -> logger.w { + "[Resolve $trackId] QUALITY FALLBACK: wanted $preferredQuality, got ${selected.describeForLog()}" + } + } + } + + private fun AudioStream.toStreamInfo() = StreamInfo( + url = normalizeManifestUrl(url, protocol), + protocol = protocol, + codec = codec, + container = container) + + private suspend fun getCachedStreamUrl( + trackId: String, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + pluginId: String): StreamInfo? { + val now = Clock.System.now().toEpochMilliseconds() + return streamUrlCacheMutex.withLock { + val cached = streamUrlCache[trackId] ?: return@withLock null + if (cached.pluginId != pluginId) { + logger.v { "Cached stream URL for track $trackId belongs to a different plugin; ignoring" } + return@withLock null + } + if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) { + return@withLock null + } + if (cached.expiresAtMs <= now) { + logger.v { "Cached stream URL expired for track $trackId" } + return@withLock null + } + StreamInfo(cached.url, cached.protocol, cached.codec, cached.container) + } + } + + private suspend fun cacheStreamInfo( + trackId: String, + info: StreamInfo, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + source: AudioSource.Streamed, + selectedStream: AudioStream, + pluginId: String) { + val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs + streamUrlCacheMutex.withLock { + streamUrlCache[trackId] = CachedStreamUrl( + url = info.url, + protocol = info.protocol, + expiresAtMs = expiresAtMs, + container = info.container, + codec = info.codec, + source = source, + selectedStream = selectedStream, + preferredFormat = preferredFormat, + preferredQuality = preferredQuality, + pluginId = pluginId) + } + } + + suspend fun invalidateCachedStreamUrl(trackId: String, url: String? = null) { + streamUrlCacheMutex.withLock { + val cached = streamUrlCache[trackId] ?: return@withLock + if (url == null || cached.url == url) { + streamUrlCache.remove(trackId) + logger.v { "Invalidated cached stream URL for track $trackId" } + } + } + // Stream metadata is part of the same URL-cache entry and is removed with it. + } + + /** Returns cached alternatives only if they were produced by [pluginId]. */ + suspend fun getCachedAlternatives(trackId: String, pluginId: String): List? { + return alternativesCacheMutex.withLock { + val cached = alternativesCache[trackId] ?: return@withLock null + if (cached.pluginId != pluginId) { + logger.v { "Cached alternatives for track $trackId belong to a different plugin; ignoring" } + return@withLock null + } + cached.sources + } + } + + suspend fun cacheAlternatives(trackId: String, sources: List, pluginId: String) { + alternativesCacheMutex.withLock { + alternativesCache[trackId] = CachedAlternatives(pluginId, sources) + logger.v { "Cached ${sources.size} alternative sources for track $trackId" } + } + } + + /** Returns stream details from the existing playback URL cache; never fetches them. */ + suspend fun getCachedStreamUrlEntry(trackId: String): CachedStreamUrl? { + val now = Clock.System.now().toEpochMilliseconds() + return streamUrlCacheMutex.withLock { + val entry = streamUrlCache[trackId] ?: return@withLock null + if (entry.expiresAtMs <= now) { + logger.v { "Returning expired stream details for track $trackId" } + } + entry + } + } + + suspend fun invalidateCachedAlternatives(trackId: String) { + alternativesCacheMutex.withLock { + alternativesCache.remove(trackId) + } + } + +} + +/** + * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are + * resolved against the YouTube origin; progressive URLs are returned unchanged. + */ +private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds + +internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { + if (url.startsWith("http")) return url + return when (protocol) { + StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com$url" + StreamProtocol.PROGRESSIVE -> url + } +} + +internal val currentPlatform: PlatformType by lazy { getPlatform().type } + +/** + * Selects the available stream closest to the user's preferred format and quality. + * Streams the [platform]'s player can decode always win over ones it can't, whatever the + * preferences say: e.g. opus/webm is unplayable on iOS. + */ +internal fun selectPreferredAudioStream( + streams: List, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull( + compareBy { stream -> + if (stream.isPlayableOn(platform)) 0 else 1 + }.thenBy { stream -> + formatMismatchCount(stream, preferredFormat) + }.thenBy { stream -> + if (stream.matchesQualityType(preferredQuality)) 0 else 1 + }.thenBy { stream -> + stream.qualityDistance(preferredQuality) + }.thenBy { stream -> + stream.channelDistance(preferredQuality) + } +) + +private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska") + +// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there. +private val UNSUPPORTED_CONTAINERS: Map> = mapOf( + // AVPlayer has no WebM/Ogg/Matroska demuxer. + PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga")) + +/** + * Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle + * everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. + */ +internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = + isContainerPlayableOn(container, platform) && + !(platform == PlatformType.IOS && protocol == StreamProtocol.DASH) + +/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */ +internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean = + container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty() + +private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + + (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) + +private fun AudioStream.matchesQualityType(preferredQuality: AudioQuality): Boolean = when (this) { + is AudioStream.Lossy -> preferredQuality is AudioQuality.Lossy + is AudioStream.Lossless -> preferredQuality is AudioQuality.Lossless +} + +private fun AudioStream.qualityDistance(preferredQuality: AudioQuality): Long = when { + this is AudioStream.Lossy && preferredQuality is AudioQuality.Lossy -> + abs(bitrate.toLong() - preferredQuality.bitrate.toLong()) + + this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless -> + abs(sampleRate.toLong() - preferredQuality.sampleRate.toLong()) + + else -> Long.MAX_VALUE +} + +private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = when { + this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless -> + abs(channels - preferredQuality.channels) + + else -> 0 +} + +internal fun AudioStream.describeForLog(): String = when (this) { + is AudioStream.Lossy -> "lossy codec=$codec container=$container bitrate=$bitrate protocol=$protocol" + is AudioStream.Lossless -> + "lossless codec=$codec container=$container sampleRate=$sampleRate channels=$channels protocol=$protocol" +} + +/** + * Host plus the query params that identify a YouTube stream (itag, mime, clen = byte size, dur), + * instead of the full signed URL. + */ +internal fun urlSummary(url: String): String = runCatching { + val parsed = io.ktor.http.Url(url) + val keys = listOf("itag", "mime", "clen", "dur") + val params = keys.mapNotNull { key -> parsed.parameters[key]?.let { "$key=$it" } } + "url=${parsed.host}${parsed.encodedPath.take(40)}" + if (params.isEmpty()) "" else " ${params.joinToString(" ")}" +}.getOrDefault("url=") diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/share/ShareService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/share/ShareService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt index d98d8ad2..9ff7ccb9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/share/ShareService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/share/ShareService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share interface ShareService { fun share(url: String, title: String) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt new file mode 100644 index 00000000..de319170 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt @@ -0,0 +1,198 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.tools.user_agents + +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.getPlatform +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM +import kotlin.time.Clock + +/** + * Provides browser User-Agent strings that match the platform the app is running on + * (Android UAs only on Android, iOS only on iOS, and desktop UAs only on the matching + * Windows / macOS / Linux host). + * + * The primary source is the `top-user-agents` list maintained by Microlink (the 100 most + * used UAs, kept current), cached on disk for [CACHE_TTL_MS]. The hardcoded [FALLBACKS] + * are used whenever nothing has been fetched yet or the fetch fails. + * + * [random] is intentionally synchronous and never blocks on the network: it serves + * whatever is cached (even stale) and refreshes in the background when needed. + */ +object UserAgents : KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() + private const val SOURCE_URL = + "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" + private const val CACHE_FILE_NAME = "user_agents_cache.json" + private const val CACHE_TTL_MS = 24L * 60 * 60 * 1000 // 1 day + + private val logger = Logger.withTag("UserAgents") + private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshMutex = Mutex() + + private val platformType: PlatformType = getPlatform().type + + @Serializable + private data class CacheEntry(val fetchedAtMs: Long, val agents: List) + private var remoteAgents: List = emptyList() + private var fetchedAtMs: Long = 0 + + // Loads the disk cache once and kicks off a background refresh if it is missing/stale. + private val bootstrap by lazy { + loadFromDisk() + if (isStale()) scope.launch { refresh() } + } + + fun random(): String { + bootstrap + return pool().random() + } + + /** + * Fetches the latest list from the network and caches it. No-op while the cache is + * still fresh unless [force] is set. Failures keep whatever data is already cached. + */ + suspend fun refresh(force: Boolean = false) = refreshMutex.withLock { + if (!force && !isStale()) return@withLock + try { + val agents = fetchRemote().filter(::isValidAgent) + if (agents.isEmpty()) return@withLock + remoteAgents = agents + fetchedAtMs = now() + saveToDisk(CacheEntry(fetchedAtMs, agents)) + } catch (e: Exception) { + logger.w(e) { "Failed to refresh user agents, using cached/fallback ones" } + } + } + + private fun pool(): List { + val matching = remoteAgents.filter { matchesPlatform(it, platformType) } + return matching.ifEmpty { FALLBACKS.getValue(platformType) } + } + + private fun isStale() = now() - fetchedAtMs >= CACHE_TTL_MS + + private fun now() = Clock.System.now().toEpochMilliseconds() + + private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/") + + private suspend fun fetchRemote(): List { + val res = httpClientFactory.default.get(SOURCE_URL) + if (!res.status.isSuccess()) error("Unexpected status ${res.status}") + return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) + } + + private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() + + private fun loadFromDisk() { + try { + val path = cachePath() + val fs = FileSystem.SYSTEM + if (!fs.exists(path)) return + val entry = json.decodeFromString(fs.read(path) { readUtf8() }) + val agents = entry.agents.filter(::isValidAgent) + if (agents.isEmpty()) return + remoteAgents = agents + fetchedAtMs = entry.fetchedAtMs + } catch (e: Exception) { + logger.w(e) { "Failed to read user agents cache" } + } + } + + private fun saveToDisk(entry: CacheEntry) { + try { + val path = cachePath() + val tmp = "$path.tmp".toPath() + val fs = FileSystem.SYSTEM + path.parent?.let { fs.createDirectories(it) } + fs.write(tmp) { writeUtf8(json.encodeToString(CacheEntry.serializer(), entry)) } + fs.atomicMove(tmp, path) + } catch (e: Exception) { + logger.w(e) { "Failed to write user agents cache" } + } + } + + internal fun matchesPlatform(ua: String, type: PlatformType): Boolean { + val isIos = "iPhone" in ua || "iPad" in ua || "iPod" in ua + val isAndroid = "Android" in ua + return when (type) { + PlatformType.IOS -> isIos + PlatformType.Android -> isAndroid + PlatformType.Windows -> "Windows NT" in ua && !isAndroid + PlatformType.MacOS -> "Macintosh" in ua && !isIos + PlatformType.Linux -> ("X11" in ua || "Linux" in ua) && !isAndroid && "CrOS" !in ua + // Unknown host: any desktop UA is a safe choice. + PlatformType.Unknown -> !isIos && !isAndroid + } + } + + private val FALLBACKS: Map> = mapOf( + PlatformType.Windows to listOf( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Vivaldi/7.6.3797.63", + ), + PlatformType.MacOS to listOf( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.5.3735.47", + ), + PlatformType.Linux to listOf( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + ), + PlatformType.Android to listOf( + "Mozilla/5.0 (Linux; Android 15; ALI-NX1 Build/HONORALI-N21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.92 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; SM-A528B Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/147.0.7727.108 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36", + ), + PlatformType.IOS to listOf( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/146.0.0.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + ), + PlatformType.Unknown to listOf( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt new file mode 100644 index 00000000..31a3fa05 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -0,0 +1,131 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.ui.unit.dp +import org.spotube.spotube.resources.iconsax.FluentDismiss +import org.spotube.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun AdaptiveDialogBottomSheet( + onDismiss: () -> Unit, + title: @Composable (() -> Unit)? = null, + // Plain-text title for the large-screen dialog's own titlebar; when set it replaces [title] + // there so the titlebar isn't left empty above a duplicated in-body title. + dialogTitle: String? = null, + modifier: Modifier = Modifier, + actions: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit) { + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + if (isLargeScreen) { + MillenniumDialog( + visible = true, + onDismissRequest = onDismiss, + modifier = modifier, + paneTitle = dialogTitle ?: "") { + MillenniumDialogHeader { + if (dialogTitle != null) { + MillenniumDialogTitle(text = dialogTitle, textStyle = AppTheme.typography.titleMedium) + } else { + MillenniumDialogTitle { title?.invoke() } + } + MillenniumDialogClose( + contentDescription = stringResource(Res.string.common_close_dialog)) + } + MillenniumDialogBody { content() } + actions?.let { MillenniumDialogFooter(content = { it() }) } + } + } else { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start) { + title?.let { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f)) { it() } + MillenniumIconButton( + onClick = onDismiss, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.FluentDismiss, + contentDescription = stringResource(Res.string.common_close_dialog)) + } + } + Spacer(Modifier.height(12.dp)) + } + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState())) { + content() + } + actions?.let { + Spacer(Modifier.height(8.dp)) + MillenniumSeparator() + Spacer(Modifier.height(8.dp)) + it() + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt new file mode 100644 index 00000000..ed860c08 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -0,0 +1,370 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +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.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +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.graphics.vector.ImageVector +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import compose.icons.FeatherIcons +import compose.icons.feathericons.Check +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxShare +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuContent +import org.spotube.millennium.composables.MillenniumDropdownMenuEmpty +import org.spotube.millennium.composables.MillenniumDropdownMenuFilter +import org.spotube.millennium.composables.MillenniumDropdownMenuHeader +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.millennium.composables.MillenniumDropdownMenuItemIcon +import org.spotube.millennium.composables.MillenniumDropdownMenuSeparator +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.composables.MillenniumTextField +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.accessibility_selected +import spotube.composeapp.generated.resources.common_no_results +import spotube.composeapp.generated.resources.search_filter_placeholder + +data class AdaptiveMenuItem( + val icon: ImageVector? = null, + val label: String, + val onClick: () -> Unit, + val enabled: Boolean = true, + val selected: Boolean = false, + val dividerBefore: Boolean = false, + val subtitle: String? = null, + val leadingContent: (@Composable () -> Unit)? = null) + +enum class HeaderDisplayMode { + Always, + OnlyInDropdown, + OnlyInBottomSheet, +} + +@Composable +fun AdaptiveDropdownBottomSheet( + items: List, + trigger: @Composable (onClick: () -> Unit) -> Unit, + modifier: Modifier = Modifier, + menuMinWidth: Dp = 200.dp, + header: @Composable (() -> Unit)? = null, + headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { + var expanded by remember { mutableStateOf(false) } + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + Box(modifier = modifier) { + if (isLargeScreen) { + DesktopDropdownMenu( + expanded = expanded, + onExpandedChange = { expanded = it }, + items = items, + menuMinWidth = menuMinWidth, + filter = filter, + trigger = { trigger { expanded = true } }) + } else { + trigger { expanded = true } + if (expanded) { + AdaptiveBottomSheetContent( + onDismiss = { expanded = false }, + items = items, + header = if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInBottomSheet)) { + header + } else { + null + }, + filter = filter) + } + } + } +} + +@Composable +private fun AdaptiveBottomSheetContent( + onDismiss: () -> Unit, + items: List, + header: @Composable (() -> Unit)?, + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { + val hasSelection = items.any { it.selected } + val hasLeading = items.any { it.icon != null || it.leadingContent != null } + val queryState = rememberTextFieldState() + val query = queryState.text.toString() + val filteredItems = if (filter != null && query.isNotBlank()) { + items.filter { filter(it, query) } + } else { + items + } + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp), + contentSpacing = 4.dp) { + Column( + modifier = Modifier.fillMaxWidth()) { + header?.invoke() + + if (filter != null) { + Spacer(modifier = Modifier.height(8.dp)) + MillenniumTextField( + state = queryState, + label = "", + placeholder = stringResource(Res.string.search_filter_placeholder), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + items( + count = filteredItems.size) { index -> + val item = filteredItems[index] + + if (item.dividerBefore) { + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) + } + AdaptiveMenuTile( + item = item, + hasSelection = hasSelection, + hasLeading = hasLeading, + textStyle = AppTheme.typography.bodyLarge, + minimumHeight = 56.dp, + onClick = { + item.onClick() + onDismiss() + }) + } + } + } + } +} + +/** One menu row. The leading slot is always reserved when any sibling has an icon/leading content + * (or a selection), so labels and icons stay aligned in a column. */ +@Composable +private fun AdaptiveMenuTile( + item: AdaptiveMenuItem, + hasSelection: Boolean, + hasLeading: Boolean, + textStyle: androidx.compose.ui.text.TextStyle, + minimumHeight: Dp, + onClick: () -> Unit) { + if (item.dividerBefore) { + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) + } + val contentColor = if (item.enabled) AppTheme.colors.onSurface + else AppTheme.colors.onSurface.copy(alpha = 0.38f) + MillenniumListTile( + title = { MillenniumText(item.label, style = textStyle, color = contentColor) }, + subtitle = item.subtitle?.let { subtitle -> + { + MillenniumText( + subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + }, + leading = when { + item.leadingContent != null -> item.leadingContent + item.icon != null -> { + { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = contentColor) + } + } + item.selected -> { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp)) + } + } + hasSelection || hasLeading -> {{ Spacer(modifier = Modifier.size(22.dp)) }} + else -> null + }, + trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) { + { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = stringResource(Res.string.accessibility_selected), + modifier = Modifier.size(22.dp)) + } + } else { + null + }, + selected = item.selected, + enabled = item.enabled, + onClick = onClick, + minimumHeight = minimumHeight) +} + +/** Desktop popup built from Millennium's dropdown bricks: native icons, descriptions, + * selection indicator, separators and an optional search header. */ +@Composable +private fun DesktopDropdownMenu( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + items: List, + menuMinWidth: Dp, + filter: ((AdaptiveMenuItem, String) -> Boolean)?, + trigger: @Composable () -> Unit) { + val queryState = rememberTextFieldState() + LaunchedEffect(expanded) { if (!expanded) queryState.clearText() } + val query = queryState.text.toString() + val visibleItems = if (filter != null && query.isNotBlank()) { + items.filter { filter(it, query) } + } else { + items + } + val hasLeading = items.any { it.icon != null || it.leadingContent != null } + val iconSize = MillenniumTheme.dropdownMenuTheme.states.default.iconSize + + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = onExpandedChange, + minimumWidth = menuMinWidth, + width = menuMinWidth, + anchor = trigger) { + if (filter != null) { + MillenniumDropdownMenuHeader(showDivider = false) { + MillenniumDropdownMenuFilter( + state = queryState, + placeholder = stringResource(Res.string.search_filter_placeholder)) + } + } + MillenniumDropdownMenuContent { + if (visibleItems.isEmpty()) { + MillenniumDropdownMenuEmpty(stringResource(Res.string.common_no_results)) + } + visibleItems.forEach { item -> + if (item.dividerBefore) MillenniumDropdownMenuSeparator() + MillenniumDropdownMenuItem( + onClick = item.onClick, + enabled = item.enabled, + selected = item.selected, + leading = item.leadingContent + ?: item.icon?.let { icon -> { MillenniumDropdownMenuItemIcon(icon) } } + ?: if (hasLeading) ({ Spacer(Modifier.size(iconSize)) }) else null, + description = item.subtitle?.let { subtitle -> { Text(subtitle) } }) { + Text(item.label) + } + } + } + } +} + +@Preview +@Composable +private fun AdaptiveDropdownBottomSheetPreview() { + Box(modifier = Modifier.background(AppTheme.colors.background)) { + AdaptiveDropdownBottomSheet( + items = listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxHeart, + label = "Favorite", + onClick = {}), + AdaptiveMenuItem( + icon = Iconsax.IconsaxShare, + label = "Share", + onClick = {}), + AdaptiveMenuItem( + icon = Iconsax.IconsaxTrash, + label = "Delete", + onClick = {}, + enabled = false)), + trigger = { onClick -> + MillenniumIconButton( + onClick = onClick, + modifier = Modifier + .size(32.dp) + .background( + color = AppTheme.colors.primary.copy(alpha = 0.1f), + shape = CircleShape) + .clip(CircleShape), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = null, + tint = AppTheme.colors.primary) + } + }) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt similarity index 51% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt index 2e021980..b651edbf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/AlbumCard.kt @@ -15,22 +15,24 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import kotlinx.coroutines.launch +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.koin.compose.koinInject @Composable @@ -39,14 +41,17 @@ fun AlbumCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false ) { - val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() PlayableCard( title = album.title, - subtitle = album.description ?: "${album.albumType} • ${album.artists.joinToString { it.name }}", + subtitle = album.description + ?: "${album.albumType} • ${album.artists.joinToString { it.name }}", imageURL = album.thumbnails.firstOrNull()?.url, isPlaying = currentCollectionEntry?.id == album.id, onClick = { @@ -54,12 +59,23 @@ fun AlbumCard( }, onPlay = { if (currentCollectionEntry?.id == album.id) return@PlayableCard - scope.launch { playbackHelper.playAlbum(album.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + album.id, + album.title + ) }, onAddToQueue = { - scope.launch { playbackHelper.addAlbumToQueue(album.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Album, + album.id, + album.title + ) }, - modifier = modifier.width(160.dp), + modifier = if (listMode) modifier else modifier.width(160.dp), sharedElementKey = "album_art_${album.id}", + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.AddToQueue) in busyCollections ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt similarity index 63% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt index 359ee469..74d7e83b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.kt @@ -15,29 +15,34 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton + import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 -import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.resources.iconsax.ArrowLeft3 +import org.spotube.spotube.resources.iconsax.Iconsax import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun ApplicationBackButton() { val navigationCommands: NavigationCommands = koinInject() - IconButton( + MillenniumIconButton( onClick = { navigationCommands.pop() }, - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.ArrowLeft3, - contentDescription = "Back" + contentDescription = stringResource(Res.string.common_back) ) } } @@ -47,5 +52,5 @@ expect fun ApplicationMainBar( title: @Composable () -> Unit = {}, subtitle: @Composable () -> Unit = {}, actions: @Composable (RowScope.() -> Unit) = {}, - backButton: Boolean = true -) \ No newline at end of file + backButton: Boolean = true, + transparent: Boolean = false) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt new file mode 100644 index 00000000..c974f17c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ArtistCard.kt @@ -0,0 +1,50 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun ArtistCard( + artist: MetadataArtist.Basic, + modifier: Modifier = Modifier, + isBlacklisted: Boolean = false, + listMode: Boolean = false +) { + val navigationCommands = koinInject() + + AvatarCard( + title = artist.name, + subtitle = stringResource(if (isBlacklisted) Res.string.media_blacklisted else Res.string.media_artist), + imageURL = artist.thumbnails.firstOrNull()?.url, + onClick = { + navigationCommands.navigateTo(Routes.Artist(artist.id)) + }, + modifier = modifier, + isBlacklisted = isBlacklisted, + listMode = listMode + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt similarity index 51% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt index 7f4335b5..84901d48 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionDetails.kt @@ -15,15 +15,26 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component + import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.height +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,9 +42,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,26 +53,21 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.ButtonGroup -import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GroupIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle2 +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource - +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -78,90 +84,114 @@ fun CollectionDetails( onShufflePlay: () -> Unit, onAddToQueue: () -> Unit, isPlaying: Boolean = false, + /** Guest in a jam session: play/shuffle are replaced by Add to Jam. */ + isJamGuest: Boolean = false, + onAddToJam: (() -> Unit)? = null, isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, - sharedElementKey: String? = null, -) { - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val isCompact = maxWidth < 600.dp + sharedElementKey: String? = null) { + Box(modifier = modifier.fillMaxWidth()) { + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val playPauseButton = @Composable { - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val modifier = if (isCompact) { - Modifier.weight(1f) - } else { - Modifier + if (isJamGuest) { + if (onAddToJam != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MillenniumButton( + modifier = if (isCompact) Modifier.weight(1f) else Modifier, + onClick = onAddToJam!!) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.track_options_add_to_jam)) + TextWithShimmer(text = stringResource(Res.string.track_options_add_to_jam)) + } + } } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val modifier = if (isCompact) { + Modifier.weight(1f) + } else { + Modifier + } - PrimaryButton( - modifier = modifier, - onClick = onPlay, - ) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - TextWithShimmer( - text = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(start = 6.dp), - ) - } - OutlineButton( - modifier = modifier, - onClick = onShufflePlay - ) { - Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer( - text = "Shuffle", - modifier = Modifier.padding(start = 6.dp), - ) + MillenniumButton( + modifier = modifier, + onClick = onPlay, + loading = isPlayLoading) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) + TextWithShimmer(text = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) + } + MillenniumButton( + modifier = modifier, + onClick = onShufflePlay, + loading = isShuffleLoading, + style = MillenniumButtonStyle.Secondary + ) { + MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play)) + TextWithShimmer(text = stringResource(Res.string.collection_details_shuffle)) + } } } } val actions = @Composable { - ButtonGroup { - GroupIconButton(onClick = onAddToQueue) { - Icon( + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + onClick = onAddToQueue, + loading = isAddToQueueLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue" + contentDescription = stringResource(Res.string.player_add_to_queue_content_description) ) } - ButtonGroupDivider() + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) if (showFollowButton) { if (isFollowing) { - GroupIconButton(onClick = onFollowClick) { - Icon( + MillenniumIconButton( + onClick = onFollowClick, + loading = isFollowLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxHeart2, - contentDescription = "Unfollow", - ) + contentDescription = stringResource(Res.string.collection_details_unfollow)) } } else { - GroupIconButton(onClick = onFollowClick) { - Icon( + MillenniumIconButton( + onClick = onFollowClick, + loading = isFollowLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxHeart, - contentDescription = "Follow", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + contentDescription = stringResource(Res.string.collection_details_follow), + tint = AppTheme.colors.onSurfaceVariant) } } } if (onEdit != null) { - ButtonGroupDivider() - GroupIconButton(onClick = onEdit) { - Icon( + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) + MillenniumIconButton( + onClick = onEdit, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxEdit, - contentDescription = "Edit playlist", - ) + contentDescription = stringResource(Res.string.collection_details_edit_playlist)) } } } @@ -170,8 +200,7 @@ fun CollectionDetails( Row( modifier = Modifier.clickable(onClick = onOwnerClick), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(10.dp)) { if (ownerImageURL != null) { AsyncImage( model = ownerImageURL, @@ -180,27 +209,24 @@ fun CollectionDetails( .size(16.dp) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.User, contentDescription = ownerName, modifier = Modifier .size(16.dp) .background( - MaterialTheme.colorScheme.primaryContainer, + AppTheme.colors.primaryContainer, CircleShape ) .padding(6.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) + tint = AppTheme.colors.onPrimaryContainer) } TextWithShimmer( - text = "By $ownerName", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = stringResource(Res.string.collection_details_by_owner, ownerName), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } } @@ -212,79 +238,68 @@ fun CollectionDetails( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) .shimmerApply(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (imageURL.isNotBlank()) { AsyncImage( model = imageURL, contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else if (imageResource != null) { androidx.compose.foundation.Image( painter = painterResource(imageResource), contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = title.take(1).ifBlank { "?" }.uppercase(), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.displaySmall, + color = AppTheme.colors.onSurfaceVariant) } } } - val metadata = @Composable { Column { TextWithShimmer( text = title, - style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge, + style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) if (description.isNotBlank()) { TextWithShimmer( text = description, - style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } - Card( + MillenniumCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - ) { + contentPadding = PaddingValues(0.dp)) { if (isCompact) { Column( modifier = Modifier .fillMaxWidth() .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { artwork() Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + verticalArrangement = Arrangement.spacedBy(6.dp)) { metadata() ownerInfo() actions() @@ -298,14 +313,12 @@ fun CollectionDetails( .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { artwork() Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { metadata() ownerInfo() actions() @@ -329,6 +342,5 @@ fun CollectionDetailsPreview() { onOwnerClick = {}, onPlay = {}, onShufflePlay = {}, - onAddToQueue = {}, - ) + onAddToQueue = {}) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt similarity index 63% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt index 02082d96..e29aa918 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/CollectionView.kt @@ -15,20 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component + import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -40,16 +37,23 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle2 import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun CollectionView( @@ -65,6 +69,10 @@ fun CollectionView( onAddToQueue: () -> Unit, isPlaying: Boolean = false, isFollowing: Boolean = false, + isFollowLoading: Boolean = false, + isPlayLoading: Boolean = false, + isShuffleLoading: Boolean = false, + isAddToQueueLoading: Boolean = false, onFollowClick: () -> Unit = {}, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, @@ -87,10 +95,13 @@ fun CollectionView( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, + isJamGuest: Boolean = false, + onAddToJam: (List) -> Unit = {}, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, - trailingContent: @Composable () -> Unit = {}, -) { + trailingContent: @Composable () -> Unit = {}) { val listState = rememberLazyListState() val isCollapsed by remember { derivedStateOf { @@ -99,55 +110,53 @@ fun CollectionView( } } - Scaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = if (isCollapsed) { - { - CollapsedCollectionTitle( - title = title, - imageURL = imageURL, - imageResource = imageResource, - ) + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = if (isCollapsed) { + { + CollapsedCollectionTitle( + title = title, + imageURL = imageURL, + imageResource = imageResource) + } + } else { + {} + }, + actions = if (isCollapsed && !isJamGuest) { + { + MillenniumIconButton( + onClick = onPlay, + loading = isPlayLoading, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) } - } else { - {} - }, - actions = if (isCollapsed) { - { - IconButton(onClick = onPlay) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - } - } - } else { - {} - }, - ) - } - ) { innerPadding -> + } + } else { + {} + }) + + Box(modifier = Modifier.weight(1f).fillMaxSize()) { if (error != null && tracks.isEmpty() && !isLoading) { ErrorDisplay( errorMessage = error, onRetry = onRetry ?: {}, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.fillMaxSize()) } else { TrackList( - modifier = Modifier.padding(innerPadding), + modifier = Modifier.fillMaxSize(), state = listState, headerContent = { if (isLoading && tracks.isEmpty()) { - dev.krtirtho.spotube.core.ui.misc.SkeletonTree(true) { + org.spotube.spotube.core.ui.misc.SkeletonTree(true) { CollectionDetails( - title = title.ifBlank { "Loading..." }, + title = title.ifBlank { stringResource(Res.string.common_loading) }, description = description, imageURL = imageURL, imageResource = imageResource, - ownerName = ownerName.ifBlank { "Unknown" }, + ownerName = ownerName.ifBlank { stringResource(Res.string.common_unknown) }, ownerImageURL = ownerImageURL, onOwnerClick = onOwnerClick, onPlay = onPlay, @@ -155,11 +164,16 @@ fun CollectionView( onAddToQueue = onAddToQueue, isPlaying = isPlaying, isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, onFollowClick = onFollowClick, showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, - ) + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }) } } else { CollectionDetails( @@ -175,11 +189,16 @@ fun CollectionView( onAddToQueue = onAddToQueue, isPlaying = isPlaying, isFollowing = isFollowing, + isFollowLoading = isFollowLoading, + isPlayLoading = isPlayLoading, + isShuffleLoading = isShuffleLoading, + isAddToQueueLoading = isAddToQueueLoading, onFollowClick = onFollowClick, showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, - ) + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }) } }, footerContent = footerContent, @@ -200,11 +219,14 @@ fun CollectionView( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, - trackOptionsState = trackOptionsState, - ) + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, + trackOptionsState = trackOptionsState) } trailingContent() + } } } @@ -212,46 +234,39 @@ fun CollectionView( private fun CollapsedCollectionTitle( title: String, imageURL: String, - imageResource: DrawableResource? = null, -) { + imageResource: DrawableResource? = null) { Row( - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .shimmerApply(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (imageURL.isNotBlank()) { AsyncImage( model = imageURL, contentDescription = title, modifier = Modifier.size(36.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else if (imageResource != null) { androidx.compose.foundation.Image( painter = painterResource(imageResource), contentDescription = title, modifier = Modifier.size(36.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = title.take(1).ifBlank { "?" }.uppercase(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 10.dp), - ) + modifier = Modifier.padding(start = 10.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt new file mode 100644 index 00000000..a5484b68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/ErrorDisplay.kt @@ -0,0 +1,266 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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 org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text + +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.koin.compose.viewmodel.koinViewModel +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.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import compose.icons.FeatherIcons +import compose.icons.feathericons.AlertCircle +import compose.icons.feathericons.Copy +import compose.icons.feathericons.Maximize2 +import compose.icons.feathericons.RefreshCw +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun ErrorDisplay( + errorMessage: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + authViewModel: MetadataAuthViewModel = koinViewModel()) { + val authState by authViewModel.uiState.collectAsStateWithLifecycle() + if (authState.loginRequired) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, + modifier = modifier) + return + } + var showFullLog by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp)) { + MillenniumIcon( + imageVector = FeatherIcons.AlertCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = AppTheme.colors.error.copy(alpha = 0.7f)) + + Text( + text = stringResource(Res.string.error_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = stringResource(Res.string.error_description), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2) + + MillenniumCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(0.dp)) { + Column( + modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(Res.string.error_details), + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurfaceVariant) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MillenniumIconButton( + onClick = { + clipboardManager.setText(AnnotatedString(errorMessage)) + }, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Copy, + contentDescription = stringResource(Res.string.error_copy_to_clipboard), + modifier = Modifier.size(16.dp)) + } + + MillenniumIconButton( + onClick = { showFullLog = true }, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Maximize2, + contentDescription = stringResource(Res.string.error_view_full_log), + modifier = Modifier.size(16.dp)) + } + } + } + + MillenniumCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(0.dp)) { + SelectionContainer { + Text( + text = errorMessage, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(12.dp)) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumButton( + onClick = onRetry, + modifier = Modifier.height(40.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.RefreshCw, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text(stringResource(Res.string.common_retry)) + } + } + } + } + + if (showFullLog) { + ErrorLogDialog( + errorMessage = errorMessage, + onDismiss = { showFullLog = false }) + } +} + +@Composable +private fun ErrorLogDialog( + errorMessage: String, + onDismiss: () -> Unit) { + val clipboardManager = LocalClipboardManager.current + + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle( + text = stringResource(Res.string.error_full_log_title), + textStyle = AppTheme.typography.titleMedium) + MillenniumIconButton( + onClick = { + clipboardManager.setText(AnnotatedString(errorMessage)) + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Copy, + contentDescription = stringResource(Res.string.error_copy_to_clipboard)) + } + MillenniumDialogClose() + } + MillenniumDialogBody(scrollable = false) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) { + SelectionContainer { + Text( + text = errorMessage, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp)) + } + } + } + MillenniumDialogFooter { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_close)) + } + } + } +} + +@Preview +@Composable +private fun ErrorLogDialogPreview() { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { + ErrorLogDialog( + errorMessage = "This is an error message", + onDismiss = {}) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt similarity index 72% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt index 810c69e4..6c768959 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/FeaturedCarousel.kt @@ -15,8 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -34,11 +38,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -62,29 +66,33 @@ import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.ui.coverflow.Coverflow -import dev.krtirtho.spotube.core.ui.coverflow.CoverflowParams -import dev.krtirtho.spotube.core.ui.coverflow.rememberCoverflowState -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle -import dev.krtirtho.spotube.resources.iconsax.Play +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.coverflow.Coverflow +import org.spotube.spotube.core.ui.coverflow.CoverflowParams +import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPlayCircle +import org.spotube.spotube.resources.iconsax.Play import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun FeaturedCarousel( items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val filteredItems = remember(items) { items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track } } @@ -92,20 +100,17 @@ fun FeaturedCarousel( if (filteredItems.isEmpty()) return FeaturedCarouselContent( - items = filteredItems, - ) + items = filteredItems) } @Composable private fun FeaturedCarouselContent( items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val centerIndex = items.size / 2 var selectedIndex by remember { mutableIntStateOf(centerIndex) } val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index } - val adaptiveInfo = currentWindowAdaptiveInfo() - val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600 + val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium Box( modifier = modifier @@ -113,8 +118,7 @@ private fun FeaturedCarouselContent( .height( if (isSmallScreen) 200.dp else 300.dp ), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Coverflow( state = state, params = CoverflowParams( @@ -123,21 +127,17 @@ private fun FeaturedCarouselContent( angle = 12f, shift = 0.255f, zoom = 0.7864f, - mirror = false, - ), - modifier = modifier, - ) { + mirror = false), + modifier = modifier) { items( items = items, - key = { index: Int -> items[index].hashCode() }, - ) { item: MetadataBrowseItem -> + key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem -> FeaturedCarouselCard( item = item, isCentered = selectedIndex == items.indexOf(item), onScrollToCenter = { state.scrollToItem(items.indexOf(item)) - }, - ) + }) } } } @@ -148,8 +148,7 @@ private fun FeaturedCarouselCard( item: MetadataBrowseItem, isCentered: Boolean, modifier: Modifier = Modifier, - onScrollToCenter: () -> Unit = { }, -) { + onScrollToCenter: () -> Unit = { }) { val navigationCommands = koinInject() val audioPlayerQueue = koinInject() @@ -172,7 +171,7 @@ private fun FeaturedCarouselCard( val subtitle = when (item) { is MetadataBrowseItem.Album -> item.data.artists.joinToString { it.name } is MetadataBrowseItem.Playlist -> item.data.owner?.displayName ?: item.data.owner?.username - ?: "Playlist" + ?: stringResource(Res.string.media_playlist) is MetadataBrowseItem.Track -> item.data.artists.joinToString { it.name } else -> "" @@ -204,25 +203,21 @@ private fun FeaturedCarouselCard( } } .aspectRatio(1f) - .hazeSource(hazeState), - ) { + .hazeSource(hazeState)) { AsyncImage( model = thumbnailUrl, contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = dimAlpha)), - ) + .background(Color.Black.copy(alpha = dimAlpha))) Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { + contentAlignment = Alignment.BottomCenter) { Box( modifier = Modifier .fillMaxWidth() @@ -231,43 +226,35 @@ private fun FeaturedCarouselCard( brush = Brush.verticalGradient( colors = listOf( Color.Transparent, - Color.Black.copy(alpha = 0.9f), - ), - ), - ), - ) + Color.Black.copy(alpha = 0.9f))))) } } Box( modifier = Modifier .fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { + contentAlignment = Alignment.BottomCenter) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .padding(16.dp), - ) { + .padding(16.dp)) { Column(modifier = Modifier.weight(1f)) { Text( text = title, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, + style = AppTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.85f), maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } val animatedAlpha by animateFloatAsState( @@ -304,12 +291,12 @@ private fun FeaturedPlayButton( item: MetadataBrowseItem, audioPlayerQueue: AudioPlayerQueue, navigationCommands: NavigationCommands, - playbackHelper: CollectionPlaybackHelper = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), enabled: Boolean, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() when (item) { is MetadataBrowseItem.Album -> { @@ -320,17 +307,26 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { if (isPlaying) { navigationCommands.navigateTo(Routes.Album(item.data.id)) } else { - scope.launch { playbackHelper.playAlbum(item.data.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + item.data.id, + item.data.title + ) } }, - enabled = enabled + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Album, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White @@ -347,17 +343,26 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { if (isPlaying) { navigationCommands.navigateTo(Routes.Playlist(item.data.id)) } else { - scope.launch { playbackHelper.playPlaylist(item.data.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + item.data.id, + item.data.title + ) } }, - enabled = enabled + enabled = enabled, + loading = CollectionPlaybackKey( + RemoteCollectionType.Playlist, item.data.id, PlaybackDestinationAction.Play + ) in busyCollections, + foreground = Color.White ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White @@ -377,20 +382,20 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { scope.launch { audioPlayerQueue.load( entries = listOf(queueEntry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } }, enabled = enabled ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt new file mode 100644 index 00000000..98439207 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LikedTracksCard.kt @@ -0,0 +1,208 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle +import org.spotube.spotube.resources.iconsax.Play +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.koinInject +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import spotube.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.component.cards.MediaCardSurface +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun LikedTracksCard( + modifier: Modifier = Modifier, + audioPlayerQueue: AudioPlayerQueue = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), + navigationCommands: NavigationCommands = koinInject(), + listMode: Boolean = false +) { + val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() + val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks + val title = stringResource(Res.string.media_liked_tracks) + val playLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.Play + ) in busyCollections + val addToQueueLoading = CollectionPlaybackKey( + RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.AddToQueue + ) in busyCollections + val onPlay = { + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } + val onAddToQueue = { + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title) + } + val subtitle = stringResource(Res.string.media_saved_songs) + if (listMode) { + PlayableListTile( + title = title, + subtitle = subtitle, + isPlaying = isPlaying, + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, + onPlay = onPlay, + onAddToQueue = onAddToQueue, + modifier = modifier, + sharedElementKey = "saved_tracks_art", + playLoading = playLoading, + addToQueueLoading = addToQueueLoading, + artwork = { + Image( + painterResource(Res.drawable.liked_tracks), + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch + + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) } + ) { isHovered -> + Column(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.fillMaxWidth()) { + androidx.compose.foundation.Image( + painter = painterResource(Res.drawable.liked_tracks), + contentDescription = title, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .sharedElementOrNone( + key = "saved_tracks_art", + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope + ), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), + contentAlignment = Alignment.BottomEnd + ) { + if (isHovered || touchInput || isPlaying || playLoading || addToQueueLoading) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(4.dp) + ) { + MillenniumIconButton( + onClick = onAddToQueue, + loading = addToQueueLoading, + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.player_add_to_queue_content_description), + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + MillenniumIconButton( + onClick = onPlay, + loading = playLoading, + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = if (isPlaying) { + Iconsax.IconsaxPauseCircle + } else Iconsax.Play, + contentDescription = stringResource(Res.string.player_play_content_description), + modifier = Modifier.size(16.dp), + tint = LocalTextStyle.current.color + ) + } + } + } + } + } + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp) + ) { + Text( + text = title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt new file mode 100644 index 00000000..f65f5b9a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/LoginRequiredDisplay.kt @@ -0,0 +1,103 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.Lock +import compose.icons.feathericons.LogIn +import org.spotube.spotube.core.ui.theming.AppTheme +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.* + +/** Friendly "sign in first" state for screens whose metadata plugin needs an account. */ +@Composable +fun LoginRequiredDisplay( + pluginName: String?, + onLogin: () -> Unit, + modifier: Modifier = Modifier, + isLoggingIn: Boolean = false) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Box( + modifier = Modifier + .size(88.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = FeatherIcons.Lock, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = AppTheme.colors.primary) + } + + Text( + text = stringResource(Res.string.login_required_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = if (pluginName.isNullOrBlank()) { + stringResource(Res.string.login_required_description) + } else { + stringResource(Res.string.login_required_description_plugin, pluginName) + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + + MillenniumButton( + onClick = onLogin, + loading = isLoggingIn, + modifier = Modifier.height(44.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.LogIn, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text(stringResource(Res.string.login_required_action)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt similarity index 54% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt index 1bca2308..ecb12b79 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/PlaylistCard.kt @@ -15,19 +15,23 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackKey +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -37,10 +41,13 @@ fun PlaylistCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false ) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() + val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle() PlayableCard( title = playlist.title, @@ -52,12 +59,23 @@ fun PlaylistCard( }, onPlay = { if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard - scope.launch { playbackHelper.playPlaylist(playlist.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title + ) }, onAddToQueue = { - scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title + ) }, modifier = modifier, sharedElementKey = "playlist_art_${playlist.id}", + listMode = listMode, + playLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.Play) in busyCollections, + addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.AddToQueue) in busyCollections ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt similarity index 90% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt index 64c05db0..a37ef31b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/SharedTransitionLocals.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf fun Modifier.sharedElementOrNone( key: String?, sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, -): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this with(sharedTransitionScope) { return this@sharedElementOrNone.sharedElement( sharedContentState = rememberSharedContentState(key = key), - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt index 4d81fd73..d1862b48 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,12 +26,9 @@ 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.material3.Card -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -43,10 +40,10 @@ import coil3.compose.AsyncImage import compose.icons.FeatherIcons import compose.icons.feathericons.Play import compose.icons.feathericons.PlusSquare -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -54,8 +51,7 @@ import org.koin.compose.koinInject fun TrackCard( track: MetadataTrack, modifier: Modifier = Modifier, - audioPlayerQueue: AudioPlayerQueue = koinInject(), -) { + audioPlayerQueue: AudioPlayerQueue = koinInject()) { val scope = rememberCoroutineScope() val queueEntry = QueueEntry.StreamingTrack(track = track, url = "") @@ -69,8 +65,7 @@ fun TrackCard( entries = listOf(queueEntry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } }, onAddToQueue = { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt new file mode 100644 index 00000000..24969413 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt @@ -0,0 +1,1026 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + + + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.state.ToggleableState +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumCheckbox +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTriStateCheckbox + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.ui.misc.uniqueLazyKeys +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlaylist +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxSort +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +private val CompactTrackListBreakpoint = 600.dp +private val LargeTrackListBreakpoint = 840.dp +private const val ShimmerRowCount = 6 + +private val TrackCardOuterPadding = 8.dp +private val TrackCardInnerPadding = 12.dp +private const val TrackCardTopKey = "track-card-top" +private const val TrackCardRowKeyPrefix = "track-card-row:" +private const val TrackCardFooterKey = "track-card-footer" +private const val TrackCardBottomKey = "track-card-bottom" + +private fun Any.isTrackCardKey() = this is String && startsWith("track-card-") + +/** + * Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that + * are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded + * corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists. + */ +private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier = + layout { measurable, constraints -> + val info = listState.layoutInfo + val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() } + if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {} + + // Item offsets are relative to the viewport start, which is negative by the top padding. + val toLocal = -info.viewportStartOffset + val viewport = info.viewportEndOffset - info.viewportStartOffset + val overscan = viewport * 3 + val first = cardItems.first() + val last = cardItems.last() + val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan + val bottom = if (last.key == TrackCardBottomKey) { + last.offset + last.size + toLocal - edgeInsetPx + } else viewport + overscan + val height = (bottom - top).coerceAtLeast(0) + val placeable = measurable.measure( + constraints.copy(minHeight = height, maxHeight = height)) + layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) } + } + +enum class TrackSortOption(val label: StringResource) { + None(Res.string.track_list_sort_original), + Title(Res.string.track_list_sort_title), + Artist(Res.string.track_list_sort_artist), + Album(Res.string.track_list_sort_album), + Duration(Res.string.track_list_sort_duration), +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TrackList( + tracks: List = emptyList(), + error: String? = null, + hasMore: Boolean = false, + isLoading: Boolean = false, + isLoadingNextPage: Boolean = false, + showEmptyMessage: Boolean = true, + headerContent: (@Composable () -> Unit)? = null, + footerContent: (@Composable () -> Unit)? = null, + onLoadNextPage: () -> Unit = {}, + onTrackClick: (MetadataTrack) -> Unit = {}, + onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> }, + onArtistClick: (MetadataArtist.Basic) -> Unit = {}, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {}, + onArtistsOverflowClick: (MetadataTrack) -> Unit = {}, + onBulkDownload: (List) -> Unit = {}, + onBulkAddToQueue: (List) -> Unit = {}, + onBulkPlayNext: (List) -> Unit = {}, + onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, + isJamGuest: Boolean = false, + currentTrackId: String? = null, + isCurrentTrackPlaying: Boolean = false, + trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), + simplified: Boolean = false, + scrollable: Boolean = true, + state: LazyListState? = null) { + val filterState = rememberTextFieldState() + val filterQuery = filterState.text.toString() + var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } + var selectedTrackForOptions by remember { mutableStateOf(null) } + var isSelectionMode by rememberSaveable { mutableStateOf(false) } + var selectedTrackIds by rememberSaveable { mutableStateOf>(emptySet()) } + + val normalizedQuery = remember(filterQuery) { filterQuery.trim().lowercase() } + val visibleTracks = remember(tracks, normalizedQuery, sortBy) { + val filtered = if (normalizedQuery.isBlank()) { + tracks + } else { + tracks.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) + } + } + + when (sortBy) { + TrackSortOption.None -> filtered + TrackSortOption.Title -> filtered.sortedBy { it.title.lowercase() } + TrackSortOption.Artist -> filtered.sortedBy { + it.artists.firstOrNull()?.name?.lowercase().orEmpty() + } + + TrackSortOption.Album -> filtered.sortedBy { it.album?.title?.lowercase() } + TrackSortOption.Duration -> filtered.sortedBy { it.durationMs } + } + } + + val listState = state ?: rememberLazyListState() + val density = LocalDensity.current + val shouldLoadMore = remember(density) { + derivedStateOf { + val loadMoreThreshold = with(density) { 200.dp.toPx() } + val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey } + // Trigger when the bottom of the track card is close to/inside the viewport, + // which means the user has scrolled through (or is about to scroll through) + // all loaded tracks. + cardItem != null && cardItem.offset + cardItem.size <= listState.layoutInfo.viewportEndOffset + loadMoreThreshold + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value && hasMore && !isLoading && !isLoadingNextPage) { + onLoadNextPage() + } + } + + val widthBreakpoint = currentWindowWidthBreakpoint() + val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium + val isDesktop = remember { getPlatform().isDesktop() } + val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded + val showAlbum = !isCompact + val useDropdownForOptions = isDesktop && !isCompact + + val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track -> + TrackListRow( + index = displayedIndex + 1, + track = track, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + isCurrentTrack = track.id == currentTrackId, + // Only the current row depends on it, so play/pause/buffering flips don't recompose every row. + isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId, + isSelectionMode = isSelectionMode, + isSelected = selectedTrackIds.contains(track.id), + onTrackClick = { + if (isSelectionMode) { + selectedTrackIds = + if (selectedTrackIds.contains(track.id)) { + selectedTrackIds - track.id + } else { + selectedTrackIds + track.id + } + } else if (isJamGuest) { + onTrackOptionsAction(track, TrackOptionsAction.AddToJam) + } else { + onTrackClick(track) + } + }, + onLongClick = { + if (!useDropdownForOptions && !isSelectionMode) { + isSelectionMode = true + selectedTrackIds = setOf(track.id) + } else if (!useDropdownForOptions) { + selectedTrackForOptions = track + } + }, + onSelectionToggle = { checked -> + isSelectionMode = true + selectedTrackIds = if (checked) { + selectedTrackIds + track.id + } else { + selectedTrackIds - track.id + } + }, + onTrackOptionsAction = { action -> + onTrackOptionsAction( + track, + action + ) + }, + trackOptionsState = trackOptionsState(track), + isInJam = isInJam, + isJamGuest = isJamGuest, + onShowOptionsClick = { selectedTrackForOptions = track }, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = { onArtistsOverflowClick(track) }) + } + + // Shimmer, error, empty and next-page rows at the end of the card. + val trackCardFooter: @Composable () -> Unit = { + if (isLoading && tracks.isEmpty()) { + repeat(ShimmerRowCount) { shimmerIndex -> + if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + ShimmerTrackListRow( + index = shimmerIndex + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions) + } + } + + if (error != null) { + if (visibleTracks.isNotEmpty()) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + TrackListFeedbackRow( + message = error, + isError = true) + } + + if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { + TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks)) + } + + if (isLoadingNextPage) { + if (visibleTracks.isNotEmpty()) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + ShimmerTrackListRow( + index = visibleTracks.size + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions) + } + } + + val trackCardContent: @Composable () -> Unit = { + MillenniumCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TrackCardOuterPadding), + contentPadding = PaddingValues(0.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding) + ) { + visibleTracks.forEachIndexed { displayedIndex, track -> + if (displayedIndex > 0) { + MillenniumSeparator() + } + trackRow(displayedIndex, track) + } + trackCardFooter() + } + } + } + + val filterSortRow: @Composable () -> Unit = { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + .heightIn(max = 60.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) { + if (showIndex || isSelectionMode) { + val allSelected = + visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size + val anySelected = selectedTrackIds.isNotEmpty() + val headerState = when { + allSelected -> ToggleableState.On + anySelected -> ToggleableState.Indeterminate + else -> ToggleableState.Off + } + MillenniumTriStateCheckbox( + value = headerState, + onClick = { + if (allSelected) { + isSelectionMode = false + selectedTrackIds = emptySet() + } else { + isSelectionMode = true + selectedTrackIds = visibleTracks.map { it.id }.toSet() + } + }, + label = "") + } + + Box( + modifier = Modifier + .weight(1f) + ) { + MillenniumTextField( + state = filterState, + label = "", + placeholder = stringResource(Res.string.track_list_filter_placeholder), + modifier = Modifier + .widthIn(max = 400.dp) + .align(Alignment.CenterEnd)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + contentAlignment = Alignment.Center) { + AdaptiveDropdownBottomSheet( + items = TrackSortOption.entries.map { option -> + AdaptiveMenuItem( + label = stringResource(option.label), + onClick = { sortBy = option }, + selected = sortBy == option) + }, + trigger = { onClick -> + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxSort, + contentDescription = stringResource(Res.string.track_list_sort)) + } + }) + } + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) + Box( + contentAlignment = Alignment.Center) { + val targetTracks = if (selectedTrackIds.isNotEmpty()) { + visibleTracks.filter { selectedTrackIds.contains(it.id) } + } else { + visibleTracks + } + val trackCount = targetTracks.size + val isAll = + selectedTrackIds.isEmpty() || trackCount == visibleTracks.size + AdaptiveDropdownBottomSheet( + items = buildList { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxDirectboxReceive, + label = if (isAll) stringResource(Res.string.track_list_download_all) else stringResource(Res.string.track_list_download_count, trackCount), + onClick = { onBulkDownload(targetTracks) })) + if (!isJamGuest) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) stringResource(Res.string.track_list_add_all_to_queue) else stringResource(Res.string.track_list_add_count_to_queue, trackCount), + onClick = { onBulkAddToQueue(targetTracks) })) + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = if (isAll) stringResource(Res.string.track_list_play_all_next) else stringResource(Res.string.track_list_play_count_next, trackCount), + onClick = { onBulkPlayNext(targetTracks) })) + } + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = if (isAll) stringResource(Res.string.track_list_add_all_to_playlist) else stringResource(Res.string.track_list_add_count_to_playlist, trackCount), + onClick = { onBulkAddToPlaylist(targetTracks) })) + } + if (isInJam) { + listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) stringResource(Res.string.track_list_add_all_to_jam) else stringResource(Res.string.track_list_add_count_to_jam, trackCount), + onClick = { onBulkAddToJam(targetTracks) })) + } else { + emptyList() + }, + trigger = { onClick -> + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.track_list_bulk_actions)) + } + }) + } + } + } + } + + if (scrollable) { + Box(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter) + .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) { + // Rows are separate lazy items (only on-screen ones are composed, #3200), so the card + // they sit on is drawn once behind the list, spanning the card's laid-out items. + MillenniumCard( + modifier = Modifier + .matchParentSize() + .trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }), + contentPadding = PaddingValues(0.dp)) {} + // No arrangement spacing: it would also open gaps between the card's rows. The 2.dp + // gaps around the card are applied to the items before and after it instead. + LazyColumn( + state = listState, + contentPadding = contentPadding) { + if (headerContent != null) { + item { + Box(Modifier.padding(bottom = 2.dp)) { headerContent() } + } + } + if (!simplified) { + item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } } + } + item(key = TrackCardTopKey) { + Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding)) + } + // Plugin ids can repeat (the same track twice in a playlist). + val rowKeys = uniqueLazyKeys(visibleTracks) { it.id } + items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex -> + Column(Modifier.fillMaxWidth()) { + if (displayedIndex > 0) { + MillenniumSeparator() + } + trackRow(displayedIndex, visibleTracks[displayedIndex]) + } + } + item(key = TrackCardFooterKey) { + Column(Modifier.fillMaxWidth()) { trackCardFooter() } + } + item(key = TrackCardBottomKey) { + Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding)) + } + if (footerContent != null) { + item { + Box(Modifier.padding(top = 2.dp)) { footerContent() } + } + } + } + } + + if (!useDropdownForOptions) { + selectedTrackForOptions?.let { track -> + TrackOptionsBottomSheet( + track = track, + state = trackOptionsState(track), + onDismiss = { selectedTrackForOptions = null }, + onAction = { action -> + onTrackOptionsAction(track, action) + selectedTrackForOptions = null + }, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest) + } + } + VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) + } + } else { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (headerContent != null) { + headerContent() + } + if (!simplified) { + filterSortRow() + } + trackCardContent() + if (footerContent != null) { + footerContent() + } + } + + if (!useDropdownForOptions) { + selectedTrackForOptions?.let { track -> + TrackOptionsBottomSheet( + track = track, + state = trackOptionsState(track), + onDismiss = { selectedTrackForOptions = null }, + onAction = { action -> + onTrackOptionsAction(track, action) + selectedTrackForOptions = null + }, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TrackListRow( + index: Int, + track: MetadataTrack, + showIndex: Boolean, + showAlbum: Boolean, + useDropdownForOptions: Boolean, + isCurrentTrack: Boolean, + isCurrentTrackPlaying: Boolean, + isSelectionMode: Boolean, + isSelected: Boolean, + onTrackClick: () -> Unit, + onLongClick: () -> Unit, + onSelectionToggle: (Boolean) -> Unit, + onTrackOptionsAction: (TrackOptionsAction) -> Unit, + trackOptionsState: TrackOptionsState, + isInJam: Boolean, + isJamGuest: Boolean, + onShowOptionsClick: () -> Unit, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onArtistsOverflowClick: () -> Unit, + modifier: Modifier = Modifier) { + val artworkInteractionSource = remember { MutableInteractionSource() } + val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() + val rowInteractionSource = remember { MutableInteractionSource() } + val isRowHovered by rowInteractionSource.collectIsHoveredAsState() + val rowBackgroundColor = when { + isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f) + isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer + isCurrentTrack -> AppTheme.colors.surfaceVariant + trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f) + else -> Color.Transparent + } + + MillenniumListTile( + modifier = modifier, + selected = isSelected || isCurrentTrack, + onClick = onTrackClick, + title = { + TextWithShimmer( + text = track.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + }, + subtitle = { + ArtistChips( + artists = track.artists, + onArtistClick = onArtistClick, + onArtistsOverflowClick = onArtistsOverflowClick) + }, + leading = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (isSelectionMode) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) + } else if (showIndex) { + Box( + modifier = Modifier.width(30.dp), + contentAlignment = Alignment.Center) { + AnimatedContent( + targetState = isRowHovered, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "index-checkbox") { hovered -> + if (hovered) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) + } else { + TextWithShimmer( + text = index.toString(), + style = AppTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = AppTheme.colors.onSurfaceVariant) + } + } + } + } + + Box( + modifier = Modifier + .size(44.dp) + .clip(AppTheme.shapes.small) + .hoverable(interactionSource = artworkInteractionSource), + contentAlignment = Alignment.Center) { + val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url + val platformContext = LocalPlatformContext.current + val imageRequest = remember(imageUrl) { + ImageRequest.Builder(platformContext) + .data(imageUrl) + .size(128) + .crossfade(false) + .build() + } + + AsyncImage( + model = imageRequest, + contentDescription = track.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().shimmerApply()) + + if (isArtworkHovered || isCurrentTrack) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description), + tint = Color.White, + modifier = Modifier.size(18.dp).shimmerApply()) + } + } + } + } + }, + trailing = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (showAlbum && track.album != null) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val colorScheme = AppTheme.colors + + TextWithShimmer( + text = track.album!!.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(max = 220.dp) + .hoverable(interactionSource) + .clickable( + indication = null, + interactionSource = interactionSource) { onAlbumClick(track.album!!) } + .then( + if (isHovered || isPressed) { + Modifier.drawWithCache { + val underlineHeight = 1.dp.toPx() + onDrawWithContent { + drawContent() + drawLine( + color = colorScheme.primary, + start = Offset(0f, size.height - underlineHeight), + end = Offset(size.width, size.height - underlineHeight), + strokeWidth = underlineHeight) + } + } + } else { + Modifier + })) + } + + TextWithShimmer( + text = track.durationMs.toDurationString(), + style = AppTheme.typography.bodySmall, + textAlign = TextAlign.End, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.width(56.dp)) + + if (useDropdownForOptions) { + TrackOptions( + track = track, + state = trackOptionsState, + onAction = onTrackOptionsAction, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest) + } else { + MillenniumIconButton( + onClick = onShowOptionsClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.track_options_title)) + } + } + } + }) +} + +@Composable +private fun ArtistChips( + artists: List, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onArtistsOverflowClick: () -> Unit) { + val visibleArtists = artists.take(2) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth()) { + visibleArtists.forEachIndexed { index, artist -> + Row( + verticalAlignment = Alignment.CenterVertically) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + + TextWithShimmer( + text = artist.name, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, + modifier = Modifier + .clickable( + indication = null, + interactionSource = interactionSource + ) { onArtistClick(artist) } + ) + if (index < visibleArtists.size - 1) + Text( + ",", + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodySmall + ) + } + } + + val remaining = artists.size - visibleArtists.size + if (remaining > 0) { + TextWithShimmer( + text = stringResource(Res.string.track_list_more_artists, remaining), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable(onClick = onArtistsOverflowClick)) + } + } +} + +@Composable +private fun ShimmerTrackListRow( + index: Int, + showIndex: Boolean, + showAlbum: Boolean, + useDropdownForOptions: Boolean) { + val dummyTrack = remember(index) { dummyTrackForShimmer(index) } + SkeletonTree(true) { + TrackListRow( + index = index, + track = dummyTrack, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + isCurrentTrack = false, + isCurrentTrackPlaying = false, + isSelectionMode = false, + isSelected = false, + onTrackClick = {}, + onLongClick = {}, + onSelectionToggle = {}, + onTrackOptionsAction = {}, + trackOptionsState = TrackOptionsState(), + isInJam = false, + isJamGuest = false, + onShowOptionsClick = {}, + onArtistClick = {}, + onAlbumClick = {}, + onArtistsOverflowClick = {}) + } +} + +private fun dummyTrackForShimmer(index: Int): MetadataTrack { + val artist = MetadataArtist.Basic( + id = "shimmer_artist_$index", + name = "Artist Name", + thumbnails = emptyList(), + externalUri = null) + return MetadataTrack( + id = "shimmer_track_$index", + title = "Track Title Here", + durationMs = 210_000, + trackNumber = index, + discNumber = 1, + artists = listOf( + artist, + artist.copy(id = "shimmer_artist_${index}_2", name = "Another Artist") + ), + album = MetadataAlbum.Detailed( + releaseDate = null, + genres = emptyList(), + trackCount = 10, + id = "shimmer_album_$index", + title = "Album Name", + description = null, + thumbnails = emptyList(), + albumType = MetadataAlbumType.Album, + artists = listOf(artist), + externalUri = null), + thumbnails = null, + explicit = false, + popularity = 0, + isrcCode = null, + externalUri = null) +} + +@Composable +private fun TrackListFeedbackRow( + message: String, + isError: Boolean = false) { + TextWithShimmer( + text = message, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp)) +} + +private fun Long.toDurationString(): String { + val totalSeconds = (this / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} + +fun previewTracks(): List { + val artists = listOf( + MetadataArtist.Basic( + id = "artist_1", + name = "Aria Vale", + thumbnails = emptyList(), + externalUri = null), + MetadataArtist.Basic( + id = "artist_2", + name = "Neon Harbor", + thumbnails = emptyList(), + externalUri = null), + MetadataArtist.Basic( + id = "artist_3", + name = "Echo Drift", + thumbnails = emptyList(), + externalUri = null)) + + val album = MetadataAlbum.Detailed( + releaseDate = null, + genres = emptyList(), + trackCount = 10, + id = "album_1", + title = "Night Drive Archives", + description = null, + thumbnails = listOf( + Thumbnail( + url = "https://picsum.photos/120", + width = 120, + height = 120) + ), + albumType = MetadataAlbumType.Album, + artists = artists.take(2), + externalUri = null) + + return listOf( + MetadataTrack( + id = "track_1", + title = "Signal Bloom", + durationMs = 198_000, + trackNumber = 1, + discNumber = 1, + artists = artists, + album = album, + thumbnails = null, + explicit = false, + popularity = 92, + isrcCode = null, + externalUri = null), + MetadataTrack( + id = "track_2", + title = "Static Horizon", + durationMs = 224_000, + trackNumber = 2, + discNumber = 1, + artists = artists.take(2), + album = album.copy(id = "album_2", title = "City Pulse"), + explicit = false, + popularity = 80, + isrcCode = null, + externalUri = null, + thumbnails = null), + MetadataTrack( + id = "track_3", + title = "Last Train Echo", + durationMs = 246_000, + trackNumber = 3, + discNumber = 1, + artists = artists.take(1), + album = album.copy(id = "album_3", title = "Afterlight"), + explicit = false, + popularity = 77, + isrcCode = null, + externalUri = null, + thumbnails = null)) +} + +@Composable +@Preview +private fun TrackListPreview() { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { + TrackList( + tracks = previewTracks(), + hasMore = true, + isLoadingNextPage = true) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt similarity index 53% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt index c401e631..1b7217c9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackOptions.kt @@ -15,21 +15,29 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -37,25 +45,26 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxMusicCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlaylist +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxShare +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* sealed interface TrackOptionsAction { + data object AddToJam : TrackOptionsAction data object StartRadio : TrackOptionsAction data object PlayNext : TrackOptionsAction data object AddToQueue : TrackOptionsAction @@ -71,8 +80,7 @@ data class TrackOptionsState( val isInQueue: Boolean = false, val isCurrentlyPlaying: Boolean = false, val isFavorite: Boolean = false, - val isBlacklisted: Boolean = false, -) + val isBlacklisted: Boolean = false) data class TrackOptionsContext( val currentTrackId: String? = null, @@ -80,15 +88,13 @@ data class TrackOptionsContext( val savedTrackIds: Set = emptySet(), val blacklistedTrackIds: Set = emptySet(), val blacklistedArtistIds: Set = emptySet(), - val forceFavorite: Boolean = false, -) { + val forceFavorite: Boolean = false) { fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState( isInQueue = track.id in queueTrackIds, isCurrentlyPlaying = track.id == currentTrackId, isFavorite = forceFavorite || track.id in savedTrackIds, isBlacklisted = track.id in blacklistedTrackIds || - track.artists.any { it.id in blacklistedArtistIds }, - ) + track.artists.any { it.id in blacklistedArtistIds }) } @Composable @@ -98,30 +104,32 @@ fun TrackOptions( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, modifier: Modifier = Modifier, -) { + isInJam: Boolean = false, + isJamGuest: Boolean = false) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( track = track, state = state, onAction = onAction, onAlbumClick = onAlbumClick, - ), + isInJam = isInJam, + isJamGuest = isJamGuest), trigger = { onClick -> - GhostIconButton(onClick = onClick) { - Icon( + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Track options", + contentDescription = stringResource(Res.string.track_options_title), modifier = Modifier.shimmerApply() ) } }, headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, header = { TrackOptionsSheetHeader(track = track) }, - modifier = modifier, - ) + modifier = modifier) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrackOptionsBottomSheet( track: MetadataTrack, @@ -129,8 +137,18 @@ fun TrackOptionsBottomSheet( onDismiss: () -> Unit, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, -) { - ModalBottomSheet(onDismissRequest = onDismiss) { + isInJam: Boolean = false, + isJamGuest: Boolean = false) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp), + contentSpacing = 4.dp) { Column(modifier = Modifier.fillMaxWidth()) { TrackOptionsSheetHeader(track = track) @@ -138,8 +156,7 @@ fun TrackOptionsBottomSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { buildTrackMenuItems( track = track, state = state, @@ -151,39 +168,36 @@ fun TrackOptionsBottomSheet( onAlbumClick() onDismiss() }, - ).forEach { item -> + isInJam = isInJam, + isJamGuest = isJamGuest).forEach { item -> Row( modifier = Modifier .fillMaxWidth() .clickable( enabled = item.enabled, - onClick = item.onClick, - ) + onClick = item.onClick) .padding(horizontal = 12.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { item.icon?.let { icon -> - Icon( + MillenniumIcon( imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) + } ?: Spacer(modifier = Modifier.size(22.dp)) Text( text = item.label, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, color = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) } } } @@ -198,143 +212,130 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) { .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { AsyncImage( model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) - .clip(MaterialTheme.shapes.small), - ) + .clip(AppTheme.shapes.small)) Column(modifier = Modifier.weight(1f)) { Text( text = track.title, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = track.artists.joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) track.album?.let { album -> Text( text = album.title, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } } +@Composable private fun buildTrackMenuItems( track: MetadataTrack, state: TrackOptionsState, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, -): List = buildList { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicCircle, - label = "Start radio", - onClick = { onAction(TrackOptionsAction.StartRadio) }, - ), - ) - - if (!state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Play next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } else if (state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Move to next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } - - if (!state.isInQueue) { + isInJam: Boolean = false, + isJamGuest: Boolean = false): List = buildList { + if (isInJam) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, - label = "Add to queue", - onClick = { onAction(TrackOptionsAction.AddToQueue) }, - ), - ) - } else { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicSquareRemove, - label = "Remove from queue", - onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, - ), - ) + label = stringResource(Res.string.track_options_add_to_jam), + onClick = { onAction(TrackOptionsAction.AddToJam) })) + } + + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicCircle, + label = stringResource(Res.string.track_options_start_radio), + onClick = { onAction(TrackOptionsAction.StartRadio) })) + + // A guest's queue is the shared jam queue — mutating it locally is not + // allowed, so queue actions are replaced by "Add to Jam". + if (!isJamGuest) { + if (!state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = stringResource(Res.string.track_options_play_next), + onClick = { onAction(TrackOptionsAction.PlayNext) })) + } else if (state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = stringResource(Res.string.track_options_move_to_next), + onClick = { onAction(TrackOptionsAction.PlayNext) })) + } + + if (!state.isInQueue) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = stringResource(Res.string.player_add_to_queue_content_description), + onClick = { onAction(TrackOptionsAction.AddToQueue) })) + } else { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicSquareRemove, + label = stringResource(Res.string.track_options_remove_from_queue), + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) })) + } } add( AdaptiveMenuItem( icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, - label = if (state.isFavorite) "Remove from favorites" else "Save as favorite", - onClick = { onAction(TrackOptionsAction.ToggleFavorite) }, - ), - ) + label = if (state.isFavorite) stringResource(Res.string.track_options_remove_from_favorites) else stringResource(Res.string.track_options_save_as_favorite), + onClick = { onAction(TrackOptionsAction.ToggleFavorite) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicPlaylist, - label = "Add to playlist", - onClick = { onAction(TrackOptionsAction.AddToPlaylist) }, - ), - ) + label = stringResource(Res.string.track_options_add_to_playlist), + onClick = { onAction(TrackOptionsAction.AddToPlaylist) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, - label = "Download", - onClick = { onAction(TrackOptionsAction.Download) }, - ), - ) + label = stringResource(Res.string.track_options_download), + onClick = { onAction(TrackOptionsAction.Download) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicSquareRemoveFilled, - label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", - onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }, - ), - ) + label = if (state.isBlacklisted) stringResource(Res.string.blacklist_remove) else stringResource(Res.string.blacklist_add), + onClick = { onAction(TrackOptionsAction.ToggleBlacklist) })) if (!track.externalUri.isNullOrBlank()) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxShare, - label = "Share", - onClick = { onAction(TrackOptionsAction.Share) }, - ), - ) + label = stringResource(Res.string.track_options_share), + onClick = { onAction(TrackOptionsAction.Share) })) } if (track.album != null) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxCd, - label = "Go to album", - onClick = onAlbumClick, - ), - ) + label = stringResource(Res.string.player_go_to_album), + onClick = onAlbumClick)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt index 1b4b9b13..5213cc0c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/UserCard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,22 +38,22 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun UserCard( user: MetadataUser, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { AvatarCard( title = user.displayName ?: user.username, - subtitle = "User", + subtitle = stringResource(Res.string.media_user), imageURL = user.thumbnails.firstOrNull()?.url, onClick = { // TODO: Route to user details screen. }, - modifier = modifier, - ) + modifier = modifier) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt index 2522f276..33b63559 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.kt @@ -15,21 +15,20 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.isDesktop +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop @Composable expect fun VerticalScrollbar( listState: LazyListState, - modifier: Modifier = Modifier, -) + modifier: Modifier = Modifier) fun Modifier.dragScrollable(rowState: LazyListState): Modifier = if (getPlatform().isDesktop()) pointerInput(rowState) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt similarity index 59% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt index 940b6b3d..a9593908 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarCard.kt @@ -15,85 +15,68 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import org.koin.compose.koinInject +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.User @Composable fun AvatarCard( title: String, subtitle: String? = null, imageURL: String? = null, - onClick: () -> Unit = {}, + onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, isBlacklisted: Boolean = false, + listMode: Boolean = false ) { - val backgroundColor = if (isBlacklisted) { - MaterialTheme.colorScheme.error.copy(alpha = 0.1f) - } else { - Color.Transparent + if (listMode) { + AvatarListTile(title, subtitle, imageURL, onClick, modifier, isBlacklisted) + return } - - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .background(backgroundColor) - .clickable(onClick = { onClick.invoke() }, enabled = true) - .width(160.dp), - ) { + + MediaCardSurface(modifier.width(160.dp), onClick, isBlacklisted = isBlacklisted) { Column( modifier = Modifier .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { Box( modifier = Modifier .fillMaxWidth() .clip(CircleShape) .padding(12.dp), - contentAlignment = Alignment.Center, + contentAlignment = Alignment.Center ) { - AsyncImage( + MediaArtwork( model = imageURL, - contentDescription = title, + title = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop, + shape = CircleShape, + placeholderIcon = Iconsax.User ) } Column( @@ -101,22 +84,23 @@ fun AvatarCard( ) { TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) subtitle?.let { + Spacer(Modifier.height(4.dp)) TextWithShimmer( text = subtitle, - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) } } } } } - diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt new file mode 100644 index 00000000..fe656879 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/AvatarListTile.kt @@ -0,0 +1,31 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.User + +@Composable +fun AvatarListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isBlacklisted: Boolean = false +) { + PlayableListTile( + title = title, + subtitle = subtitle, + imageURL = imageURL, + onClick = onClick, + modifier = modifier, + imageShape = CircleShape, + placeholderIcon = Iconsax.User, + isBlacklisted = isBlacklisted + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt new file mode 100644 index 00000000..e291363f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaCardSurface.kt @@ -0,0 +1,84 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumActionPointer +import org.spotube.millennium.theme.millenniumSurface + +/** Interaction state is presentation-only; all navigation/playback stays in event callbacks. */ +@Composable +internal fun MediaCardSurface( + modifier: Modifier, + onClick: (() -> Unit)?, + interactive: Boolean = onClick != null, + isBlacklisted: Boolean = false, + content: @Composable (highlighted: Boolean) -> Unit +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val pressed by interaction.collectIsPressedAsState() + val focused by interaction.collectIsFocusedAsState() + var groupFocused by remember { mutableStateOf(false) } + val colors = MillenniumTheme.colors + val highlight by animateFloatAsState( + if (hovered || focused || groupFocused || pressed) 1f else 0f, + tween(140) + ) + val scale by animateFloatAsState(if (pressed) 0.985f else 1f, tween(100)) + val shape = RoundedCornerShape(10.dp) + Box( + modifier.graphicsLayer { scaleX = scale; scaleY = scale } + .millenniumSurface( + background = Brush.verticalGradient( + listOf( + colors.raised.copy(alpha = highlight), + if (isBlacklisted) colors.danger.copy(alpha = 0.12f + highlight * 0.12f) + else colors.surface.copy(alpha = highlight) + ) + ), + shape = shape, + borderColor = (if (focused) colors.accent else colors.border).copy(alpha = highlight), + borderWidth = if (focused) 2.dp else 1.dp, + innerGlowColor = colors.highlight.copy(alpha = highlight * 0.4f), + shadowElevation = (highlight * 4f).dp + ) + .clip(shape) + .onFocusChanged { groupFocused = it.hasFocus } + .focusGroup() + .hoverable(interaction, enabled = interactive) + .then( + if (onClick != null) Modifier.millenniumActionPointer().clickable( + interactionSource = interaction, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) else Modifier + ) + ) { content(hovered || focused || groupFocused) } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt new file mode 100644 index 00000000..9af4fe34 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/MediaEmptyState.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface + +@Composable +internal fun MediaEmptyState( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + description: String? = null, + action: (@Composable () -> Unit)? = null +) { + Box(modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column( + Modifier.widthIn(max = 360.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + Modifier.size(64.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + icon, + null, + modifier = Modifier.size(30.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } + Text(title, style = AppTheme.typography.titleMedium, textAlign = TextAlign.Center) + if (description != null) Text( + description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + action?.invoke() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt similarity index 54% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt index 14aee52b..8f9e81d8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableCard.kt @@ -15,16 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component.cards +package org.spotube.spotube.core.ui.component.cards + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import com.composeunstyled.LocalTextStyle import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -34,35 +35,34 @@ 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold + +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton -import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone -import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer -import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.core.ui.component.LocalAnimatedVisibilityScope +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.component.sharedElementOrNone +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_add_to_queue_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.media_preview_title +import spotube.composeapp.generated.resources.media_preview_subtitle @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -76,23 +76,40 @@ fun PlayableCard( onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, sharedElementKey: String? = null, + listMode: Boolean = false, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false ) { - val interactionSource = remember { MutableInteractionSource() } + if (listMode) { + PlayableListTile( + title, + subtitle, + imageURL, + isPlaying, + onClick, + onPlay, + onAddToQueue, + modifier, + sharedElementKey, + playLoading = playLoading, + addToQueueLoading = addToQueueLoading + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = { onClick?.invoke() }, enabled = true) - .width(160.dp) - .hoverable(interactionSource = interactionSource), - ) { + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = onClick ?: onPlay, + interactive = onClick != null || onPlay != null || onAddToQueue != null + ) { highlighted -> Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { - AsyncImage( + MediaArtwork( model = imageURL, - contentDescription = title, + title = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) @@ -100,9 +117,9 @@ fun PlayableCard( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedVisibilityScope ), - contentScale = ContentScale.Crop, + shape = RoundedCornerShape(8.dp) ) if (onPlay != null || onAddToQueue != null) @@ -110,49 +127,54 @@ fun PlayableCard( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), contentAlignment = Alignment.BottomEnd ) { - val isHovered by interactionSource.collectIsHoveredAsState() this@Column.AnimatedVisibility( - visible = isHovered, + visible = highlighted || touchInput || (isPlaying && onPlay != null) || + playLoading || addToQueueLoading, enter = fadeIn(), - exit = fadeOut(), + exit = fadeOut() ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { - onAddToQueue?.let { - SecondaryIconButton( + // While this collection is playing the play/pause toggle stays + // visible and the add-to-queue action is hidden entirely. + onAddToQueue?.takeIf { !isPlaying }?.let { + MillenniumIconButton( onClick = onAddToQueue, - modifier = Modifier.size(30.dp), - theme = LocalBaseUITheme.current.iconButtons.secondary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue( - CircleShape - ) - ) + loading = addToQueueLoading, + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue", + contentDescription = stringResource( + Res.string.player_add_to_queue_content_description + ), ) } + Spacer(modifier = Modifier.height(8.dp)) } - Spacer(modifier = Modifier.height(8.dp)) onPlay?.let { - PrimaryIconButton( + MillenniumIconButton( onClick = onPlay, - modifier = Modifier.size(30.dp), - theme = LocalBaseUITheme.current.iconButtons.secondary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue( - CircleShape - ) - ) + loading = playLoading, + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Primary ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) { Iconsax.IconsaxPause } else Iconsax.IconsaxPlay, - contentDescription = "Play", + contentDescription = stringResource( + if (isPlaying) { + Res.string.player_pause_content_description + } else { + Res.string.player_play_content_description + } + ), + tint = LocalTextStyle.current.color ) } } @@ -165,18 +187,18 @@ fun PlayableCard( ) { TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) subtitle?.let { TextWithShimmer( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis ) } } @@ -185,14 +207,13 @@ fun PlayableCard( } } - @PreviewLightDark @Composable private fun PlayableCardPreview() { - Scaffold { + Box { PlayableCard( - title = "Sample Title", - subtitle = "Sample Subtitle", + title = stringResource(Res.string.media_preview_title), + subtitle = stringResource(Res.string.media_preview_subtitle), imageURL = "https://placehold.co/600x400", isPlaying = true, onClick = {}, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt new file mode 100644 index 00000000..abc69cda --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt @@ -0,0 +1,182 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.core.ui.component.cards + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composeunstyled.LocalTextStyle +import coil3.compose.AsyncImage +import org.spotube.spotube.core.ui.component.LocalAnimatedVisibilityScope +import org.spotube.spotube.core.ui.component.LocalSharedTransitionScope +import org.spotube.spotube.core.ui.component.sharedElementOrNone +import org.spotube.spotube.core.ui.misc.TextWithShimmer +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PlayableListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + isPlaying: Boolean = false, + onClick: (() -> Unit)? = null, + onPlay: (() -> Unit)? = null, + onAddToQueue: (() -> Unit)? = null, + modifier: Modifier = Modifier, + sharedElementKey: String? = null, + playLoading: Boolean = false, + addToQueueLoading: Boolean = false, + imageModel: Any? = imageURL, + imageShape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + isBlacklisted: Boolean = false, + compact: Boolean = false +) { + val colors = MillenniumTheme.colors + MillenniumListTile( + modifier = modifier, + onClick = onClick ?: onPlay, + selected = isPlaying, + background = if (isPlaying) null else millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border, + titleForeground = if (isBlacklisted) colors.danger else null, + borderWidth = 1.dp, + contentPadding = PaddingValues(if (compact) 8.dp else 12.dp), + minimumHeight = if (compact) 52.dp else 76.dp, + leading = { + MediaArtwork( + imageModel, title, + Modifier.size(if (compact) 36.dp else 52.dp).sharedElementOrNone( + sharedElementKey, + LocalSharedTransitionScope.current, + LocalAnimatedVisibilityScope.current + ), + imageShape, placeholderIcon, artwork + ) + }, + title = { + TextWithShimmer( + title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = subtitle?.let { text -> + { + TextWithShimmer( + text, + style = AppTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + }, + trailing = if (onPlay != null || onAddToQueue != null || trailingContent != null) { + { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + trailingContent?.invoke() + if (onAddToQueue != null && !isPlaying) { + MillenniumIconButton( + onClick = onAddToQueue, + loading = addToQueueLoading, + modifier = Modifier.size(if (compact) 32.dp else 40.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + Iconsax.IconsaxAddSquare, + stringResource(Res.string.player_add_to_queue_content_description) + ) + } + } + if (onPlay != null) { + MillenniumIconButton( + onClick = onPlay, + loading = playLoading, + modifier = Modifier.size(if (compact) 32.dp else 40.dp), + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + stringResource(if (isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description), + tint = LocalTextStyle.current.color + ) + } + } + } + } + } else null + ) +} + +@Composable +internal fun MediaArtwork( + model: Any?, + title: String, + modifier: Modifier, + shape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null +) { + Box( + modifier.millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = shape, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).clip(shape).shimmerApply(), + contentAlignment = Alignment.Center + ) { + if (artwork != null) artwork() + else if (model != null) AsyncImage( + model, + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + else MillenniumIcon( + placeholderIcon, + null, + modifier = Modifier.size(24.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt index 84569b6e..4ff18b1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Coverflow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -45,8 +45,7 @@ fun Coverflow( modifier: Modifier = Modifier, state: CoverflowState = rememberCoverflowState(), params: CoverflowParams = CoverflowParams(), - content: CoverflowScope.() -> Unit, -) { + content: CoverflowScope.() -> Unit) { var size by remember { mutableStateOf(IntSize.Zero) } var geometry by remember { mutableStateOf(null) } @@ -55,8 +54,7 @@ fun Coverflow( val g = geometry ?: return@LaunchedEffect state.lazyListState.scrollToItem( state.selectedIndex + 1, - -g.spacerWidth, - ) + -g.spacerWidth) state.initialScrollDone = true } @@ -69,8 +67,7 @@ fun Coverflow( }, verticalAlignment = Alignment.CenterVertically, state = state.lazyListState, - flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState), - ) { + flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) { if (size != IntSize.Zero) { val g = Geometry(params, size) geometry = g @@ -79,8 +76,7 @@ fun Coverflow( val coverflowScope = CoverflowScopeImpl( geometry = g, lazyListScope = this, - coverflowState = state, - ) + coverflowState = state) item { Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() })) @@ -102,15 +98,13 @@ fun Coverflow( if (state.selectedIndex > 0) { state.scrollToItem(state.selectedIndex - 1) } - }, - ) {} + }) {} Box( modifier = Modifier .fillMaxHeight() .align(Alignment.CenterEnd) .clickable { state.scrollToItem(state.selectedIndex + 1) - }, - ) {} + }) {} } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt index 0ea24cc7..2b85d539 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowGeometry.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.runtime.Stable import androidx.compose.ui.unit.IntSize @@ -25,8 +25,7 @@ import kotlin.math.min @Stable internal class Geometry( internal val params: CoverflowParams = CoverflowParams(), - private val size: IntSize = IntSize.Zero, -) { + private val size: IntSize = IntSize.Zero) { @Stable private val shortEdge get() = min(size.width, size.height) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt index 9eabc429..6b49fc94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowItem.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -41,8 +41,7 @@ internal fun CoverflowItem( geometry: Geometry, onClickHandler: () -> Unit = {}, onSelectedHandler: (Boolean) -> Unit, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { var horizontalPosition by remember { mutableStateOf(null) } val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f) @@ -53,8 +52,7 @@ internal fun CoverflowItem( .onGloballyPositioned { coordinates -> horizontalPosition = coordinates.positionInParent().x }, - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (horizontalPosition != null) { onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f)) @@ -65,20 +63,16 @@ internal fun CoverflowItem( rotationY = geometry.rotation(distanceToCenter), translationX = geometry.translationX(distanceToCenter), scaleY = geometry.scale(distanceToCenter), - scaleX = geometry.scale(distanceToCenter), - ), - propagateMinConstraints = true, - ) { + scaleX = geometry.scale(distanceToCenter)), + propagateMinConstraints = true) { val interactionSource = remember { MutableInteractionSource() } if (geometry.params.mirror) { Mirror( modifier = Modifier.clickable( interactionSource = interactionSource, indication = null, - onClick = onClickHandler, - ), - coverSize = geometry.coverSize, - ) { + onClick = onClickHandler), + coverSize = geometry.coverSize) { content() } } else { @@ -86,9 +80,7 @@ internal fun CoverflowItem( modifier = Modifier.clickable( interactionSource = interactionSource, indication = null, - onClick = onClickHandler, - ), - ) { + onClick = onClickHandler)) { content() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt index 6298a886..5efeac08 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowParams.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.runtime.Immutable @@ -26,5 +26,4 @@ data class CoverflowParams( val angle: Float = 45f, val shift: Float = 0.4f, val zoom: Float = 0.8f, - val mirror: Boolean = false, -) + val mirror: Boolean = false) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt index ef4fb578..6a21e771 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowScope.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable @@ -26,37 +26,32 @@ interface CoverflowScope { onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> }, key: ((index: Int) -> Any)? = null, contentType: (index: Int) -> Any? = { null }, - itemContent: @Composable ((item: T) -> Unit), - ) + itemContent: @Composable ((item: T) -> Unit)) fun items( count: Int, onSelectHandler: (index: Int) -> Unit = {}, key: ((index: Int) -> Any)? = null, contentType: (index: Int) -> Any? = { null }, - itemContent: @Composable ((index: Int) -> Unit), - ) + itemContent: @Composable ((index: Int) -> Unit)) } internal class CoverflowScopeImpl( private val geometry: Geometry, private val lazyListScope: LazyListScope, - private val coverflowState: CoverflowState, -) : CoverflowScope { + private val coverflowState: CoverflowState) : CoverflowScope { override fun items( items: List, onSelectHandler: (item: T, index: Int) -> Unit, key: ((index: Int) -> Any)?, contentType: (index: Int) -> Any?, - itemContent: @Composable (item: T) -> Unit, - ) = items( + itemContent: @Composable (item: T) -> Unit) = items( count = items.size, onSelectHandler = { onSelectHandler(items[it], it) }, key = key, - contentType = contentType, - ) { + contentType = contentType) { itemContent(items[it]) } @@ -65,12 +60,10 @@ internal class CoverflowScopeImpl( onSelectHandler: (index: Int) -> Unit, key: ((index: Int) -> Any)?, contentType: (index: Int) -> Any?, - itemContent: @Composable (index: Int) -> Unit, - ) = lazyListScope.items( + itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items( count, key, - contentType, - ) { + contentType) { CoverflowItem( onClickHandler = { coverflowState.scrollToItem(it) }, onSelectedHandler = { isSelected: Boolean -> @@ -78,16 +71,14 @@ internal class CoverflowScopeImpl( onSelectHandler(it) } }, - geometry = geometry, - ) { + geometry = geometry) { itemContent(it) } } private fun selectHandler( index: Int, - isSelected: Boolean, - ): Boolean { + isSelected: Boolean): Boolean { if (!isSelected && index == coverflowState.selectedIndex) { coverflowState.selectedIndex = -1 } else if (isSelected && index != coverflowState.selectedIndex) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt index 99a16d77..04acc272 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/CoverflowState.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -28,19 +28,16 @@ import kotlinx.coroutines.launch @Composable fun rememberCoverflowState( initialIndex: Int = 0, - onSelectHandler: (Int) -> Unit = {}, -): CoverflowState { + onSelectHandler: (Int) -> Unit = {}): CoverflowState { val lazyListState: LazyListState = rememberLazyListState( - initialFirstVisibleItemIndex = initialIndex + 1, - ) + initialFirstVisibleItemIndex = initialIndex + 1) val coroutineScope: CoroutineScope = rememberCoroutineScope() return remember { CoverflowState( lazyListState, coroutineScope, onSelectHandler, - initialIndex, - ) + initialIndex) } } @@ -48,8 +45,7 @@ class CoverflowState internal constructor( internal val lazyListState: LazyListState, private val coroutineScope: CoroutineScope, private val onSelectHandler: (Int) -> Unit = {}, - initialIndex: Int = 0, -) { + initialIndex: Int = 0) { internal var geometry: Geometry? = null internal var initialScrollDone: Boolean = false @@ -65,8 +61,7 @@ class CoverflowState internal constructor( coroutineScope.launch { lazyListState.animateScrollToItem( index + 1, - -geometryCopy.spacerWidth, - ) + -geometryCopy.spacerWidth) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt similarity index 81% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt index 3ec6fa81..ca924c8e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/coverflow/Mirror.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.coverflow +package org.spotube.spotube.core.ui.coverflow import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times internal fun Mirror( modifier: Modifier = Modifier, coverSize: Float, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() } Column( modifier = modifier .padding(top = coverSizeDp) .fillMaxWidth() - .requiredHeight(2 * coverSizeDp), - ) { + .requiredHeight(2 * coverSizeDp)) { Box( propagateMinConstraints = true, modifier = Modifier .weight(1f) .fillMaxWidth() - .height(coverSizeDp), - ) { + .height(coverSizeDp)) { content() } Box( @@ -75,34 +72,27 @@ internal fun Mirror( .height(coverSizeDp) .graphicsLayer( compositingStrategy = CompositingStrategy.Offscreen, - rotationX = 180f, - ) + rotationX = 180f) .clip(HalfSizeShape) .drawWithContent { val colors = listOf( Color.Transparent, Color.Transparent, - Color.Black, - ) + Color.Black) drawContent() drawRect( topLeft = Offset( 0f, - this.size.height / 2, - ), + this.size.height / 2), size = Size( this.size.width, - this.size.height / 2, - ), + this.size.height / 2), brush = Brush.verticalGradient(colors), - blendMode = BlendMode.DstIn, - ) + blendMode = BlendMode.DstIn) } .blur( radiusX = 1.dp, - radiusY = 3.dp, - ), - ) { + radiusY = 3.dp)) { content() } } @@ -112,17 +102,12 @@ private object HalfSizeShape : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, - density: Density, - ): Outline = Outline.Rectangle( + density: Density): Outline = Outline.Rectangle( Rect( Offset( 0f, - size.height / 2, - ), + size.height / 2), Size( size.width, - size.height, - ), - ), - ) + size.height))) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt similarity index 81% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt index a5040409..6588d239 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/Shimmer.kt @@ -15,12 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.misc +package org.spotube.spotube.core.ui.misc +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.TextAutoSize -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -38,10 +39,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit -import com.eygraber.compose.placeholder.material3.placeholder import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush val LocalSkeletonLoading = compositionLocalOf { false } @@ -80,15 +82,16 @@ fun SkeletonTree( } fun Modifier.shimmerApply( - color: Color = Color.LightGray.copy(alpha = 0.5f) // Adjust alpha/color to match your theme + color: Color? = null // defaults to Millennium's recessed skeleton fill ): Modifier = composed { val isTreeLoading = LocalSkeletonLoading.current + val skeletonBrush = millenniumBevelBrush(MillenniumTheme.colors, sunken = true) - this.placeholder( - visible = isTreeLoading, - color = color, - shape = null, - ) + when { + !isTreeLoading -> this + color != null -> this.background(color) + else -> this.background(skeletonBrush) + } } @Composable @@ -110,23 +113,23 @@ fun TextWithShimmer( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, onTextLayout: ((TextLayoutResult) -> Unit)? = null, - style: TextStyle = LocalTextStyle.current, -) { + style: TextStyle = LocalTextStyle.current) { + // While a skeleton tree is loading, the placeholder glyphs must not be readable: + // only the shimmer bars (from `shimmerApply`) should show through. + val isLoadingSkeleton = LocalSkeletonLoading.current Text( text = text, - color = color, + color = if (isLoadingSkeleton) Color.Transparent else color, modifier = Modifier.shimmerApply().then(modifier), autoSize = autoSize, fontSize = fontSize, - fontStyle = fontStyle, fontWeight = fontWeight, fontFamily = fontFamily, letterSpacing = letterSpacing, textDecoration = textDecoration, - textAlign = textAlign, + textAlign = textAlign ?: TextAlign.Start, lineHeight = lineHeight, overflow = overflow, - softWrap = softWrap, maxLines = maxLines, minLines = minLines, onTextLayout = onTextLayout, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeys.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeys.kt new file mode 100644 index 00000000..85a6d57d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeys.kt @@ -0,0 +1,67 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.misc + +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable + +/* + * Lazy lists crash when two items share a key ("Key ... was already used", #3196). Ids that come + * from plugins can be blank or repeat (the same playlist twice in a library page, a track added + * twice), so lists keyed on them use these instead of `items(list, key = { it.id })`. + */ + +private const val DUPLICATE_SEPARATOR = '\u0000' + +/** + * One key per item: an item keeps its id the first time it appears, so scroll position and + * animations stay stable; blank ids and repeats get an occurrence suffix that can't collide with + * a real id. + */ +fun uniqueLazyKeys(items: List, id: (T) -> String): List { + val occurrences = HashMap(items.size) + return items.map { item -> + val base = id(item) + val occurrence = occurrences[base] ?: 0 + occurrences[base] = occurrence + 1 + if (occurrence == 0 && base.isNotEmpty()) base else "$base$DUPLICATE_SEPARATOR$occurrence" + } +} + +fun LazyListScope.itemsWithUniqueKeys( + items: List, + id: (T) -> String, + keyPrefix: String = "", + itemContent: @Composable LazyItemScope.(T) -> Unit +) { + val keys = uniqueLazyKeys(items, id) + items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) } +} + +fun LazyGridScope.itemsWithUniqueKeys( + items: List, + id: (T) -> String, + keyPrefix: String = "", + itemContent: @Composable LazyGridItemScope.(T) -> Unit +) { + val keys = uniqueLazyKeys(items, id) + items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt new file mode 100644 index 00000000..b283451e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt @@ -0,0 +1,267 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.theming + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.millennium.composables.MillenniumModalBottomSheetTheme +import org.spotube.millennium.composables.MillenniumTooltipHost +import org.spotube.millennium.composables.MillenniumTopAppBarTheme +import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties +import org.spotube.millennium.composables.defaultMillenniumModalBottomSheetTheme +import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme +import org.spotube.millennium.theme.MillenniumColors +import org.spotube.millennium.theme.MillenniumDarkThemeComponents +import org.spotube.millennium.theme.MillenniumLightThemeComponents +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig + +/** + * Semantic color roles used across the app, resolved from the active Millennium palette. + * + * This is not a second palette: every role is derived from [MillenniumColors], so the + * whole app follows the selected Millennium accent family and light/dark appearance. + */ +@Immutable +data class AppColors( + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val tertiaryContainer: Color, + val onTertiaryContainer: Color, + val background: Color, + val onBackground: Color, + val surface: Color, + val onSurface: Color, + val surfaceVariant: Color, + val onSurfaceVariant: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val inverseSurface: Color, + val inverseOnSurface: Color, + val outline: Color, + val outlineVariant: Color, + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, + val scrim: Color +) + +/** Maps Millennium's semantic palette onto the app's role names. */ +fun appColorsFrom(m: MillenniumColors): AppColors = AppColors( + primary = m.accent, + onPrimary = m.onAccent, + primaryContainer = m.raised, + onPrimaryContainer = m.text, + secondary = m.accent, + onSecondary = m.onAccent, + secondaryContainer = m.raised, + onSecondaryContainer = m.text, + tertiaryContainer = m.raised, + onTertiaryContainer = m.text, + background = m.background, + onBackground = m.text, + surface = m.surface, + onSurface = m.text, + surfaceVariant = m.inset, + onSurfaceVariant = m.muted, + surfaceContainer = m.surface, + surfaceContainerHigh = m.raised, + surfaceContainerHighest = m.raised, + inverseSurface = m.text, + inverseOnSurface = m.background, + outline = m.border, + outlineVariant = m.border, + error = m.danger, + onError = m.background, + errorContainer = m.danger.copy(alpha = 0.16f), + onErrorContainer = m.danger, + scrim = m.scrim +) + +/** Named text styles used by the app, independent of Material typography. */ +@Immutable +data class AppTypography( + val displaySmall: TextStyle, + val headlineLarge: TextStyle, + val headlineMedium: TextStyle, + val headlineSmall: TextStyle, + val titleLarge: TextStyle, + val titleMedium: TextStyle, + val titleSmall: TextStyle, + val bodyLarge: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + val labelLarge: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle +) + +private fun style( + size: Int, + lineHeight: Int, + weight: FontWeight = FontWeight.Normal +): TextStyle = TextStyle( + fontFamily = FontFamily.SansSerif, + fontSize = size.sp, + lineHeight = lineHeight.sp, + fontWeight = weight +) + +val AppDefaultTypography = AppTypography( + displaySmall = style(36, 44), + headlineLarge = style(32, 40), + headlineMedium = style(28, 36), + headlineSmall = style(24, 32), + titleLarge = style(22, 28, FontWeight.Medium), + titleMedium = style(16, 24, FontWeight.Medium), + titleSmall = style(14, 20, FontWeight.Medium), + bodyLarge = style(16, 24), + bodyMedium = style(14, 20), + bodySmall = style(12, 16), + labelLarge = style(14, 20, FontWeight.Medium), + labelMedium = style(12, 16, FontWeight.Medium), + labelSmall = style(11, 16, FontWeight.Medium) +) + +/** Corner shapes used by the app. */ +@Immutable +data class AppShapes( + val small: Shape, + val medium: Shape, + val large: Shape +) + +val AppDefaultShapes = AppShapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp) +) + +val LocalAppColors = + staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) } +val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography } +val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes } + +/** Reads the active app theme tokens, all backed by Millennium. */ +object AppTheme { + val colors: AppColors @Composable get() = LocalAppColors.current + val typography: AppTypography @Composable get() = LocalAppTypography.current + val shapes: AppShapes @Composable get() = LocalAppShapes.current +} + +/** + * The app's single source of truth for colour. + * + * Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark + * [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and + * [AppShapes] tokens from the active Millennium theme. + */ +@Composable +fun SpotubeTheme( + settings: UserSettings, + content: @Composable () -> Unit +) { + val isDarkTheme = when (settings.theme) { + Theme.LIGHT -> false + Theme.DARK -> true + Theme.SYSTEM -> isSystemDarkTheme() + } + val palette: MillenniumPalette = settings.palette + + val darkTheme = MillenniumDarkThemeComponents + val lightTheme = MillenniumLightThemeComponents + + MillenniumTheme( + dark = isDarkTheme, + config = MillenniumThemeConfig( + palette = palette, + dark = darkTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = darkTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ), + modalBottomSheetTheme = compactModalBottomSheetTheme() + ), + light = lightTheme.copy( + topAppBarTheme = MillenniumTopAppBarTheme( + properties = lightTheme.topAppBarTheme.properties.copy( + separatorColor = Color.Transparent + ) + ), + modalBottomSheetTheme = compactModalBottomSheetTheme() + ) + ) + ) { + val colors = appColorsFrom(MillenniumTheme.colors) + MillenniumTooltipHost { + CompositionLocalProvider( + LocalAppColors provides colors, + LocalAppTypography provides AppDefaultTypography, + LocalAppShapes provides AppDefaultShapes, + content = content + ) + } + } +} + +/** Library default is 24dp padding + 16dp spacing, which is far too roomy on phones. */ +private fun compactModalBottomSheetTheme(): MillenniumModalBottomSheetTheme { + val padding = PaddingValues(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp) + fun org.spotube.millennium.composables.MillenniumModalBottomSheetThemeProperties.compact() = + copy(contentPadding = padding, contentSpacing = 8.dp) + val states = defaultMillenniumModalBottomSheetTheme().states + return MillenniumModalBottomSheetTheme( + states = org.spotube.millennium.theme.MillenniumControlStateProperty( + default = states.default.compact(), + hovered = states.hovered.compact(), + focused = states.focused.compact(), + pressed = states.pressed.compact(), + disabled = states.disabled.compact(), + selected = states.selected.compact(), + error = states.error.compact())) +} + +/** Whether the active Millennium appearance is light. */ +@Composable +fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt new file mode 100644 index 00000000..3efa4474 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt @@ -0,0 +1,24 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.theming + +import androidx.compose.runtime.Composable + +/** Whether the OS is in dark mode, observed live; desktop can't rely on Compose's own check under Tao. */ +@Composable +expect fun isSystemDarkTheme(): Boolean diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt index b9880a35..b89c9ec8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebView.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import dev.nucleusframework.webview.web.WebContent import dev.nucleusframework.webview.web.WebViewNavigator import dev.nucleusframework.webview.cookie.CookieManager -import dev.krtirtho.plugin_interfaces.host_apis.Cookie -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes +import org.spotube.plugin_interfaces.host_apis.Cookie +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt index c67c8fd0..5dab45ed 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview import dev.nucleusframework.webview.web.WebViewState diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt similarity index 73% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt index 93ecbd47..c6984344 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewScreen.kt @@ -1,222 +1,231 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.webview - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -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.statusBars -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.ChevronLeft -import compose.icons.feathericons.ChevronRight -import compose.icons.feathericons.X -import dev.krtirtho.spotube.core.tools.user_agents.UserAgents -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.nucleusframework.webview.jsbridge.IJsMessageHandler -import dev.nucleusframework.webview.jsbridge.JsMessage -import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge -import dev.nucleusframework.webview.web.WebView -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import dev.nucleusframework.webview.web.rememberWebViewNavigator - -class PostMessageHandler( - private val onMessageReceived: (String) -> Unit = {} -) : IJsMessageHandler { - override fun methodName(): String { - return "sendMessage" - } - - override fun handle( - message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit - ) { - onMessageReceived(message.params) - callback(message.params) - } -} - -@Composable -fun PlatformWebViewScreen(webViewController: WebViewController) { - if (webViewController.getContent() == null) { - // This should never happen, but just in case - Text("No URL to load") - return - } - - val state = remember { - WebViewState( - webViewController.getWebContent( - additionalHttpHeaders = mapOf( - "User-Agent" to UserAgents.random() - ) - ) - ) - }.apply { - this.content = webViewController.getWebContent() - platformWebviewConfig(this, webViewController.currentPluginId) - } - - val navigator = rememberWebViewNavigator() - val webViewBridge = rememberWebViewJsBridge(navigator) - - val bridgeBootstrapScript = remember { - """ - (function() { - if (typeof window.sendMessage !== "function") { - window.sendMessage = function(message) { - if (typeof message !== "string") { - throw new TypeError("[window.sendMessage] Message must be a string"); - } - window.kmpJsBridge.callNative("sendMessage", message); - }; - } - - if (!window.bridgeReady) { - const event = new CustomEvent("onBridgeReady"); - window.dispatchEvent(event); - window.bridgeReady = true; - } - })(); - """.trimIndent() - } - - LaunchedEffect(state) { - snapshotFlow { state.lastLoadedUrl }.collect { url -> - if (url != null) { - webViewController.emitUrlChange(url) - navigator.evaluateJavaScript(bridgeBootstrapScript) - webViewController.emitWebViewCreated() - } - } - } - - LaunchedEffect(state.cookieManager, navigator) { - webViewController.setCookieManager(cookieManager = state.cookieManager) - webViewController.webViewNavigator = navigator - } - - LaunchedEffect(webViewBridge) { - webViewBridge.register(PostMessageHandler { message -> - webViewController.emitPostMessage(message) - }) - } - - DisposableEffect(Unit) { - onDispose { - webViewController.dispose() - } - } - - Scaffold( - contentWindowInsets = WindowInsets.statusBars, - topBar = { - ApplicationMainBar( - title = { - Row( - modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - modifier = Modifier.height(56.dp) - ) { - IconButton( - onClick = { - navigator.navigateBack() - }, enabled = navigator.canGoBack - ) { - Icon( - FeatherIcons.ChevronLeft, - contentDescription = "Go back to browser history" - ) - } - IconButton( - onClick = { - navigator.navigateForward() - }, enabled = navigator.canGoForward - ) { - Icon( - FeatherIcons.ChevronRight, - contentDescription = "Go forward to browser history" - ) - } - } - Surface( - modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp), - shape = RoundedCornerShape(18.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)) - ) { - BasicTextField( - value = state.lastLoadedUrl ?: "", - onValueChange = {}, // Read-only - readOnly = true, - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start - ), - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) - .wrapContentHeight(Alignment.CenterVertically) - ) - } - } - } - ) - }) - { innerPadding -> - WebView( - state = state, - modifier = Modifier.padding(innerPadding).fillMaxSize(), - navigator = navigator, - webViewJsBridge = webViewBridge, - onCreated = { webView -> - navigator.evaluateJavaScript(bridgeBootstrapScript) - - }, - ) - } -} \ No newline at end of file +/* + * 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 . + */ + +package org.spotube.spotube.core.webview + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.ui.draw.clip + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronLeft +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.X +import org.spotube.spotube.core.tools.user_agents.UserAgents +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.rememberWebViewNavigator +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +class PostMessageHandler( + private val onMessageReceived: (String) -> Unit = {} +) : IJsMessageHandler { + override fun methodName(): String { + return "sendMessage" + } + + override fun handle( + message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit + ) { + onMessageReceived(message.params) + callback(message.params) + } +} + +@Composable +fun PlatformWebViewScreen(webViewController: WebViewController) { + if (webViewController.getContent() == null) { + // This should never happen, but just in case + Text(stringResource(Res.string.webview_no_url)) + return + } + + val state = remember { + WebViewState( + webViewController.getWebContent( + additionalHttpHeaders = mapOf( + "User-Agent" to UserAgents.random() + ) + ) + ) + }.apply { + this.content = webViewController.getWebContent() + platformWebviewConfig(this, webViewController.currentPluginId) + } + + val navigator = rememberWebViewNavigator() + val webViewBridge = rememberWebViewJsBridge(navigator) + + val bridgeBootstrapScript = remember { + """ + (function() { + if (typeof window.sendMessage !== "function") { + window.sendMessage = function(message) { + if (typeof message !== "string") { + throw new TypeError("[window.sendMessage] Message must be a string"); + } + window.kmpJsBridge.callNative("sendMessage", message); + }; + } + + if (!window.bridgeReady) { + const event = new CustomEvent("onBridgeReady"); + window.dispatchEvent(event); + window.bridgeReady = true; + } + })(); + """.trimIndent() + } + + LaunchedEffect(state) { + snapshotFlow { state.lastLoadedUrl }.collect { url -> + if (url != null) { + webViewController.emitUrlChange(url) + navigator.evaluateJavaScript(bridgeBootstrapScript) + webViewController.emitWebViewCreated() + } + } + } + + LaunchedEffect(state.cookieManager, navigator) { + webViewController.setCookieManager(cookieManager = state.cookieManager) + webViewController.webViewNavigator = navigator + } + + LaunchedEffect(webViewBridge) { + webViewBridge.register(PostMessageHandler { message -> + webViewController.emitPostMessage(message) + }) + } + + DisposableEffect(Unit) { + onDispose { + webViewController.dispose() + } + } + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Row( + modifier = Modifier.fillMaxWidth().height(56.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.height(56.dp) + ) { + MillenniumIconButton( + onClick = { + navigator.navigateBack() + }, + enabled = navigator.canGoBack, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronLeft, + contentDescription = stringResource(Res.string.webview_go_back) + ) + } + MillenniumIconButton( + onClick = { + navigator.navigateForward() + }, + enabled = navigator.canGoForward, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.webview_go_forward) + ) + } + } + Box( + modifier = Modifier + .weight(1f) + .height(36.dp) + .padding(horizontal = 4.dp) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + .border( + BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)), + RoundedCornerShape(18.dp)), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = state.lastLoadedUrl ?: "", + onValueChange = {}, // Read-only + readOnly = true, + singleLine = true, + textStyle = AppTheme.typography.bodyMedium.copy( + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Start + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) + .wrapContentHeight(Alignment.CenterVertically) + ) + } + } + } + ) + WebView( + state = state, + modifier = Modifier.weight(1f).fillMaxWidth(), + navigator = navigator, + webViewJsBridge = webViewBridge, + onCreated = { webView -> + navigator.evaluateJavaScript(bridgeBootstrapScript) + }) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt similarity index 77% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt index a2be45f6..b4a71234 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/BuiltInPluginService.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.spotube.core.zipline.plugin_apis.common.RealCoreAPI -import dev.krtirtho.spotube.core.zipline.plugin_apis.lrclib.RealLRCLibLyricsAPI -import dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt.RealNewPipeAudioAPI -import dev.krtirtho.spotube.modules.plugin.LRCLIB_BUILT_IN_PLUGIN -import dev.krtirtho.spotube.modules.plugin.NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -import dev.krtirtho.spotube.modules.plugin.PluginEntry +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.spotube.core.zipline.plugin_apis.common.RealCoreAPI +import org.spotube.spotube.core.zipline.plugin_apis.lrclib.RealLRCLibLyricsAPI +import org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt.RealNewPipeAudioAPI +import org.spotube.spotube.modules.plugin.LRCLIB_BUILT_IN_PLUGIN +import org.spotube.spotube.modules.plugin.NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN +import org.spotube.spotube.modules.plugin.PluginEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -38,9 +38,9 @@ import org.koin.core.component.KoinComponent import kotlin.reflect.KClass class BuiltInPluginService( - private val pluginInfo: PluginEntry, -) : PluginService, KoinComponent { + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { + override val pluginId: String = pluginInfo.id private val loggedInStateFlow = MutableStateFlow(false) override val loggedInFlow = loggedInStateFlow.asStateFlow() val servicesRegistry = mutableMapOf, ZiplineService>() @@ -57,7 +57,6 @@ class BuiltInPluginService( } } - override suspend fun start() { when (pluginInfo) { NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/FileSystemHTTPClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/FileSystemHTTPClient.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt index 784fae2e..a08da9a6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/FileSystemHTTPClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/FileSystemHTTPClient.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import app.cash.zipline.loader.ZiplineHttpClient -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import io.ktor.http.URLBuilder import io.ktor.http.decodeURLQueryComponent import okio.ByteString diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt similarity index 68% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt index 36e1ac7d..023415fe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/PluginService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/PluginService.kt @@ -15,20 +15,20 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI import kotlinx.coroutines.flow.StateFlow import kotlin.reflect.KClass @@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map, ZiplineService>) { } interface PluginService { + val pluginId: String val loggedInFlow: StateFlow suspend fun start() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt index 14282692..99496217 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import kotlinx.coroutines.CoroutineDispatcher @@ -40,7 +40,8 @@ class ZiplineDispatcher( * size is ~1 MiB, which causes native stack overflow when compiling large * JS modules like kotlin-stdlib (~491 KB). * - * This expect/actual ensures the backing thread has a sufficiently large stack. + * iOS secondary threads get only 512 KiB, so every platform must create its own + * large-stack thread. This expect/actual ensures the backing thread has one. */ expect fun createZiplineDispatcher(): ZiplineDispatcher diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt similarity index 77% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt index d055b64e..9b02c9e4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/ZiplinePluginService.kt @@ -1,345 +1,344 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.zipline - -import app.cash.zipline.Zipline -import app.cash.zipline.ZiplineService -import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh -import app.cash.zipline.loader.LoadResult -import app.cash.zipline.loader.ManifestVerifier -import app.cash.zipline.loader.ZiplineLoader -import dev.krtirtho.plugin_interfaces.core.Initializer -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_SERVICE_NAME -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.PersistedStorageAPI -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_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -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_SERVICE_NAME -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.lyrics.LyricsAPI -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_SERVICE_NAME -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.browse.MetadataBrowseAPI -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_SERVICE_NAME -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.track.MetadataTrackAPI -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_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.webview.WebViewController -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.RealPersistedStorageAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginCapability -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import io.ktor.http.URLBuilder -import io.ktor.http.decodeURLQueryComponent -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import okio.Path.Companion.toPath -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import kotlin.reflect.KClass - - -/** - * Manages the lifecycle of a Zipline plugin. - * It runs everything on its own dispatcher (different thread) as per Zipline's requirements. - * Anything it provides, must be called within that dispatcher context. - * The [use] function must be used. - * - * 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 - * `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. - */ -open class ZiplinePluginService( - val applicationName: String, - private val manifestUrl: String, - private val pluginInfo: PluginEntry, -) : PluginService, KoinComponent { - - // 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 - // (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 - // instance is created, before any modules are loaded. - private val ziplineDispatcher = createZiplineDispatcher() - - private fun trace(event: String) { - logger.d { "[$applicationName] $event" } - } - - private val logger by injectLogger() - private val webViewController: WebViewController by inject() - - private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) - private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> - logger.e(throwable) { "Zipline Engine Error" } - } - private val lifecycleMutex = Mutex() - private var ziplineLoader: ZiplineLoader - private var ziplineInstance: Zipline? = null - private val serviceRegistry = mutableMapOf, ZiplineService>() - - init { - val manifestPath = URLBuilder(manifestUrl) - val baseDir = - manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent - ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") - ziplineLoader = ZiplineLoader( - dispatcher = ziplineDispatcher.dispatcher, - manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, - httpClient = FileSystemHTTPClient(baseDir) - ) - } - - private val realHttpClientAPI = RealHttpClientAPI() - private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) - - private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) - private val cryptoAPI = RealCryptoAPI(scope.coroutineContext) - private val systemInformationAPI = RealSystemInformationAPI() - - private val loggedInStateFlow = MutableStateFlow(false) - override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow() - - private fun bindHostServices(zipline: Zipline) { - trace("initializer(): binding host APIs") - logger.d { "[$applicationName] Binding host APIs in initializer" } - try { - // Basic APIs - zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI) - zipline.bind( - SystemInformationAPI_SERVICE_NAME, - systemInformationAPI - ) - - // Conditional APIs based on plugin capabilities - if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { - zipline.bind( - HttpClientAPI_SERVICE_NAME, - realHttpClientAPI - ) - } - if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { - zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) - } - if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { - zipline.bind( - PersistedStorageAPI_SERVICE_NAME, - persistedStorageAPI - ) - } - } catch (e: Exception) { - logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } - throw e - } - } - - private fun consumePluginServices(result: LoadResult.Success) { - trace("start(): loadOnce success") - val apiMap = - buildMap, ZiplineService> { - put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) - - if (PluginAbility.METADATA in pluginInfo.abilities) { - put( - MetadataUserAPI::class, - result.zipline.take( - MetadataUserAPI_SERVICE_NAME - ) - ) - put( - MetadataTrackAPI::class, - result.zipline.take( - MetadataTrackAPI_SERVICE_NAME - ) - ) - put( - MetadataAlbumAPI::class, - result.zipline.take( - MetadataAlbumAPI_SERVICE_NAME - ) - ) - put( - MetadataArtistAPI::class, - result.zipline.take( - MetadataArtistAPI_SERVICE_NAME - ) - ) - put( - MetadataPlaylistAPI::class, - result.zipline.take( - MetadataPlaylistAPI_SERVICE_NAME - ) - ) - put( - MetadataBrowseAPI::class, - result.zipline.take( - MetadataBrowseAPI_SERVICE_NAME - ) - ) - put( - MetadataSearchAPI::class, - result.zipline.take( - MetadataSearchAPI_SERVICE_NAME - ) - ) - } - if (PluginAbility.AUDIO in pluginInfo.abilities) { - put( - AudioAPI::class, - result.zipline.take(AudioAPI_SERVICE_NAME) - ) - } - if (PluginAbility.LYRICS in pluginInfo.abilities) { - put( - LyricsAPI::class, - result.zipline.take(LyricsAPI_SERVICE_NAME) - ) - } - if (PluginAbility.SCROBBLE in pluginInfo.abilities) { - put( - ScrobbleAPI::class, - result.zipline.take(ScrobbleAPI_SERVICE_NAME) - ) - } - } - - serviceRegistry.putAll(apiMap) - trace("start(): API ready") - } - - private fun runLogInFlowObservers() = scope.launch { - val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI - coreAPI.loggedInFlow.collect { isLoggedIn -> - loggedInStateFlow.value = isLoggedIn - } - } - - override suspend fun start() { - lifecycleMutex.withLock { - trace("start(): entered") - if (serviceRegistry.isNotEmpty()) { - trace("start(): already started, skipping") - return - } - - logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } - withContext(ziplineDispatcher.dispatcher) { - trace("start(): inside zipline dispatcher before loadOnce") - val result = ziplineLoader.loadOnce( - applicationName = applicationName, - manifestUrl = manifestUrl, - freshnessChecker = DefaultFreshnessCheckerNotFresh, - ) - when (result) { - is LoadResult.Success -> { - logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } - ziplineInstance = result.zipline - // Now we consume the initializer - val initializer = result.zipline.take(Initializer_SERVICE_NAME) - // Bind host services before initialization, so plugins can use them in their initializer - bindHostServices(result.zipline) - trace("start(): calling initializer.initialize()") - runCatching { initializer.initialize() } - .onSuccess { - consumePluginServices(result) - runLogInFlowObservers() - } - .onFailure { e -> - logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } - throw e - } - } - - is LoadResult.Failure -> { - trace("start(): loadOnce failure: ${result.exception}") - logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } - throw result.exception - } - } - } - - } - } - - override suspend fun stop() { - lifecycleMutex.withLock { - trace("stop(): entered") - withContext(ziplineDispatcher.dispatcher) { - ziplineInstance?.close() - ziplineInstance = null - for (service in serviceRegistry.values) { - try { - trace("stop(): closing service ${service::class.simpleName}") - service.close() - } catch (_: Exception) { - trace("stop(): error closing service ${service::class.simpleName}") - } - } - } - serviceRegistry.clear() - scope.cancel() - loggedInStateFlow.value = false - ziplineDispatcher.close() - trace("stop(): completed") - } - } - - override suspend fun use(block: suspend PluginServiceScope.() -> T): T { - return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { - // Create the scope with the current registry - val scope = PluginServiceScope(serviceRegistry) - // Execute the block with 'scope' as 'this' - scope.block() - } - } +/* + * 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 . + */ + +package org.spotube.spotube.core.zipline + +import app.cash.zipline.Zipline +import app.cash.zipline.ZiplineService +import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh +import app.cash.zipline.loader.LoadResult +import app.cash.zipline.loader.ManifestVerifier +import app.cash.zipline.loader.ZiplineLoader +import org.spotube.plugin_interfaces.core.Initializer +import org.spotube.plugin_interfaces.core.Initializer_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.CryptoAPI +import org.spotube.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.core.zipline.host_apis.RealCryptoAPI +import org.spotube.spotube.core.zipline.host_apis.RealHttpClientAPI +import org.spotube.spotube.core.zipline.host_apis.RealPersistedStorageAPI +import org.spotube.spotube.core.zipline.host_apis.RealSystemInformationAPI +import org.spotube.spotube.core.zipline.host_apis.RealWebViewAPI +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginCapability +import org.spotube.spotube.modules.plugin.PluginEntry +import io.ktor.http.URLBuilder +import io.ktor.http.decodeURLQueryComponent +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.Path.Companion.toPath +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.reflect.KClass + +/** + * Manages the lifecycle of a Zipline plugin. + * It runs everything on its own dispatcher (different thread) as per Zipline's requirements. + * Anything it provides, must be called within that dispatcher context. + * The [use] function must be used. + * + * 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 + * `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. + */ +open class ZiplinePluginService( + val applicationName: String, + private val manifestUrl: String, + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { + + override val pluginId: String = pluginInfo.id + + // 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 + // (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 + // instance is created, before any modules are loaded. + private val ziplineDispatcher = createZiplineDispatcher() + + private fun trace(event: String) { + logger.d { "[$applicationName] $event" } + } + + private val logger by injectLogger() + private val webViewController: WebViewController by inject() + + private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) + private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> + logger.e(throwable) { "Zipline Engine Error" } + } + private val lifecycleMutex = Mutex() + private var ziplineLoader: ZiplineLoader + private var ziplineInstance: Zipline? = null + private val serviceRegistry = mutableMapOf, ZiplineService>() + + init { + val manifestPath = URLBuilder(manifestUrl) + val baseDir = + manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent + ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") + ziplineLoader = ZiplineLoader( + dispatcher = ziplineDispatcher.dispatcher, + manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, + httpClient = FileSystemHTTPClient(baseDir) + ) + } + + private val realHttpClientAPI = RealHttpClientAPI() + private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) + + private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) + private val cryptoAPI = RealCryptoAPI(scope.coroutineContext) + private val systemInformationAPI = RealSystemInformationAPI() + + private val loggedInStateFlow = MutableStateFlow(false) + override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow() + + private fun bindHostServices(zipline: Zipline) { + trace("initializer(): binding host APIs") + logger.d { "[$applicationName] Binding host APIs in initializer" } + try { + // Basic APIs + zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI) + zipline.bind( + SystemInformationAPI_SERVICE_NAME, + systemInformationAPI + ) + + // Conditional APIs based on plugin capabilities + if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { + zipline.bind( + HttpClientAPI_SERVICE_NAME, + realHttpClientAPI + ) + } + if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { + zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) + } + if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { + zipline.bind( + PersistedStorageAPI_SERVICE_NAME, + persistedStorageAPI + ) + } + } catch (e: Exception) { + logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } + throw e + } + } + + private fun consumePluginServices(result: LoadResult.Success) { + trace("start(): loadOnce success") + val apiMap = + buildMap, ZiplineService> { + put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) + + if (PluginAbility.METADATA in pluginInfo.abilities) { + put( + MetadataUserAPI::class, + result.zipline.take( + MetadataUserAPI_SERVICE_NAME + ) + ) + put( + MetadataTrackAPI::class, + result.zipline.take( + MetadataTrackAPI_SERVICE_NAME + ) + ) + put( + MetadataAlbumAPI::class, + result.zipline.take( + MetadataAlbumAPI_SERVICE_NAME + ) + ) + put( + MetadataArtistAPI::class, + result.zipline.take( + MetadataArtistAPI_SERVICE_NAME + ) + ) + put( + MetadataPlaylistAPI::class, + result.zipline.take( + MetadataPlaylistAPI_SERVICE_NAME + ) + ) + put( + MetadataBrowseAPI::class, + result.zipline.take( + MetadataBrowseAPI_SERVICE_NAME + ) + ) + put( + MetadataSearchAPI::class, + result.zipline.take( + MetadataSearchAPI_SERVICE_NAME + ) + ) + } + if (PluginAbility.AUDIO in pluginInfo.abilities) { + put( + AudioAPI::class, + result.zipline.take(AudioAPI_SERVICE_NAME) + ) + } + if (PluginAbility.LYRICS in pluginInfo.abilities) { + put( + LyricsAPI::class, + result.zipline.take(LyricsAPI_SERVICE_NAME) + ) + } + if (PluginAbility.SCROBBLE in pluginInfo.abilities) { + put( + ScrobbleAPI::class, + result.zipline.take(ScrobbleAPI_SERVICE_NAME) + ) + } + } + + serviceRegistry.putAll(apiMap) + trace("start(): API ready") + } + + private fun runLogInFlowObservers() = scope.launch { + val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI + coreAPI.loggedInFlow.collect { isLoggedIn -> + loggedInStateFlow.value = isLoggedIn + } + } + + override suspend fun start() { + lifecycleMutex.withLock { + trace("start(): entered") + if (serviceRegistry.isNotEmpty()) { + trace("start(): already started, skipping") + return + } + + logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } + withContext(ziplineDispatcher.dispatcher) { + trace("start(): inside zipline dispatcher before loadOnce") + val result = ziplineLoader.loadOnce( + applicationName = applicationName, + manifestUrl = manifestUrl, + freshnessChecker = DefaultFreshnessCheckerNotFresh) + when (result) { + is LoadResult.Success -> { + logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } + ziplineInstance = result.zipline + // Now we consume the initializer + val initializer = result.zipline.take(Initializer_SERVICE_NAME) + // Bind host services before initialization, so plugins can use them in their initializer + bindHostServices(result.zipline) + trace("start(): calling initializer.initialize()") + runCatching { initializer.initialize() } + .onSuccess { + consumePluginServices(result) + runLogInFlowObservers() + } + .onFailure { e -> + logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } + throw e + } + } + + is LoadResult.Failure -> { + trace("start(): loadOnce failure: ${result.exception}") + logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } + throw result.exception + } + } + } + + } + } + + override suspend fun stop() { + lifecycleMutex.withLock { + trace("stop(): entered") + withContext(ziplineDispatcher.dispatcher) { + ziplineInstance?.close() + ziplineInstance = null + for (service in serviceRegistry.values) { + try { + trace("stop(): closing service ${service::class.simpleName}") + service.close() + } catch (_: Exception) { + trace("stop(): error closing service ${service::class.simpleName}") + } + } + } + serviceRegistry.clear() + scope.cancel() + loggedInStateFlow.value = false + ziplineDispatcher.close() + trace("stop(): completed") + } + } + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { + // Create the scope with the current registry + val scope = PluginServiceScope(serviceRegistry) + // Execute the block with 'scope' as 'this' + scope.block() + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt index 2c0fc64b..bac3d40a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealCryptoAPI.kt @@ -15,39 +15,39 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis -import dev.krtirtho.plugin_interfaces.host_apis.AESEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize.B128 -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize.B192 -import dev.krtirtho.plugin_interfaces.host_apis.AESKeySize.B256 -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI -import dev.krtirtho.plugin_interfaces.host_apis.GenerateKeyPairAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.P256 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.P384 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.P521 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.brainpoolP256r1 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.brainpoolP384r1 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.brainpoolP512r1 -import dev.krtirtho.plugin_interfaces.host_apis.ECCurves.secp256k1 -import dev.krtirtho.plugin_interfaces.host_apis.ECDSASignatureFormats -import dev.krtirtho.plugin_interfaces.host_apis.ECEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.EdDSACurves -import dev.krtirtho.plugin_interfaces.host_apis.EdDSACurves.Ed25519 -import dev.krtirtho.plugin_interfaces.host_apis.EdDSACurves.Ed448 -import dev.krtirtho.plugin_interfaces.host_apis.EdDSAEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.MACKeyGeneratorAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.HMACEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.HashAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.LegacyCipherAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.RSAEncodingFormats -import dev.krtirtho.plugin_interfaces.host_apis.RSAEncodingFormats.JWK -import dev.krtirtho.plugin_interfaces.host_apis.RSAEncodingFormats.PEM -import dev.krtirtho.plugin_interfaces.host_apis.SignAlgorithms -import dev.krtirtho.plugin_interfaces.host_apis.MACSignatureAlgorithms -import dev.krtirtho.spotube.core.zipline.host_apis.additionals.DES +import org.spotube.plugin_interfaces.host_apis.AESEncodingFormats +import org.spotube.plugin_interfaces.host_apis.AESKeySize +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B128 +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B192 +import org.spotube.plugin_interfaces.host_apis.AESKeySize.B256 +import org.spotube.plugin_interfaces.host_apis.CryptoAPI +import org.spotube.plugin_interfaces.host_apis.GenerateKeyPairAlgorithms +import org.spotube.plugin_interfaces.host_apis.ECCurves +import org.spotube.plugin_interfaces.host_apis.ECCurves.P256 +import org.spotube.plugin_interfaces.host_apis.ECCurves.P384 +import org.spotube.plugin_interfaces.host_apis.ECCurves.P521 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP256r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP384r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.brainpoolP512r1 +import org.spotube.plugin_interfaces.host_apis.ECCurves.secp256k1 +import org.spotube.plugin_interfaces.host_apis.ECDSASignatureFormats +import org.spotube.plugin_interfaces.host_apis.ECEncodingFormats +import org.spotube.plugin_interfaces.host_apis.EdDSACurves +import org.spotube.plugin_interfaces.host_apis.EdDSACurves.Ed25519 +import org.spotube.plugin_interfaces.host_apis.EdDSACurves.Ed448 +import org.spotube.plugin_interfaces.host_apis.EdDSAEncodingFormats +import org.spotube.plugin_interfaces.host_apis.MACKeyGeneratorAlgorithms +import org.spotube.plugin_interfaces.host_apis.HMACEncodingFormats +import org.spotube.plugin_interfaces.host_apis.HashAlgorithms +import org.spotube.plugin_interfaces.host_apis.LegacyCipherAlgorithms +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats.JWK +import org.spotube.plugin_interfaces.host_apis.RSAEncodingFormats.PEM +import org.spotube.plugin_interfaces.host_apis.SignAlgorithms +import org.spotube.plugin_interfaces.host_apis.MACSignatureAlgorithms +import org.spotube.spotube.core.zipline.host_apis.additionals.DES import dev.whyoleg.cryptography.BinarySize import dev.whyoleg.cryptography.BinarySize.Companion.bits import dev.whyoleg.cryptography.CryptographyAlgorithmId @@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format { } } - class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { private val provider = CryptographyProvider.Default @@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { return CryptographyRandom.nextBytes(size) } - override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray { val hasher = provider.get(algorithm.algorithm()).hasher() return hasher.hash(data) @@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { val keyPair = provider.get(RSA.PSS).keyPairGenerator( algorithm.keySizeBits.bits, algorithm.hashAlgorithm.algorithm(), - algorithm.publicExponent.toBigInt(), - ).generateKey() + algorithm.publicExponent.toBigInt()).generateKey() Pair( keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), @@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.ECDSA -> provider.get(ECDSA).privateKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.privateKeyFormat(), privateKey ).signatureGenerator( @@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.EdDSA -> provider.get(EdDSA).privateKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.privateKeyFormat(), privateKey ).signatureGenerator().generateSignature(data) @@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.ECDSA -> provider.get(ECDSA).publicKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.publicKeyFormat(), publicKey ).signatureVerifier( @@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.EdDSA -> provider.get(EdDSA).publicKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.publicKeyFormat(), publicKey ).signatureVerifier().verifySignature( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt similarity index 83% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt index db09738a..553966b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -15,23 +15,24 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpMethod -import dev.krtirtho.plugin_interfaces.host_apis.HttpResponse -import dev.krtirtho.spotube.core.di.injectLogger -import io.ktor.client.HttpClient +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpMethod +import org.spotube.plugin_interfaces.host_apis.HttpResponse +import org.spotube.spotube.core.di.injectLogger import io.ktor.client.request.request import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.takeFrom import org.koin.core.component.KoinComponent - class RealHttpClientAPI : HttpClientAPI, KoinComponent { val logger by injectLogger() - val httpClient = HttpClient() + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient get() = httpClientFactory.default override suspend fun request( method: HttpMethod, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt index 35148f46..72de1bc2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealPersistedStorageAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis import androidx.datastore.preferences.core.stringPreferencesKey -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.modules.plugin.PluginEntry +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.modules.plugin.PluginEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.first diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt index 7fc8c10b..bd5c488f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealSystemInformationAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis +package org.spotube.spotube.core.zipline.host_apis -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI -import dev.krtirtho.spotube.getPlatform +import org.spotube.plugin_interfaces.host_apis.SystemInformationAPI +import org.spotube.spotube.getPlatform import kotlinx.datetime.TimeZone class RealSystemInformationAPI: SystemInformationAPI { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt index 80f906cb..cbb4e1e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealWebViewAPI.kt @@ -1,74 +1,73 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.zipline.host_apis - -import dev.krtirtho.plugin_interfaces.host_apis.Cookie -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.spotube.core.webview.WebViewController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class RealWebViewAPI( - private val scope: CoroutineScope, - private val webViewController: WebViewController, - private val pluginId: String, -) : WebViewAPI { - override fun navigateTo(url: String) { - scope.launch(Dispatchers.Main) { - webViewController.navigateTo(url, pluginId) - } - } - - override fun navigateToHTML(html: String) { - scope.launch { - webViewController.navigateToHTML(html, pluginId) - } - } - - override suspend fun getCookies(url: String): List { - return withContext(Dispatchers.Main) { - webViewController.getCookies(url) - } - } - - override suspend fun evaluateJavaScript(script: String): String? { - return withContext(Dispatchers.Main) { - webViewController.evaluateJavascript(script) - } - } - - override fun urlChangeFlow(): Flow { - return webViewController.urlChangedFlow - } - override fun webviewCreatedFlow(): Flow { - return webViewController.webviewCreatedFlow - } - override fun postMessagesFlow(): Flow { - return webViewController.postMessagesFlow - } - - override fun exitWebView() { - scope.launch(Dispatchers.Main) { - webViewController.closeWebview() - } - } - +/* + * 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 . + */ + +package org.spotube.spotube.core.zipline.host_apis + +import org.spotube.plugin_interfaces.host_apis.Cookie +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.spotube.core.webview.WebViewController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RealWebViewAPI( + private val scope: CoroutineScope, + private val webViewController: WebViewController, + private val pluginId: String) : WebViewAPI { + override fun navigateTo(url: String) { + scope.launch(Dispatchers.Main) { + webViewController.navigateTo(url, pluginId) + } + } + + override fun navigateToHTML(html: String) { + scope.launch { + webViewController.navigateToHTML(html, pluginId) + } + } + + override suspend fun getCookies(url: String): List { + return withContext(Dispatchers.Main) { + webViewController.getCookies(url) + } + } + + override suspend fun evaluateJavaScript(script: String): String? { + return withContext(Dispatchers.Main) { + webViewController.evaluateJavascript(script) + } + } + + override fun urlChangeFlow(): Flow { + return webViewController.urlChangedFlow + } + override fun webviewCreatedFlow(): Flow { + return webViewController.webviewCreatedFlow + } + override fun postMessagesFlow(): Flow { + return webViewController.postMessagesFlow + } + + override fun exitWebView() { + scope.launch(Dispatchers.Main) { + webViewController.closeWebview() + } + } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DES.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DES.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt index d5cc1cef..e64edb94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DES.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DES.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis.additionals +package org.spotube.spotube.core.zipline.host_apis.additionals -import dev.krtirtho.plugin_interfaces.host_apis.PaddingTypes -import dev.krtirtho.plugin_interfaces.host_apis.SymmetricModes +import org.spotube.plugin_interfaces.host_apis.PaddingTypes +import org.spotube.plugin_interfaces.host_apis.SymmetricModes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt index ba8c7097..fccbd324 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/common/RealCoreAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.common +package org.spotube.spotube.core.zipline.plugin_apis.common -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt index d3c8e92e..59a931b8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -15,13 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.lrclib +package org.spotube.spotube.core.zipline.plugin_apis.lrclib -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsResponse -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import io.ktor.client.HttpClient +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpJson +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class LRCLibResponse( @@ -45,22 +47,24 @@ data class LRCLibResponse( val artistName: String, val albumName: String, val plainLyrics: String?, - val syncedLyrics: String?, -) + val syncedLyrics: String?) -class RealLRCLibLyricsAPI : LyricsAPI { - private val httpClient = HttpClient { - install(Logging) { - level = LogLevel.ALL - logger = Logger.SIMPLE - } - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - defaultRequest { - url("https://lrclib.net/api/get") - // TODO: Add the actual version of Spotube instead of hardcoding it - userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") +class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient by lazy { + httpClientFactory.create { + install(Logging) { + level = LogLevel.ALL + logger = Logger.SIMPLE + } + install(ContentNegotiation) { + json(HttpJson) + } + defaultRequest { + url("https://lrclib.net/api/get") + // TODO: Add the actual version of Spotube instead of hardcoding it + userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") + } } } @@ -95,7 +99,6 @@ class RealLRCLibLyricsAPI : LyricsAPI { val text = line.substring(match.range.last + 1).trim() LyricsLine(time = timeMs, text = text) }, - plainLyrics = response.plainLyrics, - ) + plainLyrics = response.plainLyrics) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt index e1268ef1..c9c95cca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -124,16 +124,14 @@ data class MusicGatewayResponse( val externalIds: ExternalIds, val id: String, val name: String, - val popularity: Int, - ) { + val popularity: Int) { @Serializable data class Album( @SerialName("album_type") val albumType: String, val artists: List, val id: String, - val name: String, - ) + val name: String) @Serializable data class Artist( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt similarity index 55% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index a36a7ad9..52b2eaa7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -15,13 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.tools.user_agents.UserAgents -import io.ktor.client.HttpClient +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.tools.user_agents.UserAgents import io.ktor.client.call.body -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import org.spotube.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post @@ -37,7 +41,10 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -class ISRCProviders { +class ISRCProviders : KoinComponent { + private val logger by injectLogger() + private val lenientJson = Json { isLenient = true; ignoreUnknownKeys = true } + companion object { val greedyGreenCorp = listOf(-0x03, -0x01, 0x05, -0x0B, -0x03, 0x13) @@ -47,16 +54,19 @@ class ISRCProviders { .lowercase() } - val client = HttpClient() { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - } + private val httpClientFactory: HttpClientFactory by inject() + private val client get() = httpClientFactory.json + private fun MetadataTrack.describe() = + "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + + "durationMs=$durationMs isrc=$isrcCode externalUri=$externalUri" + + /** Logs the full raw body (so parse failures are diagnosable) then decodes it. */ + private suspend inline fun HttpResponse.decodeLogged(provider: String): T { + val text = bodyAsText() + logger.i { "[ISRC:$provider] status=${status.value} rawBody=$text" } + return lenientJson.decodeFromString(text) + } private var ifpiSessionToken: String? = null private val ifpiDefaultHeaders = mapOf( @@ -76,24 +86,26 @@ class ISRCProviders { "User-Agent" to UserAgents.random() ) - private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { if (ifpiSessionToken != null) return@withContext ifpiSessionToken + logger.i { "[ISRC:ifpi] requesting session token" } val res = client.get("https://isrc-api.soundexchange.com/api/ext/login") { headers.appendAll(ifpiDefaultHeaders) } if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] session login failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("ifpi-login") ifpiSessionToken = body.token body.token } suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:ifpi] input: ${track.describe()}" } val sessonToken = ifpiSessionToken() ?: return@withContext null val input = IFPIRecordingRequestInput( searchFields = IFPIRecordingRequestInput.SearchFields( @@ -115,6 +127,7 @@ class ISRCProviders { ) ) + logger.i { "[ISRC:ifpi] request searchFields=${input.searchFields}" } val res = client.post("https://isrc-api.soundexchange.com/api/ext/recordings") { contentType(ContentType.Application.Json) headers { @@ -125,17 +138,28 @@ class ISRCProviders { } if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] search failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("ifpi") if (body.recordings.isEmpty()) { + logger.i { "[ISRC:ifpi] no recordings returned" } return@withContext null } + body.recordings.forEachIndexed { i, r -> + logger.i { + "[ISRC:ifpi] candidate #$i isrc=${r.isrc} title='${r.recordingTitle}' " + + "artist='${r.recordingArtistName}' duration=${r.duration} year=${r.recordingYear} " + + "version=${r.recordingVersion} valid=${r.isValidIsrc}" + } + } + logger.i { "[ISRC:ifpi] picking FIRST candidate isrc=${body.recordings.first().isrc} (no artist/title validation)" } body.recordings.first().isrc } suspend fun musicGateway(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:musicGateway] input: ${track.describe()}" } val res = client.get("https://www.musicgateway.com/isrc-finder") { parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}") parameter("type", "name") @@ -143,39 +167,47 @@ class ISRCProviders { } if (res.status != HttpStatusCode.OK) { + logger.w { "[ISRC:musicGateway] failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("musicGateway") val results = body.result.tracks.items if (results.isEmpty()) { + logger.i { "[ISRC:musicGateway] no results" } return@withContext null } var isrcCode: String? = null - for (item in results) { - val isExactTitle = item.name.equals(track.title, ignoreCase = true) - val isArtistMatch = item.artists.any { + for ((_, artists, externalIds, id, name) in results) { + val isExactTitle = name.equals(track.title, ignoreCase = true) + val isArtistMatch = artists.any { it.name.equals( track.artists.firstOrNull()?.name ?: "", ignoreCase = true ) } - val isSameId = item.id == track.id + val isSameId = id == track.id + logger.i { + "[ISRC:musicGateway] candidate id=$id name='$name' artists=${artists.map { it.name }} " + + "isrc=${externalIds.isrc} exactTitle=$isExactTitle artistMatch=$isArtistMatch sameId=$isSameId" + } if (isSameId || (isExactTitle && isArtistMatch)) { - isrcCode = item.externalIds.isrc + isrcCode = externalIds.isrc break } } + logger.i { "[ISRC:musicGateway] result isrc=$isrcCode" } isrcCode } suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) { val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false val uri = "https://open.$greedyGreenCorp.com/track/${track.id}" + logger.i { "[ISRC:soundplate] input: ${track.describe()} useUri=$isGreedyGreenCorp" } val res = client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") { if (isGreedyGreenCorp) { @@ -187,16 +219,39 @@ class ISRCProviders { } if (res.status.value != 200) { + logger.w { "[ISRC:soundplate] failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("soundplate") + logger.i { + "[ISRC:soundplate] returned isrc=${body.isrc} name='${body.name}' artist='${body.artist}' " + + "album='${body.album}' year=${body.year} (input title='${track.title}' " + + "artist='${track.artists.firstOrNull()?.name}') — not validated against input" + } body.isrc } suspend fun auto(track: MetadataTrack): String? { - return runCatching { soundplate(track) }.getOrNull() - ?: runCatching { ifpi(track) }.getOrNull() - ?: runCatching { musicGateway(track) }.getOrNull() + logger.i { "[ISRC:auto] start: ${track.describe()}" } + val providers = listOf String?>>( + "soundplate" to ::soundplate, + "ifpi" to ::ifpi, + "musicGateway" to ::musicGateway) + for ((name, provider) in providers) { + val result = runCatching { provider(track) } + result.exceptionOrNull()?.let { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[ISRC:auto] provider $name threw" } + } + val isrc = result.getOrNull() + logger.i { "[ISRC:auto] provider $name -> $isrc" } + if (isrc != null) { + logger.i { "[ISRC:auto] using isrc=$isrc from $name" } + return isrc + } + } + logger.w { "[ISRC:auto] no provider produced an ISRC" } + return null } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt new file mode 100644 index 00000000..46174a37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -0,0 +1,265 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.newpipe.NewPipeService +import org.spotube.spotube.core.newpipe.SearchKind +import org.spotube.spotube.core.newpipe.VideoSearchResult +import org.koin.core.component.KoinComponent + +class RealNewPipeAudioAPI : AudioAPI, KoinComponent { + private val logger by injectLogger() + private val newPipeService = NewPipeService() + + companion object { + private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$") + private val isrcRegex = Regex("^[A-Z]{2}[A-Z0-9]{3}\\d{7}$") + private const val minConfidence = 0.3f + private const val confidentMatch = 0.6f + } + + private val isrcProvider = ISRCProviders() + + private fun normalize(value: String): String = + value.lowercase() + .replace(Regex("[^\\p{L}\\p{N}]+"), " ") + .trim() + + /** Drops "(feat. X)", "[Remastered]" style suffixes so the core title can be matched. */ + private fun coreTitle(title: String): String = + normalize(title.replace(Regex("[(\\[].*?[)\\]]"), " ")) + + private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { + var confidence = 0f + + val videoTitle = normalize(video.title) + val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) + val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) + val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() } + val title = coreTitle(track.title) + + val videoTokens = videoTitle.split(' ').toSet() + val isTitleMatch = title.isNotEmpty() && + (title in videoTitle || title.split(' ').all { it in videoTokens }) + val isArtistInTitle = artistNames.any { it in videoTitle } + val isAlbumInTitle = track.album?.title?.let { normalize(it) } + ?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false + val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it } + // Unknown duration (0) must neither reward nor punish + val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE + + if (isTitleMatch) confidence += 0.3f + if (isUploaderMatch) confidence += 0.3f + if (durationDiff <= 10_000) confidence += 0.2f + else if (durationDiff <= 30_000) confidence += 0.1f + if (isArtistInTitle) confidence += 0.1f + if (isAlbumInTitle) confidence += 0.1f + // Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos + if (isTopicChannel && isUploaderMatch) confidence += 0.1f + confidence = confidence.coerceAtMost(1f) + + logger.i { + "[Match] video id=${video.id} title='${video.title}' uploader='${video.uploader}' " + + "durationMs=${video.durationMs} url=${video.url}\n" + + " vs track title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs}\n" + + " normalized: videoTitle='$videoTitle' uploader='$uploader' coreTitle='$title' artists=$artistNames\n" + + " titleMatch=$isTitleMatch uploaderMatch=$isUploaderMatch topic=$isTopicChannel " + + "artistInTitle=$isArtistInTitle albumInTitle=$isAlbumInTitle durationDiffMs=$durationDiff " + + "=> confidence=$confidence" + } + + return AudioSource.Basic( + id = video.id, + title = video.title, + artist = video.uploader, + album = null, + thumbnails = listOf( + Thumbnail( + url = video.thumbnailUrl, + width = 300, + height = 300) + ), + externalUri = "https://www.youtube.com/watch?v=${video.id}", + confidence = confidence) + } + + override val supportedQualities: List = listOf( + AudioFormat( + codec = "opus", container = "webm", qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000)) + ), AudioFormat( + codec = "aac", container = "mp4", qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000)) + ) + ) + + override suspend fun getStreamsByTrack(track: MetadataTrack): List { + val isYouTubeUrl = + track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains( + "youtu.be" + )) + val isYouTubeID = track.id.matches(youtubeIDRegex) + + logger.i { + "Searching streams for track(${track.id}): ${track.title} by ${ + track.artists.joinToString( + ", " + ) { it.name } + }" + } + if (isYouTubeID || isYouTubeUrl) { + logger.i { "Track has YouTube ID or URL, fetching video info directly" } + return newPipeService.getVideoInfo(track.id).let { videoInfo -> + listOf( + AudioSource.Streamed( + id = videoInfo.id, + title = videoInfo.title, + artist = videoInfo.uploader, + album = null, + thumbnails = listOf( + Thumbnail( + url = videoInfo.thumbnailUrl, + width = 300, + height = 300) + ), + externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", + confidence = 1.0f, + streams = videoInfo.audioStreams) + ) + } + } + + logger.i { "No YouTube ID or URL found, performing search with track metadata" } + + logger.i { + "[Search] input track id=${track.id} title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs} trackIsrc=${track.isrcCode} " + + "externalUri=${track.externalUri}" + } + val rawIsrc = track.isrcCode ?: isrcProvider.auto(track) + val isrcCode = rawIsrc + ?.trim()?.uppercase()?.replace("-", "") + ?.takeIf { isrcRegex.matches(it) } + logger.i { + "[Search] isrc raw=$rawIsrc (from ${if (track.isrcCode != null) "track" else "providers"}) " + + "validated=$isrcCode" + } + + val artistText = track.artists.joinToString(" ") { it.name }.trim() + val firstArtist = track.artists.firstOrNull()?.name?.trim().orEmpty() + val plainTitle = track.title.replace(Regex("[(\\[].*?[)\\]]"), " ").replace(Regex("\\s+"), " ").trim() + + // Ordered from most to least trusted. The ISRC is only a hint: a text search for it on + // YouTube Music often returns unrelated songs, so every step is scored against the track + // and the next step runs until one result clears [confidentMatch]. + val plan = buildList { + if (isrcCode != null) add("isrc" to (isrcCode to SearchKind.Songs)) + add("songs: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Songs)) + if (plainTitle.isNotEmpty() && firstArtist.isNotEmpty() && plainTitle != track.title) { + add("songs: plain title+first artist" to ("$plainTitle $firstArtist" to SearchKind.Songs)) + } + add("videos: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Videos)) + add("songs: title only" to (plainTitle.ifEmpty { track.title } to SearchKind.Songs)) + } + + val sources = linkedMapOf() + val searched = mutableSetOf>() + // Results of the text searches in YouTube's relevance order, used if nothing scores well + val textSearchResults = linkedMapOf() + for ((stage, request) in plan) { + if (sources.values.any { it.confidence >= confidentMatch }) break + if (!searched.add(request)) continue + val (query, kind) = request + val raw = runCatching { newPipeService.searchVideos(query, kind) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[Search] stage '$stage' failed for query '$query' ($kind)" } + }.getOrDefault(emptyList()) + logger.i { "[Search] stage '$stage' query='$query' kind=$kind -> ${raw.size} results: $raw" } + val scored = raw.map { transformVideo(track, it) } + logger.i { + "[Search] stage '$stage' best confidence=${scored.maxOfOrNull { it.confidence }} " + + "(need >= $confidentMatch to stop)" + } + if (stage != "isrc") scored.forEach { if (it.id !in textSearchResults) textSearchResults[it.id] = it } + scored.forEach { s -> + val existing = sources[s.id] + if (existing == null || existing.confidence < s.confidence) sources[s.id] = s + } + } + + val ranked = sources.values.sortedByDescending { it.confidence } + val matched = ranked.filter { it.confidence >= minConfidence } + logger.i { "[Search] ${matched.size} of ${ranked.size} candidates have confidence >= $minConfidence" } + logger.i { + "[Search] final ranking:\n" + matched.take(5).mapIndexed { i, s -> + " #$i ${s.id} conf=${s.confidence} '${s.title}' by '${s.artist}'" + }.joinToString("\n") + } + + if (matched.isNotEmpty()) return matched.take(5) + + // Nothing scored well. Playing YouTube's best text-search guess beats failing playback, + // and ISRC results are skipped because an ISRC text search tends to return unrelated songs. + val fallback = textSearchResults.values.toList() + .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order + .take(5) + .ifEmpty { ranked.take(5) } + logger.w { + "[Search] no confident match for '${track.title}'; falling back to best guesses: " + + fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } + } + return fallback + } + + override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { + return newPipeService.getVideoInfo(source.id).let { videoInfo -> + listOf( + AudioSource.Streamed( + id = videoInfo.id, + title = videoInfo.title, + artist = videoInfo.uploader, + album = null, + thumbnails = listOf( + Thumbnail( + url = videoInfo.thumbnailUrl, + width = 300, + height = 300) + ), + externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", + confidence = source.confidence, + streams = videoInfo.audioStreams + ) + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt index a8d2e8ec..636c8a41 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumRepository.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.album +package org.spotube.spotube.modules.album -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,8 +36,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class AlbumRepository( val pluginManager: PluginManager, - private val libraryRepository: LibraryRepository, -) { + private val libraryRepository: LibraryRepository) { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val plugin diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt similarity index 69% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt index 8a91a2c1..bd134ba8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumScreen.kt @@ -15,18 +15,26 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.album +package org.spotube.spotube.modules.album import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker +import kotlinx.coroutines.flow.map +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.album_loading_title +import spotube.composeapp.generated.resources.media_unknown_artist @Composable fun AlbumScreen( @@ -37,6 +45,12 @@ fun AlbumScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() @@ -47,7 +61,7 @@ fun AlbumScreen( val dataState = state as? AlbumScreenState.Data val album = dataState?.album val ownerName = - album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" } + album?.artists?.joinToString { it.name }.orEmpty().ifBlank { stringResource(Res.string.media_unknown_artist) } val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url val firstArtistId = album?.artists?.firstOrNull()?.id val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty() @@ -58,7 +72,10 @@ fun AlbumScreen( val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( - title = album?.title ?: "Loading album...", + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, + title = album?.title ?: stringResource(Res.string.album_loading_title), description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}", imageURL = artworkUrl, ownerName = ownerName, @@ -69,10 +86,11 @@ fun AlbumScreen( } }, onPlay = viewModel::playAlbum, - onShufflePlay = {}, + onShufflePlay = viewModel::shufflePlayAlbum, onAddToQueue = viewModel::addAlbumToQueue, isPlaying = isAlbumPlaying, isFollowing = savedAlbumIds.contains(albumId), + isFollowLoading = dataState?.isSaving == true, onFollowClick = viewModel::toggleSavedAlbum, isLoading = isLoading, error = errorMessage, @@ -92,13 +110,15 @@ fun AlbumScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) - }, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt similarity index 66% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt index f705968c..a552af4a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/album/AlbumViewModel.kt @@ -15,24 +15,27 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.album +package org.spotube.spotube.modules.album import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -45,8 +48,12 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface AlbumScreenState { data object Loading : AlbumScreenState @@ -63,15 +70,13 @@ sealed interface AlbumScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data { + override val isSaving: Boolean = false) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( album = album, isSaved = isSaved, tracks = tracks, nextPagination = nextPagination, - isSaving = isSaving, - ) + isSaving = isSaving) } data class LoadingMore( @@ -79,8 +84,7 @@ sealed interface AlbumScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data + override val isSaving: Boolean = false) : Data } data class Error(val message: String) : AlbumScreenState @@ -97,11 +101,16 @@ class AlbumViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(AlbumScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Album, albumId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedAlbumIds get() = libraryRepository.savedAlbumIdsFlow @@ -123,8 +132,7 @@ class AlbumViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -132,8 +140,7 @@ class AlbumViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -172,11 +179,10 @@ class AlbumViewModel( album = albumInfo?.first, isSaved = albumInfo?.second ?: false, tracks = tracksResult?.items ?: emptyList(), - nextPagination = tracksResult?.nextPagination, - ) + nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load album" } - _state.value = AlbumScreenState.Error(e.message ?: "Unknown error") + _state.value = AlbumScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreTracks() = runCatching { @@ -190,12 +196,11 @@ class AlbumViewModel( isSaved = currentState.isSaved, tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - isSaving = currentState.isSaving, - ) + isSaving = currentState.isSaving) } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } - _state.value = AlbumScreenState.Error(e.message ?: "Unknown error") + _state.value = AlbumScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun loadNextTracksPage() { @@ -205,27 +210,61 @@ class AlbumViewModel( } fun toggleSavedAlbum() { + if ((_state.value as? AlbumScreenState.Data)?.isSaving == true) return + setSaving(true) viewModelScope.launch { - val isLiked = - libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false - if (isLiked) { - libraryRepository.removeSavedAlbums(listOf(albumId)) - } else { - libraryRepository.saveAlbums(listOf(albumId)) + runCatching { + val isLiked = + libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedAlbums(listOf(albumId)) + } else { + libraryRepository.saveAlbums(listOf(albumId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved album" } + } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is AlbumScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is AlbumScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state } } } fun playAlbum() { - viewModelScope.launch { playbackHelper.playAlbum(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) + } + + fun shufflePlayAlbum() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title, shuffle = true) } fun addAlbumToQueue() { - viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) + } + + fun playAlbumNext() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title) } fun playAlbumFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Album, + id = albumId, + title = title, + startTrack = track) } fun refresh() { @@ -241,14 +280,24 @@ class AlbumViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -288,8 +337,7 @@ class AlbumViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -311,34 +359,18 @@ class AlbumViewModel( tracks.forEach { track -> downloadManager.enqueue(track) } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksPlayNext(tracks, title) } fun isTrackBlacklisted(track: MetadataTrack): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt index f51cc187..b421fee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistRepository.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.artist +package org.spotube.spotube.modules.artist -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.MetadataArtistOverview -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt new file mode 100644 index 00000000..5b2459e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt @@ -0,0 +1,753 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.artist + + +import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +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.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.koin.compose.koinInject +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackList +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.User +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.math.roundToInt +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun ArtistScreen( + viewModel: ArtistViewModel, + audioPlayerQueue: AudioPlayerQueue, + audioPlayer: AudioPlayerInterface, + navigationCommands: NavigationCommands +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() + val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() + val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle() + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() + val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() + val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + ApplicationMainBar() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + when (val currentState = state) { + is ArtistScreenState.Loading -> { + ArtistLoadingContent() + } + + is ArtistScreenState.Error -> { + ArtistErrorContent( + message = currentState.message, + onRetry = { viewModel.refresh() } + ) + } + + is ArtistScreenState.Loaded -> { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + item { + ArtistHeaderCard( + artist = currentState.artist, + isSaved = savedArtistIds.contains(currentState.artist.id), + isSaving = currentState.isSaving, + onFollowClick = viewModel::toggleSavedArtist, + isBlacklisted = currentState.artist.id in blacklistedArtistIds, + onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) + } + + item { + TopTracksHeader( + onPlay = viewModel::playTopTracks, + onAddToQueue = viewModel::addTopTracksToQueue, + isPlayLoading = playbackUiState.isPlayLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading + ) + } + + item { + TrackList( + tracks = currentState.topTracks, + error = null, + hasMore = false, + isLoading = false, + isLoadingNextPage = false, + currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, + isCurrentTrackPlaying = playerState == PlayerState.PLAYING, + onTrackClick = viewModel::playTopTracksFromTrack, + onTrackOptionsAction = viewModel::handleTrackOptionsAction, + trackOptionsState = trackOptionsContext::stateFor, + onArtistClick = { trackArtist -> + navigationCommands.navigateTo( + Routes.Artist( + trackArtist.id + ) + ) + }, + onAlbumClick = { album -> + navigationCommands.navigateTo( + Routes.Album( + album.id + ) + ) + }, + onLoadNextPage = {}, + simplified = true, + scrollable = false, + onBulkDownload = viewModel::downloadTracks, + onBulkAddToQueue = viewModel::addTracksToQueue, + onBulkPlayNext = viewModel::playTracksNext, + onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest + ) + } + + if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { + item { + AlbumsSection( + albums = currentState.albums, + onAlbumClick = { album -> + navigationCommands.navigateTo( + Routes.Album( + album.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreAlbums() }) + } + } + + if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) { + item { + RelatedArtistsSection( + artists = currentState.relatedArtists, + onArtistClick = { artist -> + navigationCommands.navigateTo( + Routes.Artist( + artist.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreRelatedArtists() }) + } + } + + if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) { + item { + FeaturedPlaylistsSection( + playlists = currentState.featuredPlaylists, + onPlaylistClick = { playlist -> + navigationCommands.navigateTo( + Routes.Playlist( + playlist.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }) + } + } + } + } + } + } + + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = viewModel::dismissAddToPlaylistPicker, + onPlaylistSelected = viewModel::addTracksToPlaylist + ) + } +} + +@Composable +private fun ArtistLoadingContent() { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SkeletonTree(true) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .size(200.dp) + .clip(CircleShape) + .background(AppTheme.colors.surfaceContainerHighest) + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(4) { + PlayableCard( + title = "Item Title", + subtitle = "Subtitle", + imageURL = "https://placehold.co/600x400" + ) + } + } + } + } + } + } +} + +@Composable +private fun ArtistErrorContent( + message: String, + onRetry: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = message, + color = AppTheme.colors.error, + style = AppTheme.typography.bodyLarge + ) + MillenniumButton(onClick = onRetry) { + Text(stringResource(Res.string.common_retry)) + } + } + } +} + +@Composable +private fun ArtistHeaderCard( + artist: MetadataArtist.Detailed, + isSaved: Boolean, + isSaving: Boolean, + onFollowClick: () -> Unit, + isBlacklisted: Boolean, + onBlacklistClick: () -> Unit +) { + Box(modifier = Modifier.fillMaxWidth()) { + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium + + MillenniumCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + if (isCompact) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ArtistAvatar( + artist = artist, + size = 180.dp + ) + + ArtistMeta( + artist = artist, + isCompact = true + ) + + ArtistHeaderActions( + isSaved = isSaved, + isSaving = isSaving, + onFollowClick = onFollowClick, + isBlacklisted = isBlacklisted, + onBlacklistClick = onBlacklistClick + ) + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.Top + ) { + ArtistAvatar( + artist = artist, + size = 220.dp + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + ArtistMeta( + artist = artist, + isCompact = false + ) + + ArtistHeaderActions( + isSaved = isSaved, + isSaving = isSaving, + onFollowClick = onFollowClick, + isBlacklisted = isBlacklisted, + onBlacklistClick = onBlacklistClick + ) + } + } + } + } + } +} + +@Composable +private fun ArtistAvatar( + artist: MetadataArtist.Detailed, + size: androidx.compose.ui.unit.Dp +) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center + ) { + val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() + if (imageUrl.isNotBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = artist.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = artist.name, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(size * 0.4f) + ) + } + } +} + +@Composable +private fun ArtistMeta( + artist: MetadataArtist.Detailed, + isCompact: Boolean +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = artist.name, + style = if (isCompact) AppTheme.typography.headlineSmall else AppTheme.typography.headlineLarge, + fontWeight = FontWeight.SemiBold, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = buildString { + append(formatFollowers(artist.followersCount)) + if (artist.genres.isNotEmpty()) { + append(" • ") + append(artist.genres.joinToString(", ")) + } + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start + ) + + artist.biography?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + maxLines = if (isCompact) 4 else 6, + overflow = TextOverflow.Ellipsis, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start + ) + } + } +} + +@Composable +private fun ArtistHeaderActions( + isSaved: Boolean, + isSaving: Boolean, + onFollowClick: () -> Unit, + isBlacklisted: Boolean, + onBlacklistClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isSaved) { + MillenniumButton( + onClick = onFollowClick, + loading = isSaving, + style = MillenniumButtonStyle.Secondary + ) { + Text( + stringResource(Res.string.artist_following), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + MillenniumButton(onClick = onFollowClick, loading = isSaving) { + Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) + } + } + + MillenniumTooltip( + text = if (isBlacklisted) stringResource(Res.string.blacklist_remove) else stringResource(Res.string.blacklist_add) + ) { + if (isBlacklisted) { + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = stringResource(Res.string.blacklist_remove), + tint = AppTheme.colors.error + ) + } + } else { + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = stringResource(Res.string.blacklist_add) + ) + } + } + } + } +} + +@Composable +private fun TopTracksHeader( + onPlay: () -> Unit, + onAddToQueue: () -> Unit, + isPlayLoading: Boolean = false, + isAddToQueueLoading: Boolean = false +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(Res.string.artist_top_tracks), + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumIconButton( + onClick = onPlay, + loading = isPlayLoading, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxPlay, + contentDescription = stringResource(Res.string.artist_play_top_tracks), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = onAddToQueue, + loading = isAddToQueueLoading, + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.artist_add_top_tracks_to_queue) + ) + } + } + } +} + +@Composable +private fun AlbumsSection( + albums: List, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onLoadMore: () -> Unit +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = stringResource(Res.string.library_tab_albums)) + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + itemsWithUniqueKeys(albums, id = { it.id }) { album -> + AlbumCard(album = album) + } + } +} + +@Composable +private fun RelatedArtistsSection( + artists: List, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onLoadMore: () -> Unit +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = stringResource(Res.string.artist_related_artists)) + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + itemsWithUniqueKeys(artists, id = { it.id }) { artist -> + ArtistCard(artist = artist) + } + } +} + +@Composable +private fun FeaturedPlaylistsSection( + playlists: List, + onPlaylistClick: (MetadataPlaylist) -> Unit, + onLoadMore: () -> Unit +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = stringResource(Res.string.artist_featured_playlists)) + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + itemsWithUniqueKeys(playlists, id = { it.id }) { playlist -> + PlaylistCard(playlist = playlist) + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp) + ) +} + +@Composable +private fun formatFollowers(count: Int?): String { + if (count == null) return stringResource(Res.string.artist_followers_unavailable) + return when { + count >= 1_000_000_000 -> stringResource(Res.string.artist_followers_billions, formatAbbreviatedCount(count, 1_000_000_000)) + count >= 1_000_000 -> stringResource(Res.string.artist_followers_millions, formatAbbreviatedCount(count, 1_000_000)) + count >= 1_000 -> stringResource(Res.string.artist_followers_thousands, formatAbbreviatedCount(count, 1_000)) + else -> pluralStringResource(Res.plurals.artist_followers_count, count, count) + } +} + +private fun formatAbbreviatedCount(count: Int, divisor: Int): String { + val scaled = count / divisor.toDouble() + val rounded = (scaled * 10).roundToInt() / 10.0 + return if (rounded % 1.0 == 0.0) { + rounded.toInt().toString() + } else { + rounded.toString() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt similarity index 73% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt index e6af958c..ce18fe50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistViewModel.kt @@ -15,25 +15,28 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.artist +package org.spotube.spotube.modules.artist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -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.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -42,8 +45,11 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.* sealed interface ArtistScreenState { data object Loading : ArtistScreenState @@ -58,7 +64,7 @@ sealed interface ArtistScreenState { val relatedArtistsNextPagination: PaginationStrategy?, val featuredPlaylists: List, val featuredPlaylistsNextPagination: PaginationStrategy?, - ) : ArtistScreenState + val isSaving: Boolean = false) : ArtistScreenState data class Error(val message: String) : ArtistScreenState } @@ -73,12 +79,17 @@ class ArtistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(ArtistScreenState.Loading) val state: StateFlow = _state.asStateFlow() + /** Loading flags for the top-tracks play / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.ArtistTopTracks, artistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) + val savedArtistIds get() = libraryRepository.savedArtistIdsFlow @@ -103,8 +114,7 @@ class ArtistViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -112,8 +122,7 @@ class ArtistViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -151,13 +160,26 @@ class ArtistViewModel( } fun toggleSavedArtist() { + if ((_state.value as? ArtistScreenState.Loaded)?.isSaving == true) return + setSaving(true) viewModelScope.launch { - val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false - if (isLiked) { - libraryRepository.removeSavedArtists(listOf(artistId)) - } else { - libraryRepository.saveArtists(listOf(artistId)) + runCatching { + val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false + if (isLiked) { + libraryRepository.removeSavedArtists(listOf(artistId)) + } else { + libraryRepository.saveArtists(listOf(artistId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved artist" } } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + if (state is ArtistScreenState.Loaded) state.copy(isSaving = isSaving) else state } } @@ -195,8 +217,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( albums = currentState.albums + result.items, - albumsNextPagination = result.nextPagination, - ) + albumsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } @@ -216,8 +237,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( relatedArtists = currentState.relatedArtists + result.items, - relatedArtistsNextPagination = result.nextPagination, - ) + relatedArtistsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more related artists" } @@ -237,8 +257,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( featuredPlaylists = currentState.featuredPlaylists + result.items, - featuredPlaylistsNextPagination = result.nextPagination, - ) + featuredPlaylistsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more featured playlists" } @@ -247,80 +266,41 @@ class ArtistViewModel( } fun addTopTracksToQueue() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName) } fun playTopTracks() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = 0, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName) } fun playTopTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { - val queue = audioPlayerQueue.getQueue() - val queueIndex = queue.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - } - if (queueIndex >= 0) { - audioPlayerQueue.jumpTo(queueIndex) - return@launch - } - - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - - val startPosition = entries.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }.coerceAtLeast(0) - - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = startPosition, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.ArtistTopTracks, + id = artistId, + title = artistName, + startTrack = track) } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksAddToQueue(tracks, artistName) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksPlayNext(tracks, artistName) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { @@ -329,13 +309,23 @@ class ArtistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() @@ -372,8 +362,7 @@ class ArtistViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -412,14 +401,13 @@ class ArtistViewModel( relatedArtists = overview.relatedArtists.items, relatedArtistsNextPagination = overview.relatedArtists.nextPagination, featuredPlaylists = overview.featuredPlaylists.items, - featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination, - ) + featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination) } else { - _state.value = ArtistScreenState.Error("Failed to load artist overview") + _state.value = ArtistScreenState.Error(getString(Res.string.artist_error_load_failed)) } }.onFailure { e -> logger.e(e) { "Failed to load artist overview" } - _state.value = ArtistScreenState.Error(e.message ?: "Unknown error") + _state.value = ArtistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt index ee9f4d56..394d264b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistRepository.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.blacklist +package org.spotube.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 org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -29,8 +29,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.encodeToString open class BlacklistRepository( - private val database: Database, -) { + private val database: Database) { companion object { private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks") private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists") diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt new file mode 100644 index 00000000..dac469e7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistScreen.kt @@ -0,0 +1,303 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.blacklist + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField +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 org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.misc.shimmerApply +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxMusicCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemoveFilled +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.User +import org.koin.compose.viewmodel.koinViewModel +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun BlacklistScreen() { + val viewModel = koinViewModel() + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = { Text(stringResource(Res.string.settings_blacklist_title)) }) + BlacklistContent( + viewModel = viewModel, + modifier = Modifier.weight(1f)) + } +} + +/** + * The blacklist UI without its own app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen]. + */ +@Composable +internal fun BlacklistContent( + viewModel: BlacklistViewModel, + modifier: Modifier = Modifier) { + val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle() + val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + val filterState = rememberTextFieldState("") + val filterQuery = filterState.text.toString() + val normalizedQuery = 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() + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumTextField( + state = filterState, + label = "", + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.blacklist_filter_placeholder)) + + if (isEmpty) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center) { + Text( + text = if (hasActiveFilter) { + stringResource(Res.string.blacklist_no_results, filterQuery) + } else { + stringResource(Res.string.blacklist_empty) + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.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 = stringResource(Res.string.blacklist_section_tracks), + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.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 = stringResource(Res.string.library_tab_artists), + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.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)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicCircle, + contentDescription = stringResource(Res.string.blacklist_item_track), + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.onSurfaceVariant) + + AsyncImage( + model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(48.dp) + .clip(AppTheme.shapes.small) + .shimmerApply()) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicSquareRemoveFilled, + contentDescription = stringResource(Res.string.blacklist_remove), + tint = AppTheme.colors.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)) { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = stringResource(Res.string.media_artist), + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.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 { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = artist.name, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(24.dp)) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = artist.name, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = stringResource(Res.string.media_artist), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = stringResource(Res.string.blacklist_remove), + tint = AppTheme.colors.error) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt new file mode 100644 index 00000000..be47579b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModek.kt @@ -0,0 +1,2 @@ +package org.spotube.spotube.modules.blacklist + diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt similarity index 85% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt index d4315086..a4b1ae81 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/blacklist/BlacklistViewModel.kt @@ -15,20 +15,19 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.blacklist +package org.spotube.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 org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.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() { + private val repository: BlacklistRepository) : ViewModel() { val blacklistedTracks: StateFlow> = repository.blacklistedTracks .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt new file mode 100644 index 00000000..e9d946d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -0,0 +1,414 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.devices + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.CircleShape + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.remoteControlSettingCards +import org.spotube.spotube.modules.settings.sections.settingsSectionCard +import org.spotube.spotube.modules.settings.sections.settingsSectionHeader +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_disconnect +import spotube.composeapp.generated.resources.devices_empty +import spotube.composeapp.generated.resources.devices_empty_hint +import spotube.composeapp.generated.resources.devices_open_remote +import spotube.composeapp.generated.resources.devices_refresh +import spotube.composeapp.generated.resources.devices_resolving +import spotube.composeapp.generated.resources.devices_search_again +import spotube.composeapp.generated.resources.devices_searching +import spotube.composeapp.generated.resources.devices_searching_more +import spotube.composeapp.generated.resources.devices_section_available +import spotube.composeapp.generated.resources.devices_section_connected +import spotube.composeapp.generated.resources.devices_section_this_device +import spotube.composeapp.generated.resources.devices_status_connected +import spotube.composeapp.generated.resources.devices_title +import spotube.composeapp.generated.resources.devices_unknown_device +import spotube.composeapp.generated.resources.jam_error_dismiss + +private val ContentMaxWidth = 720.dp + +@Composable +fun DevicesScreen( + viewModel: DevicesViewModel = koinViewModel(), + settingsViewModel: SettingsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val settings by settingsViewModel.settingsState.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val shellBottomInset = LocalAppShellBottomInset.current + + DisposableEffect(Unit) { + // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. + requestLocalNetworkPermission() + viewModel.startDiscovery() + onDispose { + // The remote connection itself outlives this screen; only discovery stops. + viewModel.stopDiscovery() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + ApplicationMainBar( + backButton = true, + title = { Text(stringResource(Res.string.devices_title)) }, + actions = { + MillenniumIconButton( + onClick = viewModel::startDiscovery, + enabled = !state.isConnecting, + loading = state.isDiscovering, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.devices_refresh) + ) + } + } + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + LazyColumn( + modifier = Modifier + .widthIn(max = ContentMaxWidth) + .fillMaxSize(), + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + top = 8.dp, + bottom = shellBottomInset + 16.dp + ) + ) { + state.error?.let { error -> + item { + ErrorBanner( + message = error.rawMessage + ?: stringResource(error.message, *error.args.toTypedArray()), + onDismiss = viewModel::clearError + ) + } + } + + state.connectedDevice?.let { device -> + settingsSectionHeader(Res.string.devices_section_connected) + item { + ConnectedDeviceCard( + device = device, + onOpenRemote = viewModel::openRemote, + onDisconnect = viewModel::disconnect + ) + } + } + + if (state.devices.isNotEmpty()) { + settingsSectionHeader(Res.string.devices_section_available) + settingsSectionCard( + items = state.devices.map { device -> + { + DeviceTile( + device = device, + enabled = !state.isConnecting || device.isConnecting, + onClick = { viewModel.onDeviceClick(device.key) } + ) + } + } + ) + if (state.isDiscovering) { + item { + Text( + text = stringResource(Res.string.devices_searching_more), + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) + } + } + } else if (state.showEmptyState) { + item { + EmptyState( + isDiscovering = state.isDiscovering, + onSearchAgain = viewModel::startDiscovery + ) + } + } + + // Same cards as Settings > Playback, so making this device controllable (or renaming + // it) doesn't require a detour through Settings. + settings?.let { current -> + settingsSectionHeader(Res.string.devices_section_this_device) + settingsSectionCard( + items = remoteControlSettingCards(current, settingsViewModel, requestLocalNetworkPermission) + ) + } + } + } + } +} + +@Composable +private fun DeviceTile( + device: DeviceItemUi, + enabled: Boolean, + onClick: () -> Unit +) { + val name = device.name ?: stringResource(Res.string.devices_unknown_device) + SettingCardItem( + enabled = enabled, + title = name, + subtitle = device.address ?: stringResource(Res.string.devices_resolving), + icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) }, + trailingContent = { + when { + device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp) + device.isConnected -> Text( + text = stringResource(Res.string.devices_status_connected), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.primary + ) + else -> MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant + ) + } + }, + onClick = onClick + ) +} + +@Composable +private fun LazyItemScope.ConnectedDeviceCard( + device: ConnectedDeviceUi, + onOpenRemote: () -> Unit, + onDisconnect: () -> Unit +) { + val name = device.name ?: stringResource(Res.string.devices_unknown_device) + MillenniumCard( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + maxWidth = ContentMaxWidth, + contentPadding = PaddingValues(16.dp), + contentSpacing = 16.dp, + shadowElevation = 6.dp + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = AppTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = device.address, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MillenniumButton( + onClick = onDisconnect, + loading = device.isDisconnecting, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.devices_disconnect)) + } + MillenniumButton( + onClick = onOpenRemote, + style = MillenniumButtonStyle.Primary + ) { + Text(stringResource(Res.string.devices_open_remote)) + } + } + } +} + +@Composable +private fun ErrorBanner( + message: String, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .clip(AppTheme.shapes.medium) + .background(AppTheme.colors.errorContainer) + .padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxInformation, + contentDescription = null, + tint = AppTheme.colors.onErrorContainer, + modifier = Modifier.size(20.dp) + ) + Text( + text = message, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onErrorContainer, + modifier = Modifier.weight(1f) + ) + MillenniumIconButton( + onClick = onDismiss, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.jam_error_dismiss), + tint = AppTheme.colors.onErrorContainer + ) + } + } +} + +@Composable +private fun LazyItemScope.EmptyState( + isDiscovering: Boolean, + onSearchAgain: () -> Unit +) { + // Fixed padding rather than filling the viewport, so the "This device" section below stays visible. + Box( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = 24.dp, vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + if (isDiscovering) { + MillenniumSpokeSpinner(size = 32.dp) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxWifiSquare, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(32.dp) + ) + } + } + Text( + text = stringResource( + if (isDiscovering) Res.string.devices_searching else Res.string.devices_empty + ), + style = AppTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = stringResource(Res.string.devices_empty_hint), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (!isDiscovering) { + MillenniumButton( + onClick = onSearchAgain, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(stringResource(Res.string.devices_search_again)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt new file mode 100644 index 00000000..19f365ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -0,0 +1,314 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.discovery.DeviceDiscoveryService +import org.spotube.spotube.core.discovery.DiscoveredDevice +import org.spotube.spotube.core.discovery.DiscoveryState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlService +import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_error_connect_failed +import spotube.composeapp.generated.resources.devices_error_discovery_failed + +/** + * A user-facing error for the devices screen. [rawMessage] (an untranslated + * cause reported by the transport) takes precedence when present; otherwise the + * composable resolves [message] with [args]. + */ +data class DevicesError( + val message: StringResource, + val args: List = emptyList(), + val rawMessage: String? = null) + +data class DeviceItemUi( + val key: String, + /** Null when the device advertised a blank name. */ + val name: String?, + /** `host:port`, or null while the address is still being resolved. */ + val address: String?, + val isConnecting: Boolean, + val isConnected: Boolean) + +data class ConnectedDeviceUi( + val name: String?, + val address: String, + val isDisconnecting: Boolean = false) + +data class DevicesUiState( + val devices: List = emptyList(), + val isDiscovering: Boolean = false, + val isConnecting: Boolean = false, + val connectedDevice: ConnectedDeviceUi? = null, + val error: DevicesError? = null) { + val showEmptyState: Boolean get() = devices.isEmpty() && !isConnecting +} + +class DevicesViewModel( + private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { + private val logger = Logger.withTag("DevicesViewModel") + private val discoveryService: DeviceDiscoveryService by inject() + private val remoteControlClient: RemoteControlClient by inject() + private val remoteControlService: RemoteControlService by inject() + private val settingsProvider: SettingsProvider by inject() + + private val _devices = MutableStateFlow>(emptyMap()) + private val _isDiscovering = MutableStateFlow(false) + private val _connectingToDevice = MutableStateFlow(null) + private val _error = MutableStateFlow(null) + private val _isDisconnecting = MutableStateFlow(false) + + val uiState: StateFlow = combine( + _devices, + _isDiscovering, + _connectingToDevice, + remoteControlClient.connectionState, + combine(_error, _isDisconnecting, ::Pair) + ) { devices, isDiscovering, connectingTo, connection, (error, isDisconnecting) -> + val connected = connection as? ConnectionState.Connected + DevicesUiState( + devices = devices.values.map { device -> + DeviceItemUi( + key = device.key, + name = device.name.ifBlank { null }, + address = device.address(), + isConnecting = connectingTo?.key == device.key, + isConnected = connected != null && device.matches(connected)) + }, + isDiscovering = isDiscovering, + isConnecting = connectingTo != null, + connectedDevice = connected?.let { + ConnectedDeviceUi( + name = it.deviceName.ifBlank { null }, + address = "${it.host}:${it.port}", + isDisconnecting = isDisconnecting) + }, + error = error) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) + + private var discoveryJob: Job? = null + + init { + viewModelScope.launch { + var previous: ConnectionState = remoteControlClient.connectionState.value + remoteControlClient.connectionState.collect { state -> + val wasConnecting = previous is ConnectionState.Connecting + previous = state + when (state) { + is ConnectionState.Error -> { + _error.value = DevicesError(message = state.fallback, rawMessage = state.message) + _connectingToDevice.value = null + } + // Only a drop *during* an attempt means it failed. Switching devices + // disconnects the old one first, and that must not cancel the new attempt. + is ConnectionState.Disconnected -> if (wasConnecting) _connectingToDevice.value = null + is ConnectionState.Connected -> { + // Only navigate for a connection this screen started. A connection + // that already existed when the screen opened must not bounce the + // user straight to the remote screen. + if (_connectingToDevice.value != null) { + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + is ConnectionState.Connecting -> {} + } + } + } + // Whenever the local device id is resolved, drop any of our own + // advertisements that may have been picked up before we knew our id. + viewModelScope.launch { + remoteControlService.localDeviceId.collect { id -> + if (id.isNotBlank()) { + removeSelf() + } + } + } + } + + fun startDiscovery() { + if (discoveryJob?.isActive == true) return + _isDiscovering.value = true + _error.value = null + logger.i { "Starting device discovery" } + // Advertising may have failed before the local-network permission was + // granted; give it another chance now that discovery is being used. + remoteControlService.retryAdvertising() + discoveryJob = viewModelScope.launch { + try { + discoveryService.discover().collect { event -> + logger.d { "Discovery event: $event" } + when (event) { + is DiscoveryState.Discovered -> { + // Don't hold up the rest of the events while this device resolves. + launch { event.resolve() } + if (!isSelf(event.device)) { + _devices.update { it + (event.device.key to event.device.copy()) } + } + } + is DiscoveryState.Resolved -> { + if (isSelf(event.device)) { + // Resolved now carries our deviceId in TXT; drop self. + _devices.update { it - event.device.key } + } else { + _devices.update { it + (event.device.key to event.device) } + } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } + } + } + } catch (e: CancellationException) { + // stopDiscovery() (e.g. leaving the screen) is not a failure. + throw e + } catch (e: Exception) { + logger.e(e) { "Discovery failed" } + _error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString())) + _isDiscovering.value = false + } + } + } + + /** + * True when [device] is this device's own advertisement. On the initial + * `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is + * empty), so we match by the name we advertise; once resolved we also have + * the authoritative deviceId. + */ + private fun isSelf(device: DiscoveredDevice): Boolean { + val localId = remoteControlService.localDeviceId.value.ifBlank { + settingsProvider.settingsState.value?.remoteControlDeviceId ?: "" + } + if (localId.isNotBlank() && device.deviceId == localId) return true + // Match by the deterministic advertised name as a fallback for the + // pre-resolution event where deviceId isn't available yet. + return device.name.isNotBlank() && device.name == remoteControlService.advertisedName() + } + + private fun removeSelf() { + val localId = remoteControlService.localDeviceId.value + if (localId.isBlank()) return + _devices.update { map -> + map.filterNot { (_, device) -> device.deviceId == localId } + } + } + + fun stopDiscovery() { + discoveryJob?.cancel() + discoveryJob = null + _isDiscovering.value = false + } + + fun onDeviceClick(key: String) { + val device = _devices.value[key] ?: return + val connected = remoteControlClient.connectionState.value as? ConnectionState.Connected + when { + // Already controlling it (e.g. came back from the remote screen): just reopen it. + connected != null && device.matches(connected) -> openRemote() + connected != null -> viewModelScope.launch { + remoteControlClient.disconnect() + connectToDevice(device) + } + else -> connectToDevice(device) + } + } + + fun openRemote() { + navigationCommands.navigateTo(Routes.RemoteControl) + } + + private fun connectToDevice(device: DiscoveredDevice) { + if (_connectingToDevice.value != null) { + logger.w { "Already connecting to a device" } + return + } + if (isSelf(device)) { + logger.w { "Refusing to connect to self: ${device.name}" } + return + } + + _connectingToDevice.value = device + _error.value = null + logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + + viewModelScope.launch { + try { + val settings = settingsProvider.settingsState.value + val deviceId = settings?.remoteControlDeviceId ?: "" + val deviceName = settings?.remoteControlDeviceName?.ifBlank { "Spotube Controller" } + ?: "Spotube Controller" + + remoteControlClient.connect( + host = device.host, + port = device.port, + deviceId = deviceId, + deviceName = deviceName, + remoteDeviceName = device.name) + // connect() returns once the socket is open, but the host may still be + // asking its user for permission. The connection-state collector clears + // the connecting flag once the outcome is known. + } catch (e: Exception) { + logger.e(e) { "Failed to connect to device" } + _error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString())) + _connectingToDevice.value = null + } + } + } + + fun disconnect() { + if (_isDisconnecting.value) return + _isDisconnecting.value = true + viewModelScope.launch { + try { + remoteControlClient.disconnect() + } finally { + _isDisconnecting.value = false + } + } + } + + fun clearError() { + _error.value = null + } +} + +private fun DiscoveredDevice.address(): String? = + if (host.isNotBlank() && port > 0) "$host:$port" else null + +private fun DiscoveredDevice.matches(connected: ConnectionState.Connected): Boolean = + host == connected.host && port == connected.port diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt new file mode 100644 index 00000000..849b7c5c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/PlayDestinationPicker.kt @@ -0,0 +1,158 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.devices + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.PlaybackDestinationAction +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_destination_action_add_to_queue +import spotube.composeapp.generated.resources.devices_destination_action_play +import spotube.composeapp.generated.resources.devices_destination_action_play_next +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.devices_destination_prompt +import spotube.composeapp.generated.resources.devices_destination_remote_device +import spotube.composeapp.generated.resources.devices_destination_remote_device_host +import spotube.composeapp.generated.resources.devices_destination_remote_subtitle +import spotube.composeapp.generated.resources.devices_destination_this_device +import spotube.composeapp.generated.resources.devices_destination_this_device_subtitle +import spotube.composeapp.generated.resources.devices_destination_title + +/** + * Globally hosted dialog shown when the user tries to play / add to queue / + * play next and there is more than one place it could go (a connected remote + * device and/or an active jam session). Lets the user choose the destination. + */ +@Composable +fun PlayDestinationPickerHost() { + val controller = koinInject() + val remoteControlClient = koinInject() + val request by controller.pendingRequest.collectAsStateWithLifecycle() + val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + + val pendingRequest = request ?: return + + val remoteDeviceName = when (val state = connectionState) { + is ConnectionState.Connected -> stringResource(Res.string.devices_destination_remote_device_host, state.host) + else -> stringResource(Res.string.devices_destination_remote_device) + } + + val actionLabel = when (pendingRequest.action) { + PlaybackDestinationAction.Play -> stringResource(Res.string.devices_destination_action_play) + PlaybackDestinationAction.AddToQueue -> stringResource(Res.string.devices_destination_action_add_to_queue) + PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next) + } + + MillenniumDialog(visible = true, onDismissRequest = controller::dismissPicker) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.devices_destination_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(Res.string.devices_destination_prompt, actionLabel, pendingRequest.title), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + + MillenniumListTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = AppTheme.colors.primary) + }, + title = { + Text( + text = stringResource(Res.string.devices_destination_this_device), + style = AppTheme.typography.bodyLarge) + }, + subtitle = { + Text( + text = stringResource(Res.string.devices_destination_this_device_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) + + if (connectionState is ConnectionState.Connected) { + MillenniumListTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = AppTheme.colors.primary) + }, + title = { + Text( + text = remoteDeviceName, + style = AppTheme.typography.bodyLarge) + }, + subtitle = { + Text( + text = stringResource(Res.string.devices_destination_remote_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) + } + + } + } + MillenniumDialogFooter { + MillenniumButton( + onClick = controller::dismissPicker, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.common_cancel)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt new file mode 100644 index 00000000..765df60b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt @@ -0,0 +1,833 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.devices + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import androidx.compose.runtime.Composable +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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.modules.shell.player_queue.QueueSheet +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross +import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.devices_connecting +import spotube.composeapp.generated.resources.devices_disconnect +import spotube.composeapp.generated.resources.devices_remote_back_to_devices +import spotube.composeapp.generated.resources.devices_remote_connecting_hint +import spotube.composeapp.generated.resources.devices_remote_connection_error +import spotube.composeapp.generated.resources.devices_remote_disconnected +import spotube.composeapp.generated.resources.devices_remote_disconnected_hint +import spotube.composeapp.generated.resources.devices_remote_more_options +import spotube.composeapp.generated.resources.devices_remote_nothing_playing +import spotube.composeapp.generated.resources.devices_remote_nothing_playing_hint +import spotube.composeapp.generated.resources.devices_remote_playing_on +import spotube.composeapp.generated.resources.devices_remote_remove_from_queue +import spotube.composeapp.generated.resources.devices_remote_title +import spotube.composeapp.generated.resources.devices_remote_unknown_artist +import spotube.composeapp.generated.resources.devices_remote_unknown_track +import spotube.composeapp.generated.resources.devices_remote_volume +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.queue_no_queue_entries +import spotube.composeapp.generated.resources.queue_title + +// Below this content width the queue moves into a sheet; above it, it sits beside the player. +private val WideLayoutMinWidth = 760.dp +private val QueuePanelWidth = 380.dp +private val NowPlayingMaxWidth = 520.dp + +// Rough height of everything under the artwork (track info, seek bar, controls, volume). +// The artwork gets what's left, so the whole player fits without scrolling. +private val ControlsReservedHeight = 330.dp +private val MinArtworkSize = 140.dp +private val MaxArtworkSize = 460.dp + +@Composable +fun RemoteControlScreen( + onDisconnect: () -> Unit, + viewModel: RemoteControlViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + val isConnected = state.connection is ConnectionState.Connected + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + val isWide = maxWidth >= WideLayoutMinWidth + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { Text(stringResource(Res.string.devices_remote_title)) }, + subtitle = { + if (isConnected && state.deviceName.isNotBlank()) { + Text(stringResource(Res.string.devices_remote_playing_on, state.deviceName)) + } + }, + backButton = true, + actions = { + if (isConnected && !isWide) { + MillenniumIconButton( + onClick = viewModel::toggleQueueVisibility, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = stringResource(Res.string.queue_title), + tint = if (state.isQueueVisible) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } + } + if (isConnected) { + MillenniumIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.devices_disconnect) + ) + } + } + } + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(bottom = shellBottomInset) + ) { + when (val connection = state.connection) { + is ConnectionState.Connected -> { + if (isWide) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + MillenniumCard( + modifier = Modifier + .width(QueuePanelWidth) + .fillMaxHeight(), + maxWidth = QueuePanelWidth, + contentPadding = PaddingValues(12.dp), + shadowElevation = 2.dp + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) + } + } + } else { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier.fillMaxSize() + ) + } + } + + is ConnectionState.Connecting -> ConnectionStatus( + title = stringResource(Res.string.devices_connecting), + message = stringResource(Res.string.devices_remote_connecting_hint), + leading = { MillenniumSpokeSpinner(size = 40.dp) } + ) + + is ConnectionState.Disconnected -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource(Res.string.devices_remote_disconnected_hint), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = false) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + + is ConnectionState.Error -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource( + Res.string.devices_remote_connection_error, + connection.message ?: stringResource(connection.fallback) + ), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = true) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + } + } + } + + if (isConnected && !isWide) { + // Click-outside scrim for the sliding drawer variant of the queue sheet. + // (The bottom sheet variant has its own built-in scrim.) + if (state.isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = viewModel::dismissQueue + ) + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset) + ) { + QueueSheet( + isVisible = state.isQueueVisible, + onDismiss = viewModel::dismissQueue, + modifier = Modifier.fillMaxSize() + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) + } + } + } + } +} + +@Composable +private fun NowPlayingPane( + state: RemoteControlUiState, + viewModel: RemoteControlViewModel, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier) { + val artworkSize = minOf( + maxWidth - 32.dp, + maxHeight - ControlsReservedHeight, + MaxArtworkSize + ).coerceAtLeast(MinArtworkSize) + + // Centered when it fits; scrolls only on very short windows. + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier.widthIn(max = NowPlayingMaxWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Artwork( + coverUrl = state.coverUrl, + contentDescription = state.title, + size = artworkSize + ) + TrackInfo( + state = state, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) + SeekBar( + state = state, + onSeekChange = viewModel::onSeekChange, + onSeekFinished = viewModel::onSeekFinished, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + ) + PlaybackControls( + state = state, + onToggleShuffle = viewModel::toggleShuffle, + onSkipPrevious = viewModel::skipPrevious, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 12.dp) + ) + VolumeControl( + state = state, + onVolumeChange = viewModel::onVolumeChange, + onVolumeChangeFinished = viewModel::onVolumeChangeFinished, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun Artwork( + coverUrl: String?, + contentDescription: String?, + size: Dp +) { + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(22.dp)) + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center + ) { + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusic, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(56.dp) + ) + } + } +} + +@Composable +private fun TrackInfo( + state: RemoteControlUiState, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (!state.hasTrack) { + Text( + text = stringResource(Res.string.devices_remote_nothing_playing), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1 + ) + Text( + text = stringResource(Res.string.devices_remote_nothing_playing_hint), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + return@Column + } + + Text( + text = state.title ?: stringResource(Res.string.devices_remote_unknown_track), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = state.artists ?: stringResource(Res.string.devices_remote_unknown_artist), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + state.album?.let { album -> + Text( + text = album, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} + +@Composable +private fun SeekBar( + state: RemoteControlUiState, + onSeekChange: (Float) -> Unit, + onSeekFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + MillenniumSlider( + value = state.seekProgress, + onValueChange = onSeekChange, + onValueChangeFinished = onSeekFinished, + enabled = state.canSeek, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = state.positionLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = state.durationLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } +} + +@Composable +private fun PlaybackControls( + state: RemoteControlUiState, + onToggleShuffle: () -> Unit, + onSkipPrevious: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onCycleLoopMode: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIconButton( + onClick = onToggleShuffle, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = stringResource( + if (state.isShuffling) { + Res.string.player_disable_shuffle_content_description + } else { + Res.string.player_enable_shuffle_content_description + } + ), + tint = if (state.isShuffling) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant + ) + } + MillenniumIconButton( + onClick = onSkipPrevious, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = stringResource(Res.string.player_previous_content_description) + ) + } + MillenniumIconButton( + onClick = onTogglePlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = if (state.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource( + if (state.isPlaying) { + Res.string.player_pause_content_description + } else { + Res.string.player_play_content_description + } + ), + modifier = Modifier.size(30.dp), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = onSkipNext, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxNext, + contentDescription = stringResource(Res.string.player_next_content_description) + ) + } + MillenniumIconButton( + onClick = onCycleLoopMode, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = when (state.loopMode) { + RemoteLoopMode.NONE -> Iconsax.IconsaxRepeateMusic + RemoteLoopMode.ONE -> Iconsax.IconsaxRepeateOne + RemoteLoopMode.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource( + when (state.loopMode) { + RemoteLoopMode.NONE -> Res.string.player_loop_mode_none + RemoteLoopMode.ONE -> Res.string.player_loop_mode_one + RemoteLoopMode.ALL -> Res.string.player_loop_mode_all + } + ), + tint = if (state.loopMode == RemoteLoopMode.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary + } + ) + } + } +} + +@Composable +private fun VolumeControl( + state: RemoteControlUiState, + onVolumeChange: (Float) -> Unit, + onVolumeChangeFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MillenniumIcon( + imageVector = when (state.volumeLevel) { + RemoteVolumeLevel.MUTED -> Iconsax.IconsaxVolumeCross + RemoteVolumeLevel.LOW -> Iconsax.IconsaxVolumeLow + RemoteVolumeLevel.HIGH -> Iconsax.IconsaxVolumeHigh + }, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + MillenniumSlider( + value = state.volume, + onValueChange = onVolumeChange, + onValueChangeFinished = onVolumeChangeFinished, + label = stringResource(Res.string.devices_remote_volume), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ConnectionStatus( + title: String, + message: String, + leading: @Composable () -> Unit, + actionLabel: String? = null, + onAction: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + leading() + Text( + text = title, + style = AppTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = message, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (actionLabel != null) { + MillenniumButton( + onClick = onAction, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(actionLabel) + } + } + } + } +} + +@Composable +private fun StatusIcon(icon: ImageVector, isError: Boolean) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background( + if (isError) AppTheme.colors.errorContainer else AppTheme.colors.surfaceContainerHighest + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + tint = if (isError) AppTheme.colors.onErrorContainer else AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(32.dp) + ) + } +} + +@Composable +private fun RemoteQueueList( + state: RemoteControlUiState, + onPlayQueueItem: (Int) -> Unit, + onRemoveQueueItem: (String) -> Unit +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(Res.string.queue_title), + style = AppTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 4.dp) + ) + + if (state.queue.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(Res.string.queue_no_queue_entries), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } + } else { + // Open with the current track in view; after that the user owns the scroll position. + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = state.currentQueueIndex.coerceAtLeast(0) + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = listState, + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(items = state.queue, key = { it.key }) { item -> + RemoteQueueItemRow( + item = item, + onPlayClick = { onPlayQueueItem(item.index) }, + onRemoveClick = { onRemoveQueueItem(item.mediaUrl) } + ) + } + } + } + } +} + +@Composable +private fun RemoteQueueItemRow( + item: RemoteQueueItemUi, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + + MillenniumListTile( + onClick = onPlayClick, + selected = item.isCurrent, + leading = { + Box( + modifier = Modifier + .size(40.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center + ) { + if (item.coverUrl != null) { + AsyncImage( + model = item.coverUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = "${item.index + 1}", + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + }, + title = { + Text( + text = item.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (item.isCurrent) { + AppTheme.colors.onSecondaryContainer + } else { + AppTheme.colors.onSurface + } + ) + }, + subtitle = { + Text( + text = item.artists, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + trailing = { + Text( + text = item.durationLabel, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.devices_remote_more_options) + ) + } + }) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.devices_remote_remove_from_queue), + icon = Iconsax.IconsaxMusicSquareRemove, + destructive = true, + onClick = { + onRemoveClick() + showMenu = false + }) + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt new file mode 100644 index 00000000..0c5d78c1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt @@ -0,0 +1,272 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient +import org.spotube.spotube.core.remote.RemoteControlCommand +import org.spotube.spotube.core.remote.RemoteControlEvent +import org.spotube.spotube.modules.shell.formatPlayerTime +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.time.Duration.Companion.milliseconds + +enum class RemoteLoopMode(val wireValue: String) { + NONE("none"), + ONE("one"), + ALL("all"); + + fun next(): RemoteLoopMode = when (this) { + NONE -> ONE + ONE -> ALL + ALL -> NONE + } + + companion object { + fun fromWire(value: String): RemoteLoopMode = entries.firstOrNull { it.wireValue == value } ?: NONE + } +} + +enum class RemoteVolumeLevel { MUTED, LOW, HIGH } + +data class RemoteQueueItemUi( + val key: String, + val index: Int, + val mediaUrl: String, + val title: String, + val artists: String, + val coverUrl: String?, + val durationLabel: String, + val isCurrent: Boolean) + +data class RemoteControlUiState( + val connection: ConnectionState = ConnectionState.Disconnected, + val deviceName: String = "", + val hasTrack: Boolean = false, + val title: String? = null, + val artists: String? = null, + val album: String? = null, + val coverUrl: String? = null, + val isPlaying: Boolean = false, + val isShuffling: Boolean = false, + val loopMode: RemoteLoopMode = RemoteLoopMode.NONE, + val canSeek: Boolean = false, + val seekProgress: Float = 0f, + val positionLabel: String = formatPlayerTime(0.milliseconds), + val durationLabel: String = formatPlayerTime(0.milliseconds), + val volume: Float = 1f, + val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH, + val queue: List = emptyList(), + val currentQueueIndex: Int = -1, + val isQueueVisible: Boolean = false) + +private data class LocalOverrides( + val seekProgress: Float?, + val volume: Float?, + val isQueueVisible: Boolean) + +class RemoteControlViewModel : ViewModel(), KoinComponent { + private val remoteControlClient: RemoteControlClient by inject() + + // While the user drags a slider these hold the local value, so the host's + // periodic state pushes don't yank the thumb back mid-gesture. + private val seekOverride = MutableStateFlow(null) + private val volumeOverride = MutableStateFlow(null) + private val isQueueVisible = MutableStateFlow(false) + + private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible -> + LocalOverrides(seek, volume, queueVisible) + } + + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + remoteControlClient.latestPlayerState, + remoteControlClient.latestQueue, + overrides + ) { connection, player, queue, local -> + buildUiState(connection, player, queue, local) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState()) + + init { + // Stream volume changes while dragging, throttled so a fast drag doesn't + // flood the socket with one command per frame. + viewModelScope.launch { + volumeOverride.filterNotNull().collect { volume -> + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + delay(VOLUME_THROTTLE_MS) + } + } + } + + private fun buildUiState( + connection: ConnectionState, + player: RemoteControlEvent.PlayerState?, + queue: RemoteControlEvent.QueueUpdated?, + local: LocalOverrides + ): RemoteControlUiState { + val durationMs = player?.durationMs ?: 0L + val canSeek = durationMs > 0L + val liveProgress = if (canSeek) { + ((player?.positionMs ?: 0L).toFloat() / durationMs).coerceIn(0f, 1f) + } else { + 0f + } + val seekProgress = local.seekProgress ?: liveProgress + val currentIndex = queue?.currentIndex ?: -1 + val volume = local.volume ?: player?.volume ?: 1f + + return RemoteControlUiState( + connection = connection, + deviceName = (connection as? ConnectionState.Connected)?.deviceName.orEmpty(), + hasTrack = player?.currentTrackId != null || player?.currentTrackTitle != null, + title = player?.currentTrackTitle, + artists = player?.currentTrackArtists, + album = player?.currentTrackAlbum, + coverUrl = player?.currentTrackCoverUrl, + isPlaying = player?.isPlaying ?: false, + isShuffling = player?.shuffleEnabled ?: false, + loopMode = RemoteLoopMode.fromWire(player?.loopMode ?: RemoteLoopMode.NONE.wireValue), + canSeek = canSeek, + seekProgress = seekProgress, + positionLabel = formatPlayerTime((durationMs * seekProgress).toLong().milliseconds), + durationLabel = formatPlayerTime(durationMs.milliseconds), + volume = volume, + volumeLevel = when { + volume <= 0f -> RemoteVolumeLevel.MUTED + volume < 0.5f -> RemoteVolumeLevel.LOW + else -> RemoteVolumeLevel.HIGH + }, + queue = queue?.entries.orEmpty().mapIndexed { index, entry -> + RemoteQueueItemUi( + key = "${entry.mediaUrl}@$index", + index = index, + mediaUrl = entry.mediaUrl, + title = entry.title, + artists = entry.artists, + coverUrl = entry.coverUrl, + durationLabel = formatPlayerTime(entry.durationMs.milliseconds), + isCurrent = index == currentIndex) + }, + currentQueueIndex = currentIndex, + isQueueVisible = local.isQueueVisible) + } + + private fun send(command: RemoteControlCommand) { + viewModelScope.launch { + remoteControlClient.sendCommand(command) + } + } + + /** + * Keeps [override] at [value] until the host reports back (or a timeout), so + * the slider doesn't flash the stale value in between. Skips the reset if the + * user already started a new drag. + */ + private suspend fun releaseOverrideAfterEcho(override: MutableStateFlow, value: Float) { + withTimeoutOrNull(OVERRIDE_RELEASE_TIMEOUT_MS) { + remoteControlClient.latestPlayerState.drop(1).first() + } + override.compareAndSet(value, null) + } + + fun togglePlayPause() = send(RemoteControlCommand.TogglePlayPause) + + fun skipNext() = send(RemoteControlCommand.SkipNext) + + fun skipPrevious() = send(RemoteControlCommand.SkipPrevious) + + fun onSeekChange(progress: Float) { + seekOverride.value = progress.coerceIn(0f, 1f) + } + + fun onSeekFinished() { + val progress = seekOverride.value ?: return + val durationMs = remoteControlClient.latestPlayerState.value?.durationMs ?: 0L + if (durationMs <= 0L) { + seekOverride.value = null + return + } + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong())) + releaseOverrideAfterEcho(seekOverride, progress) + } + } + + fun onVolumeChange(volume: Float) { + volumeOverride.value = volume.coerceIn(0f, 1f) + } + + fun onVolumeChangeFinished() { + val volume = volumeOverride.value ?: return + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + releaseOverrideAfterEcho(volumeOverride, volume) + } + } + + fun toggleShuffle() { + val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false + send(RemoteControlCommand.SetShuffle(!shuffling)) + } + + fun cycleLoopMode() { + val current = RemoteLoopMode.fromWire( + remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue) + send(RemoteControlCommand.SetLoopMode(current.next().wireValue)) + } + + fun playQueueItem(index: Int) { + if (index < 0) return + send(RemoteControlCommand.PlayIndex(index)) + } + + fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl)) + + fun toggleQueueVisibility() { + isQueueVisible.update { !it } + } + + fun dismissQueue() { + isQueueVisible.value = false + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } + + private companion object { + const val VOLUME_THROTTLE_MS = 100L + const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt similarity index 60% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index cc1aea59..30943cb1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -15,22 +15,27 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.downloads +package org.spotube.spotube.modules.downloads +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.server.StreamInfo -import dev.krtirtho.spotube.core.server.StreamingUrlRepository -import dev.krtirtho.spotube.modules.settings.SettingsRepository -import io.ktor.client.HttpClient +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.server.StreamInfo +import org.spotube.spotube.core.server.StreamRequest +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.urlSummary +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import io.ktor.client.request.head import io.ktor.client.request.headers import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode import io.ktor.http.contentLength +import io.ktor.http.isSuccess import io.ktor.utils.io.readRemaining import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -57,6 +62,8 @@ import okio.SYSTEM import okio.buffer import okio.use import kotlin.time.Clock +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.* data class DownloadItem( val id: String, @@ -68,8 +75,7 @@ data class DownloadItem( val totalBytes: Long = 0L, val downloadedBytes: Long = 0L, val errorMessage: String? = null, - val track: MetadataTrack? = null, -) + val track: MetadataTrack? = null) sealed interface DownloadStatus { data object Queued : DownloadStatus @@ -82,7 +88,8 @@ sealed interface DownloadStatus { class DownloadManager( private val settingsRepository: SettingsRepository, private val paths: Paths, - private val streamingUrlRepository: StreamingUrlRepository + private val streamingUrlRepository: StreamingUrlRepository, + private val httpClientFactory: HttpClientFactory ) { companion object { const val MAX_CONCURRENT_DOWNLOADS = 4 @@ -95,7 +102,7 @@ class DownloadManager( private val fileSystem = FileSystem.SYSTEM private val queueMutex = Mutex() - private val httpClient = HttpClient { + private val httpClient = httpClientFactory.create { install(HttpTimeout) { requestTimeoutMillis = 300_000 connectTimeoutMillis = 30_000 @@ -111,6 +118,11 @@ class DownloadManager( private val cachedDownloadFolder = MutableStateFlow(null) + /** Where downloads are saved right now: the user's override, else the platform default. */ + val downloadFolder: StateFlow = cachedDownloadFolder + .map { it?.takeIf(String::isNotBlank) ?: paths.getUserDownloadsDirPath() } + .stateIn(scope, SharingStarted.Eagerly, paths.getUserDownloadsDirPath()) + private val activeJobs = mutableMapOf() init { @@ -131,10 +143,16 @@ class DownloadManager( title = track.title, artists = artistNames, album = track.album?.title, - track = track, - ) + track = track) downloadsMap.update { it + (id to item) } + val settings = settingsRepository.userSettings.value + logger.i { + "[Download $id] enqueued '${track.title}' by $artistNames (track=${track.id}) " + + "downloadPrefs=${settings.downloadMusicFormat.codec}/${settings.downloadMusicFormat.container} " + + "${settings.downloadMusicQuality} streamingPrefs=${settings.streamingMusicFormat.codec}/" + + "${settings.streamingMusicFormat.container} ${settings.streamingMusicQuality} folder=${downloadFolder.value}" + } scope.launch { processQueue() } } @@ -158,8 +176,7 @@ class DownloadManager( status = DownloadStatus.Queued, progress = 0f, downloadedBytes = 0L, - errorMessage = null, - )) + errorMessage = null)) } scope.launch { processQueue() } } @@ -199,33 +216,43 @@ class DownloadManager( private suspend fun executeDownload(item: DownloadItem) { val track = item.track ?: run { logger.e { "Download item has no track metadata" } + val noMetadataMessage = getString(Res.string.downloads_error_no_metadata) downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy( - status = DownloadStatus.Failed("No track metadata"), - errorMessage = "No track metadata", - )) + status = DownloadStatus.Failed(noMetadataMessage), + errorMessage = noMetadataMessage)) } ?: map } return } try { - val streamInfo = streamingUrlRepository.resolveStreamInfo(track) + logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" } + val settings = settingsRepository.userSettings.value + val streamInfo = streamingUrlRepository.resolveStreamInfo( + track, + request = StreamRequest( + format = settings.downloadMusicFormat, + quality = settings.downloadMusicQuality, + // A manifest URL would be saved as-is instead of the audio it points to. + progressiveOnly = true)) if (streamInfo == null) { logger.w { "Could not resolve stream URL for ${track.title}" } + val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short) + val unresolvedMessage = getString(Res.string.downloads_error_stream_unresolved) downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy( - status = DownloadStatus.Failed("Could not resolve stream URL"), - errorMessage = "Could not resolve stream URL. Try playing the track first.", - )) + status = DownloadStatus.Failed(unresolvedStatus), + errorMessage = unresolvedMessage)) } ?: map } return } val url = streamInfo.url + logResolvedStream(item, streamInfo) val filename = buildFilename(item, streamInfo) @@ -235,15 +262,28 @@ class DownloadManager( val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath() fileSystem.createDirectories(tempDir) - val (totalSize, supportsRanges) = probeUrl(url) + val (totalSize, supportsRanges) = probeUrl(item.id, url) if (totalSize > 0 && supportsRanges) { + logger.i { "[Download ${item.id}] multi-segment download ($SEGMENT_COUNT segments, $totalSize bytes) -> ${downloadDir / filename}" } executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize) } else { + logger.i { "[Download ${item.id}] single-connection download (size=$totalSize ranges=$supportsRanges) -> ${downloadDir / filename}" } executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir) } + val finalPath = downloadDir / filename.toPath() + val writtenBytes = fileSystem.metadataOrNull(finalPath)?.size + logger.i { "[Download ${item.id}] wrote $finalPath ($writtenBytes bytes, expected $totalSize)" } + if (totalSize > 0 && writtenBytes != null && writtenBytes != totalSize) { + logger.w { "[Download ${item.id}] SIZE MISMATCH: wrote $writtenBytes bytes, server reported $totalSize" } + } + applyMetadata(item, filename, downloadDir) + // Without this Android music players and the Files app's Audio view don't see the + // file until the next full media scan. + addToMediaLibrary((downloadDir / filename.toPath()).toString()) + logger.i { "[Download ${item.id}] completed '${item.title}'" } downloadsMap.update { map -> map[item.id]?.let { @@ -251,6 +291,7 @@ class DownloadManager( } ?: map } } catch (e: CancellationException) { + logger.i { "[Download ${item.id}] cancelled" } downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy(status = DownloadStatus.Cancelled)) @@ -258,13 +299,13 @@ class DownloadManager( } throw e } catch (e: Exception) { - logger.e(e) { "Download failed: ${item.title}" } + logger.e(e) { "[Download ${item.id}] FAILED '${item.title}': ${e.message}" } + val failureMessage = e.message ?: getString(Res.string.common_unknown_error) downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy( - status = DownloadStatus.Failed(e.message ?: "Unknown error"), - errorMessage = e.message, - )) + status = DownloadStatus.Failed(failureMessage), + errorMessage = e.message)) } ?: map } } finally { @@ -279,8 +320,7 @@ class DownloadManager( item: DownloadItem, tempDir: Path, downloadDir: Path, - totalSize: Long, - ) { + totalSize: Long) { val segmentSize = totalSize / SEGMENT_COUNT val segments = (0 until SEGMENT_COUNT).map { i -> val start = i * segmentSize @@ -317,13 +357,27 @@ class DownloadManager( item: DownloadItem, segment: Segment, outputPath: Path, - totalSize: Long, - ) { + totalSize: Long) { val response = httpClient.get(url) { headers { append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") } } + logger.d { + "[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " + + "HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " + + "range=${response.headers[HttpHeaders.ContentRange]}" + } + // expectSuccess = false, so without these checks an error page, or the whole file once per + // segment (a 200 to a range request), would be written into the "audio" file. + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; failing download" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) + } + if (response.status != HttpStatusCode.PartialContent) { + logger.w { "[Download ${item.id}] segment ${segment.index} got ${response.status} instead of 206; range ignored" } + throw DownloadException(getString(Res.string.downloads_error_range_ignored)) + } val channel = response.bodyAsChannel() fileSystem.sink(outputPath).buffer().use { sink -> @@ -341,12 +395,20 @@ class DownloadManager( filename: String, item: DownloadItem, tempDir: Path, - downloadDir: Path, - ) { + downloadDir: Path) { val tempFile = tempDir / "${item.id}.tmp" val response = httpClient.get(url) val totalSize = response.contentLength() ?: 0L + logger.i { + "[Download ${item.id}] GET -> HTTP ${response.status.value} " + + "type=${response.headers[HttpHeaders.ContentType]} length=$totalSize" + } + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; failing download" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) + } + rejectVideo(item.id, response.headers[HttpHeaders.ContentType]) val channel = response.bodyAsChannel() fileSystem.sink(tempFile).buffer().use { sink -> @@ -373,28 +435,70 @@ class DownloadManager( map + (id to current.copy( progress = progress, downloadedBytes = newDownloaded, - totalBytes = totalSize, - )) + totalBytes = totalSize)) } ?: map } } - private suspend fun probeUrl(url: String): Pair { + private suspend fun probeUrl(id: String, url: String): Pair { return try { val response = httpClient.head(url) val contentLength = response.contentLength() ?: 0L val acceptRanges = response.headers[HttpHeaders.AcceptRanges] val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true + val contentType = response.headers[HttpHeaders.ContentType] + logger.i { + "[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + + "length=$contentLength acceptRanges=$acceptRanges" + } + rejectVideo(id, contentType) Pair(contentLength, supportsRanges) + } catch (e: DownloadException) { + throw e } catch (e: Exception) { - logger.w(e) { "HEAD request failed, falling back to single connection" } + logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" } Pair(0L, false) } } + /** + * Some sources only offer muxed video (e.g. NewPipe falls back to video streams when a video + * has no audio-only ones). Saving that as a "song" is worse than failing with a clear reason. + */ + private suspend fun rejectVideo(id: String, contentType: String?) { + if (contentType?.startsWith("video/", ignoreCase = true) == true) { + logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio; failing download" } + throw DownloadException(getString(Res.string.downloads_error_video_only)) + } + } + + private fun logResolvedStream(item: DownloadItem, info: StreamInfo) { + val settings = settingsRepository.userSettings.value + val wanted = settings.downloadMusicFormat + logger.i { + "[Download ${item.id}] resolved stream codec=${info.codec} container=${info.container} " + + "protocol=${info.protocol} ${urlSummary(info.url)}" + } + if (info.protocol != StreamProtocol.PROGRESSIVE) { + logger.w { + "[Download ${item.id}] stream is a ${info.protocol} MANIFEST; a plain GET saves the manifest, not audio" + } + } + if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) { + logger.w { + "[Download ${item.id}] FORMAT FALLBACK: download setting is ${wanted.codec}/${wanted.container} " + + "${settings.downloadMusicQuality}, but the closest downloadable stream offered is ${info.codec}/${info.container}" + } + } + } + private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) { val filePath = downloadDir / filename.toPath() - if (!fileSystem.exists(filePath)) return + if (!fileSystem.exists(filePath)) { + logger.w { "[Download ${item.id}] skipping metadata: $filePath does not exist" } + return + } + logger.d { "[Download ${item.id}] writing metadata to $filePath" } val track = item.track ?: return @@ -423,21 +527,13 @@ class DownloadManager( album = item.album, trackNumber = track.trackNumber, discNumber = track.discNumber, - coverBytes = coverBytes, - ) + coverBytes = coverBytes) } catch (e: Exception) { - logger.w(e) { "Failed to write audio metadata for ${item.title}" } + logger.w(e) { "[Download ${item.id}] failed to write audio metadata for ${item.title}" } } } - private fun resolveDownloadDir(): Path { - val folder = cachedDownloadFolder.value - return if (!folder.isNullOrBlank()) { - folder.toPath() - } else { - paths.getUserDownloadsDirPath().toPath() - } - } + private fun resolveDownloadDir(): Path = downloadFolder.value.toPath() private fun cleanupTempFiles(id: String) { try { @@ -457,13 +553,21 @@ class DownloadManager( private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String { val safeTitle = downloadItem.title.sanitizeFilename() val safeArtist = downloadItem.artists.sanitizeFilename() - val extension = streamInfo.container + val extension = fileExtension(streamInfo) return "$safeArtist - $safeTitle.$extension" } + /** AAC in an MP4 container is audio-only, so give it the audio extension players expect. */ + private fun fileExtension(info: StreamInfo): String = + if (info.codec.equals("aac", ignoreCase = true) && info.container.equals("mp4", ignoreCase = true)) "m4a" + else info.container + private fun String.sanitizeFilename(): String { return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200) } private data class Segment(val index: Int, val start: Long, val end: Long) } + +/** A download failure whose [message] is already localized and safe to show the user. */ +private class DownloadException(message: String) : Exception(message) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt similarity index 69% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt index fa26af13..4045940c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadProgressIcon.kt @@ -15,27 +15,32 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.downloads +package org.spotube.spotube.modules.downloads + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.koin.compose.viewmodel.koinViewModel @Composable @@ -46,9 +51,8 @@ fun DownloadProgressIcon( modifier: Modifier = Modifier, size: Dp = 24.dp, strokeWidth: Dp = 2.dp, - tint: Color = MaterialTheme.colorScheme.onSurface, - progressTint: Color = MaterialTheme.colorScheme.primary, -) { + tint: Color = AppTheme.colors.onSurface, + progressTint: Color = AppTheme.colors.primary) { val downloadsViewModel = koinViewModel() val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() @@ -68,33 +72,36 @@ fun DownloadProgressIcon( modifier = modifier.size(size), contentAlignment = Alignment.Center ) { - CircularProgressIndicator( - progress = { progress }, - modifier = Modifier.size(size), - strokeWidth = strokeWidth, - color = progressTint, - ) - Icon( + Canvas(Modifier.size(size)) { + val diameter = this.size.minDimension + val stroke = strokeWidth.toPx() + drawArc( + color = progressTint, + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = stroke), + topLeft = Offset(stroke / 2f, stroke / 2f), + size = Size(diameter - stroke, diameter - stroke)) + } + MillenniumIcon( imageVector = icon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(size * 0.6f), - ) + modifier = Modifier.size(size * 0.6f)) } } else { - Icon( + MillenniumIcon( imageVector = icon, contentDescription = contentDescription, tint = tint, - modifier = modifier.size(size), - ) + modifier = modifier.size(size)) } } @Composable fun DownloadBadgeIndicator( - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val downloadsViewModel = koinViewModel() val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() @@ -105,7 +112,7 @@ fun DownloadBadgeIndicator( modifier = modifier .size(8.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) + .background(AppTheme.colors.primary) ) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt new file mode 100644 index 00000000..8bcee0fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt @@ -0,0 +1,259 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.downloads + +import org.spotube.millennium.composables.MillenniumStripedProgress +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.cards.MediaArtwork +import org.spotube.spotube.core.ui.component.cards.MediaEmptyState +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +fun DownloadsScreen( + modifier: Modifier = Modifier, + viewModel: DownloadsViewModel = koinViewModel(), + headerInAppBar: Boolean = false, + topContent: @Composable () -> Unit = {} +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Column(modifier.fillMaxSize()) { + if (headerInAppBar) { + ApplicationMainBar( + backButton = false, + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, viewModel::clearCompleted) } + ) + topContent() + } + DownloadsContent( + state, + viewModel::cancel, + viewModel::retry, + viewModel::remove, + viewModel::clearCompleted, + Modifier.weight(1f) + .then(if (headerInAppBar) Modifier.padding(horizontal = 12.dp) else Modifier), + showHeader = !headerInAppBar + ) + } +} + +@Composable +internal fun DownloadsToolbarTitle() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(20.dp) + ) + Text( + stringResource(Res.string.library_tab_downloads), + style = AppTheme.typography.titleMedium + ) + } +} + +@Composable +internal fun DownloadsToolbarSummary(state: DownloadsUiState) { + Column(Modifier.padding(start = 28.dp)) { + if (state.items.isNotEmpty()) Text( + stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + // Always visible (empty list included), so users know where their files end up. + if (state.folder.isNotBlank()) Text( + stringResource(Res.string.downloads_saved_to, state.folder), + style = AppTheme.typography.bodySmall, color = MillenniumTheme.colors.muted, + maxLines = 2, overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun DownloadsToolbarActions(state: DownloadsUiState, onClearCompleted: () -> Unit) { + if (state.canClearCompleted) MillenniumButton( + onClick = onClearCompleted, + style = MillenniumButtonStyle.Secondary + ) { + Text( + stringResource(Res.string.downloads_clear_completed), + style = AppTheme.typography.labelMedium + ) + } +} + +@Composable +internal fun DownloadsContent( + state: DownloadsUiState, + onCancel: (String) -> Unit, + onRetry: (String) -> Unit, + onRemove: (String) -> Unit, + onClearCompleted: () -> Unit, + modifier: Modifier = Modifier, + showHeader: Boolean = true +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column(Modifier.widthIn(max = 960.dp).fillMaxSize()) { + if (showHeader) Row( + Modifier.fillMaxWidth().padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(Modifier.weight(1f)) { + DownloadsToolbarTitle() + DownloadsToolbarSummary(state) + } + DownloadsToolbarActions(state, onClearCompleted) + } + if (state.items.isEmpty()) MediaEmptyState( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.downloads_empty), + Modifier.padding(bottom = inset) + ) + else LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues( + top = if (showHeader) 0.dp else 16.dp, + bottom = inset + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.items, key = { it.id }) { row -> + DownloadListRow( + row, + { onCancel(row.id) }, + { onRetry(row.id) }, + { onRemove(row.id) }) + } + } + } + } +} + +@Composable +internal fun DownloadListRow( + item: DownloadRowUiState, + onCancel: () -> Unit, + onRetry: () -> Unit, + onRemove: () -> Unit +) { + val colors = MillenniumTheme.colors + Row( + Modifier.fillMaxWidth().millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = colors.border, + borderWidth = 1.dp, + innerGlowColor = colors.highlight.copy(alpha = 0.2f) + ).padding(12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MediaArtwork(item.artworkUrl, item.title, Modifier.size(52.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + item.title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + item.artists, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + when (item.status) { + DownloadStatus.Downloading -> stringResource( + Res.string.downloads_status_downloading, + item.percent + ) + + DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued) + DownloadStatus.Completed -> stringResource(Res.string.downloads_status_completed) + DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled) + is DownloadStatus.Failed -> if (item.error != null) stringResource( + Res.string.downloads_status_failed_error, + item.error + ) else stringResource(Res.string.downloads_status_failed) + }, + style = AppTheme.typography.labelSmall, + color = when (item.status) { + is DownloadStatus.Failed -> AppTheme.colors.error + DownloadStatus.Completed -> AppTheme.colors.primary + else -> AppTheme.colors.onSurfaceVariant + }, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + if (item.status == DownloadStatus.Downloading) MillenniumStripedProgress( + progress = item.progress, + modifier = Modifier.fillMaxWidth(), + height = 6.dp + ) + } + MillenniumIconButton( + onClick = when (item.action) { + DownloadRowAction.Cancel -> onCancel; DownloadRowAction.Retry -> onRetry; DownloadRowAction.Remove -> onRemove + }, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + when (item.action) { + DownloadRowAction.Cancel -> Iconsax.IconsaxCloseSquare; DownloadRowAction.Retry -> Iconsax.IconsaxRefreshRight; DownloadRowAction.Remove -> Iconsax.IconsaxTrash + }, + stringResource( + when (item.action) { + DownloadRowAction.Cancel -> Res.string.downloads_action_cancel; DownloadRowAction.Retry -> Res.string.downloads_action_retry; DownloadRowAction.Remove -> Res.string.downloads_action_remove + } + ) + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt new file mode 100644 index 00000000..8ee69171 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt @@ -0,0 +1,113 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.downloads + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.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.flow.combine + +enum class DownloadRowAction { Cancel, Retry, Remove } + +data class DownloadRowUiState( + val id: String, + val title: String, + val artists: String, + val artworkUrl: String?, + val status: DownloadStatus, + val progress: Float, + val percent: Int, + val error: String?, + val action: DownloadRowAction +) + +data class DownloadsUiState( + val items: List = emptyList(), + val activeCount: Int = 0, + val completedCount: Int = 0, + val canClearCompleted: Boolean = false, + /** Folder new downloads are saved to, shown so users can find their files. */ + val folder: String = "" +) + +internal fun downloadsUiState(items: List, folder: String = ""): DownloadsUiState { + val completedCount = items.count { it.status == DownloadStatus.Completed } + return DownloadsUiState( + items = items.map { item -> + val progress = item.progress.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + DownloadRowUiState( + item.id, item.title, item.artists, + (item.track?.album?.thumbnails + ?: item.track?.thumbnails)?.firstOrNull()?.url?.takeIf { it.isNotBlank() }, + item.status, progress, (progress * 100).toInt(), + item.errorMessage ?: (item.status as? DownloadStatus.Failed)?.error, + when (item.status) { + DownloadStatus.Completed -> DownloadRowAction.Remove + is DownloadStatus.Failed, DownloadStatus.Cancelled -> DownloadRowAction.Retry + else -> DownloadRowAction.Cancel + } + ) + }, + activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, + completedCount = completedCount, + canClearCompleted = completedCount > 0, + folder = folder + ) +} + +class DownloadsViewModel( + private val downloadManager: DownloadManager +) : ViewModel() { + val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + val uiState = combine(downloadManager.downloadsFlow, downloadManager.downloadFolder, ::downloadsUiState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) + + fun downloadTrack(track: MetadataTrack) { + downloadManager.enqueue(track) + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> + downloadManager.enqueue(track) + } + } + + fun cancel(id: String) { + downloadManager.cancel(id) + } + + fun retry(id: String) { + downloadManager.retry(id) + } + + fun remove(id: String) { + downloadManager.remove(id) + } + + fun clearCompleted() { + downloadManager.clearCompleted() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt similarity index 79% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt index 5cbb3c81..75340bb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt @@ -15,9 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.modules.downloads -expect class NewPipeService() { - suspend fun searchVideos(query: String): List - suspend fun getVideoInfo(id: String): VideoInfo -} \ No newline at end of file +/** Tells the OS media index about a finished download so other apps can see it right away. */ +internal expect fun addToMediaLibrary(path: String) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt similarity index 62% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt index c34d53ee..2a8a8944 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt @@ -15,8 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.home +package org.spotube.spotube.modules.home + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,12 +38,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumPullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,42 +50,58 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.ui.base.ChipTab -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.TrackCard -import dev.krtirtho.spotube.core.ui.component.UserCard -import dev.krtirtho.spotube.core.ui.component.FeaturedCarousel -import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.component.dragScrollable -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackCard +import org.spotube.spotube.core.ui.component.UserCard +import org.spotube.spotube.core.ui.component.FeaturedCarousel +import org.spotube.spotube.core.ui.component.VerticalScrollbar +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.shell.ConnectStatusBanner +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.support_button_label +import spotube.composeapp.generated.resources.devices_title +import spotube.composeapp.generated.resources.home_title_browse +import spotube.composeapp.generated.resources.jam_screen_title import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import org.jetbrains.compose.resources.Font +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular +import spotube.composeapp.generated.resources.cookie_regular - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(viewModel: HomeScreenViewModel) { - val platform = getPlatform() - val isDesktop = platform.type == PlatformType.Windows || - platform.type == PlatformType.Linux || - platform.type == PlatformType.MacOS - + val navigationCommands = koinInject() + val openSupport = LocalSupportPrompt.current + val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() @@ -111,42 +133,91 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { } } - Scaffold( - topBar = { - ApplicationMainBar( - backButton = false, - title = { - Text("Browse") + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + ApplicationMainBar( + backButton = false, + title = { + if (isSmallScreen) { + Text( + "Spotube", + style = AppTheme.typography.headlineLarge.copy( + fontFamily = FontFamily( + listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal)) + ) + ), + color = AppTheme.colors.onSurface + ) + } else { + Text(stringResource(Res.string.home_title_browse)) } + + }, + actions = { + if (isSmallScreen) { + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Devices) }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMirrorScreenRegular, + contentDescription = stringResource(Res.string.devices_title) + ) + } + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Jam) }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxSoundTwotone, + contentDescription = stringResource(Res.string.jam_screen_title) + ) + } + val supportLabel = stringResource(Res.string.support_button_label) + MillenniumIconButton( + onClick = openSupport, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxHeart2, + contentDescription = supportLabel + ) + } + } + }) + if (isSmallScreen) { + ConnectStatusBanner( + onOpenRemote = { navigationCommands.navigateTo(Routes.RemoteControl) }, + onOpenJam = { navigationCommands.navigateTo(Routes.Jam) } ) - }, - ) { innerPadding -> + } Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .weight(1f) ) { - if (isDesktop) { + if (!isSmallScreen) { HomeContent( listState = listState, state = state, onRetry = { viewModel.refresh() }, - onGenreSelected = { viewModel.selectGenre(it) }, - ) + onGenreSelected = { viewModel.selectGenre(it) }) } else { - PullToRefreshBox( + MillenniumPullToRefreshBox( isRefreshing = state is HomeScreenState.Loading, onRefresh = { viewModel.refresh() }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize() ) { HomeContent( listState = listState, state = state, onRetry = { viewModel.refresh() }, - onGenreSelected = { viewModel.selectGenre(it) }, - ) + onGenreSelected = { viewModel.selectGenre(it) }) } } @@ -164,7 +235,7 @@ private fun HomeContent( listState: androidx.compose.foundation.lazy.LazyListState, state: HomeScreenState, onRetry: () -> Unit, - onGenreSelected: (String) -> Unit, + onGenreSelected: (String) -> Unit ) { val shellBottomInset = LocalAppShellBottomInset.current val contentPadding = remember(shellBottomInset) { @@ -175,36 +246,36 @@ private fun HomeContent( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) ) { when (state) { is HomeScreenState.Loading -> { item { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { repeat(3) { SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) ) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", + imageURL = "https://placehold.co/600x400" ) } } @@ -219,7 +290,7 @@ private fun HomeContent( item { ErrorDisplay( errorMessage = state.message, - onRetry = onRetry, + onRetry = onRetry ) } } @@ -236,7 +307,7 @@ private fun HomeContent( GenreTabs( genres = state.genres, selectedGenreId = state.selectedGenreId, - onGenreSelected = onGenreSelected, + onGenreSelected = onGenreSelected ) } } @@ -248,23 +319,23 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) ) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", + imageURL = "https://placehold.co/600x400" ) } } @@ -276,7 +347,7 @@ private fun HomeContent( HomeSection( title = section.title, subtitle = section.description, - items = section.items, + items = section.items ) } } @@ -286,23 +357,23 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) ) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", + imageURL = "https://placehold.co/600x400" ) } } @@ -321,27 +392,27 @@ private fun HomeSection( title: String, subtitle: String? = null, items: List, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { val rowState = rememberLazyListState() Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (!subtitle.isNullOrEmpty()) Text( text = subtitle, - style = MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.secondary, - fontWeight = FontWeight.Medium, + style = AppTheme.typography.labelMedium.copy( + color = AppTheme.colors.secondary, + fontWeight = FontWeight.Medium ), - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = 16.dp) ) Text( text = title, - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp), + modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp) ) LazyRow( @@ -349,7 +420,7 @@ private fun HomeSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.Top ) { items(items) { browseItem -> when (browseItem) { @@ -368,7 +439,7 @@ private fun HomeSection( private fun GenreTabs( genres: List, selectedGenreId: String?, - onGenreSelected: (String) -> Unit, + onGenreSelected: (String) -> Unit ) { val rowState = rememberLazyListState() @@ -376,14 +447,19 @@ private fun GenreTabs( state = rowState, modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { items(genres) { genre -> - ChipTab( - text = genre.name, - selected = selectedGenreId == genre.id, + MillenniumButton( onClick = { onGenreSelected(genre.id) }, - ) + style = if (selectedGenreId == genre.id) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + } + ) { + Text(genre.name) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt similarity index 88% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt index ecffbd25..8c981e73 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenRepository.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.home +package org.spotube.spotube.modules.home -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch - @OptIn(ExperimentalCoroutinesApi::class) class HomeScreenRepository( private val pluginManager: PluginManager diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt index f77c8d86..fe82fc14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreenViewModel.kt @@ -15,17 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.home +package org.spotube.spotube.modules.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.plugin.PluginManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,6 +35,9 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface HomeScreenState { data object Loading : HomeScreenState @@ -51,15 +54,13 @@ sealed interface HomeScreenState { override val genres: List, override val selectedGenreId: String?, override val browseSections: Map?>, - override val paginationStrategies: Map, - ) : Data { + override val paginationStrategies: Map) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( featuredItems = featuredItems, genres = genres, selectedGenreId = selectedGenreId, browseSections = browseSections, - paginationStrategies = paginationStrategies, - ) + paginationStrategies = paginationStrategies) } data class LoadingMore( @@ -67,8 +68,7 @@ sealed interface HomeScreenState { override val genres: List, override val selectedGenreId: String?, override val browseSections: Map?>, - override val paginationStrategies: Map, - ) : Data + override val paginationStrategies: Map) : Data } data class Error(val message: String) : HomeScreenState @@ -111,11 +111,10 @@ class HomeScreenViewModel( genres = genres, selectedGenreId = firstGenreId, browseSections = browseSections, - paginationStrategies = paginationStrategies, - ) + paginationStrategies = paginationStrategies) }.onFailure { e -> logger.e(e) { "Failed to load home screen data" } - state.value = HomeScreenState.Error(e.message ?: "Unknown error") + state.value = HomeScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun selectGenre(genreId: String) { @@ -135,8 +134,7 @@ class HomeScreenViewModel( genres = current.genres, selectedGenreId = genreId, browseSections = current.browseSections + (genreId to (result?.items ?: emptyList())), - paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination), - ) + paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination)) } } } @@ -156,12 +154,11 @@ class HomeScreenViewModel( genres = currentState.genres, selectedGenreId = genreId, browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))), - paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination), - ) + paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination)) } }.onFailure { e -> logger.e(e) { "Failed to load more home screen data" } - state.value = HomeScreenState.Error(e.message ?: "Unknown error") + state.value = HomeScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun refresh() { @@ -170,4 +167,4 @@ class HomeScreenViewModel( loadInitialData() } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt new file mode 100644 index 00000000..9c827d29 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt @@ -0,0 +1,504 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.jam + +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.sections.JamNameSettingCard +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +fun JamScreen(navigationCommands: NavigationCommands) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val clipboard = LocalClipboardManager.current + val shellBottomInset = LocalAppShellBottomInset.current + + Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + ApplicationMainBar(title = { Text(stringResource(Res.string.jam_screen_title)) }) + Column( + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = shellBottomInset + 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + Modifier.widthIn(max = 520.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + state.error?.let { error -> + JamPanel { + Text( + error, + color = AppTheme.colors.error, + style = AppTheme.typography.bodySmall + ) + MillenniumButton( + onClick = viewModel::clearError, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.jam_error_dismiss)) + } + } + } + if (state.isActive) { + SessionPanel( + state = state, + onCopy = { + clipboard.setText(AnnotatedString(state.roomCode.orEmpty())) + viewModel.onCodeCopied() + }, + onShare = viewModel::shareRoomCode, + onLeave = viewModel::leave, + onKick = viewModel::kickParticipant, + onBan = viewModel::banParticipant, + onMenuChange = viewModel::showParticipantMenu + ) + } else { + SetupPanel( + state, + viewModel::selectSetupMode, + viewModel::createSession, + viewModel::joinSession + ) + // Pick a name before starting or joining, without a detour through Settings. + JamPanel { + JamNameSettingCard( + customName = state.customName, + defaultName = state.defaultName, + onNameSaved = viewModel::setParticipantName, + enabled = !state.isBusy + ) + } + } + ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) } + } + } + } +} + +@Composable +private fun JamPanel(content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(10.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + innerGlowColor = MillenniumTheme.colors.highlight.copy(alpha = 0.25f) + ).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content + ) +} + +@Composable +internal fun SetupPanel( + state: JamUiState, + onMode: (JamSetupMode) -> Unit, + onCreate: () -> Unit, + onJoin: () -> Unit +) { + JamPanel { + MillenniumSegmentedButtonRow(Modifier.fillMaxWidth()) { + MillenniumSegmentedButton( + selected = state.setupMode == JamSetupMode.Create, + onClick = { onMode(JamSetupMode.Create) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2) + ) { Text(stringResource(Res.string.jam_setup_create_tab)) } + MillenniumSegmentedButton( + selected = state.setupMode == JamSetupMode.Join, + onClick = { onMode(JamSetupMode.Join) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2) + ) { Text(stringResource(Res.string.jam_setup_join_tab)) } + } + if (state.setupMode == JamSetupMode.Create) { + SharedRoomIllustration(Modifier.fillMaxWidth().height(100.dp)) + Text( + stringResource(Res.string.jam_setup_create_hint), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + MillenniumTextField( + state = state.codeInput, + label = stringResource(Res.string.jam_setup_code_label), + placeholder = stringResource(Res.string.jam_setup_code_placeholder), + enabled = !state.isBusy, + textStyle = AppTheme.typography.headlineSmall.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 4.sp + ), + modifier = Modifier.fillMaxWidth() + ) + } + MillenniumButton( + onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin, + enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin, + loading = state.isBusy, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource( + when { + state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button + else -> Res.string.jam_setup_join_button + } + ) + ) + } + } +} + +/** A small media-player diagram: one source feeding two listeners. No stock artwork. */ +@Composable +private fun SharedRoomIllustration(modifier: Modifier) { + val colors = MillenniumTheme.colors + Canvas(modifier) { + val scale = minOf(size.width / 260f, size.height / 100f) + val origin = Offset((size.width - 260f * scale) / 2f, (size.height - 100f * scale) / 2f) + fun point(x: Float, y: Float) = origin + Offset(x * scale, y * scale) + val stroke = 1.2f * scale + val metal = Brush.verticalGradient( + listOf(colors.raised, colors.surface), + startY = origin.y, + endY = origin.y + 100f * scale + ) + drawLine(colors.border, point(45f, 50f), point(215f, 50f), 2f * scale) + drawLine(colors.accent.copy(alpha = 0.7f), point(45f, 49f), point(215f, 49f), stroke) + // Two miniature speakers, with an accent power LED and concentric drivers. + for (x in listOf(15f, 207f)) { + drawRoundRect( + metal, + point(x, 18f), + Size(38f * scale, 64f * scale), + CornerRadius(5f * scale) + ) + drawRoundRect( + colors.border, + point(x, 18f), + Size(38f * scale, 64f * scale), + CornerRadius(5f * scale), + style = Stroke(stroke) + ) + drawCircle(colors.background, 10f * scale, point(x + 19f, 57f)) + drawCircle(colors.border, 10f * scale, point(x + 19f, 57f), style = Stroke(stroke)) + drawCircle(colors.raised, 5f * scale, point(x + 19f, 57f)) + drawCircle(colors.background, 4f * scale, point(x + 19f, 34f)) + drawCircle(colors.accent, 1.5f * scale, point(x + 19f, 74f)) + } + // Brushed-metal source unit with a backlit equalizer display. + drawRoundRect( + metal, + point(82f, 10f), + Size(96f * scale, 80f * scale), + CornerRadius(8f * scale) + ) + drawRoundRect( + colors.border, + point(82f, 10f), + Size(96f * scale, 80f * scale), + CornerRadius(8f * scale), + style = Stroke(stroke) + ) + drawRoundRect( + colors.highlight.copy(alpha = 0.25f), + point(86f, 13f), + Size(88f * scale, 1f * scale), + CornerRadius(scale) + ) + drawRoundRect( + colors.background, + point(92f, 22f), + Size(76f * scale, 30f * scale), + CornerRadius(3f * scale) + ) + val bars = listOf(8f, 16f, 12f, 22f, 18f, 10f, 15f, 7f) + bars.forEachIndexed { index, height -> + drawRoundRect( + colors.accent.copy(alpha = 0.85f), + point(99f + index * 8f, 47f - height), + Size(4f * scale, height * scale), + CornerRadius(scale) + ) + } + drawCircle(colors.background, 11f * scale, point(130f, 70f)) + drawCircle(colors.border, 11f * scale, point(130f, 70f), style = Stroke(stroke)) + drawPath(Path().apply { + moveTo(point(127f, 65f).x, point(127f, 65f).y) + lineTo(point(135f, 70f).x, point(135f, 70f).y) + lineTo(point(127f, 75f).x, point(127f, 75f).y) + close() + }, colors.text) + for (x in listOf(101f, 159f)) drawRoundRect( + colors.border, + point(x - 4f, 68f), + Size(8f * scale, 4f * scale), + CornerRadius(scale) + ) + } +} + +@Composable +internal fun SessionPanel( + state: JamUiState, + onCopy: () -> Unit, + onShare: () -> Unit, + onLeave: () -> Unit, + onKick: (String) -> Unit, + onBan: (String) -> Unit, + onMenuChange: (String?) -> Unit +) { + JamPanel { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + Modifier.size(7.dp).background( + if (state.isConnected) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant, + CircleShape + ) + ) + Text( + stringResource(if (state.isConnected) Res.string.jam_session_connected else Res.string.jam_session_connecting), + style = AppTheme.typography.labelMedium + ) + } + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(5.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + stringResource(Res.string.jam_setup_code_label), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + SelectionContainer { + Text( + state.roomCode.orEmpty(), + style = AppTheme.typography.headlineMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 5.sp, + color = AppTheme.colors.primary + ) + } + } + if (state.isHost) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumButton( + onClick = onCopy, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(if (state.codeCopied) Res.string.jam_invite_copied else Res.string.jam_invite_copy)) + } + if (state.supportsNativeShare) MillenniumButton( + onClick = onShare, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.jam_invite_share)) + } + } + } + MillenniumSeparator() + Text( + stringResource(Res.string.jam_participants_heading, state.participants.size), + style = AppTheme.typography.titleSmall + ) + Column( + Modifier.fillMaxWidth().millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(5.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.participants.isEmpty()) Text( + stringResource(Res.string.jam_participants_waiting), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + state.participants.forEach { participant -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(32.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = CircleShape, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), contentAlignment = Alignment.Center + ) { + Text(participant.initial, style = AppTheme.typography.labelMedium) + } + Column(Modifier.weight(1f)) { + Text( + if (participant.isYou) { + stringResource(Res.string.jam_participants_you, participant.displayName) + } else { + participant.displayName + }, + style = AppTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(if (participant.isHost) Res.string.jam_participants_host else Res.string.jam_participants_guest), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + if (participant.canModerate) { + // Keep destructive moderation out of the participant's primary row. + org.spotube.millennium.composables.MillenniumDropdownMenu( + expanded = participant.menuExpanded, + onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) }, + anchor = { + MillenniumButton( + onClick = { onMenuChange(participant.id) }, + style = MillenniumButtonStyle.Secondary + ) { Text(stringResource(Res.string.jam_participants_manage)) } + } + ) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.jam_participants_remove), + icon = Iconsax.IconsaxUserRemove, + onClick = { onKick(participant.id) }) + MillenniumDropdownMenuItem( + label = stringResource(Res.string.jam_participants_ban), + icon = Iconsax.IconsaxForbidden, + destructive = true, + onClick = { onBan(participant.id) }) + } + } + } + } + } + MillenniumButton( + onClick = onLeave, + loading = state.isLeaving, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(Res.string.jam_session_leave)) + } + } +} + +@Composable +internal fun ServerStatus(state: JamUiState, onConfigure: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + if (state.serverConfigured) stringResource( + Res.string.jam_connection_host, + state.serverHost + ) else stringResource(Res.string.jam_connection_missing), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + MillenniumTooltip(stringResource(Res.string.jam_connection_settings)) { + MillenniumIconButton(onClick = onConfigure, style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.IconsaxSetting2, + contentDescription = stringResource(Res.string.jam_connection_settings) + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt new file mode 100644 index 00000000..e213db40 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -0,0 +1,273 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.jam + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomCode +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.Job +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.* + +enum class JamSetupMode { Create, Join } + +data class JamParticipantUi( + val id: String, + val displayName: String, + val initial: String, + val isHost: Boolean, + val canModerate: Boolean, + val isYou: Boolean = false, + val menuExpanded: Boolean = false +) + +data class JamUiState( + val isActive: Boolean = false, + val isConnected: Boolean = false, + val role: JamRole? = null, + val participants: List = emptyList(), + val roomCode: String? = null, + val serverHost: String = "", + val serverConfigured: Boolean = false, + val error: String? = null, + val setupMode: JamSetupMode = JamSetupMode.Create, + val codeInput: TextFieldState = TextFieldState(), + val canCreate: Boolean = false, + val canJoin: Boolean = false, + val isBusy: Boolean = false, + val isLeaving: Boolean = false, + val isHost: Boolean = false, + val supportsNativeShare: Boolean = false, + val codeCopied: Boolean = false, + /** The name the user picked (blank when they haven't). */ + val customName: String = "", + /** This device's generated name, used when [customName] is blank. */ + val defaultName: String = "" +) + +class JamViewModel( + private val jamRoomService: JamRoomService, + private val shareService: ShareService, + private val settingsProvider: SettingsProvider +) : ViewModel() { + + private val _localError = MutableStateFlow(null) + private val setupMode = MutableStateFlow(JamSetupMode.Create) + private val isBusy = MutableStateFlow(false) + private val isLeaving = MutableStateFlow(false) + private val codeCopied = MutableStateFlow(false) + private val codeInput = TextFieldState() + private var copyFeedbackJob: Job? = null + private val participantMenu = MutableStateFlow(null) + private val defaultName = MutableStateFlow("") + + val supportsNativeShare: Boolean = + getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS + + val uiState: StateFlow = combine( + jamRoomService.role, + jamRoomService.participants, + jamRoomService.isConnected, + jamRoomService.roomCode, + jamRoomService.connectionError, + settingsProvider.settingsState, + _localError, + setupMode, + isBusy, + codeCopied, + snapshotFlow { codeInput.text.toString() }, + participantMenu, + isLeaving, + defaultName + ) { values -> + @Suppress("UNCHECKED_CAST") + val role = values[0] as JamRole? + + @Suppress("UNCHECKED_CAST") + val participants = values[1] as List + val isConnected = values[2] as Boolean + val roomCode = values[3] as String? + val connectionError = values[4] as String? + val settings = values[5] as? org.spotube.spotube.modules.settings.UserSettings + val localError = values[6] as String? + val busy = values[8] as Boolean + val serverConfigured = !settings?.jamServerUrl.isNullOrBlank() + + JamUiState( + isActive = role != null, + isConnected = isConnected, + role = role, + participants = participants.sortedByDescending { it.isHost }.map { participant -> + JamParticipantUi( + id = participant.id, + displayName = participant.displayName, + initial = participant.displayName.firstOrNull()?.uppercase() ?: "?", + isHost = participant.isHost, + canModerate = role == JamRole.Host && !participant.isHost, + isYou = participant.id == jamRoomService.participantClientId, + menuExpanded = values[11] == participant.id + ) + }, + roomCode = roomCode, + serverHost = settings?.jamServerUrl.orEmpty() + .substringAfter("://").substringBefore('/'), + serverConfigured = serverConfigured, + error = localError ?: connectionError, + setupMode = values[7] as JamSetupMode, + isBusy = busy, + isLeaving = values[12] as Boolean, + codeCopied = values[9] as Boolean, + codeInput = codeInput, + canCreate = serverConfigured, + canJoin = serverConfigured && + JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), + isHost = role == JamRole.Host, + supportsNativeShare = supportsNativeShare, + customName = settings?.jamParticipantName.orEmpty(), + defaultName = values[13] as String + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + JamUiState(codeInput = codeInput) + ) + + init { + viewModelScope.launch { defaultName.value = jamRoomService.defaultParticipantName() } + } + + fun setParticipantName(name: String) { + viewModelScope.launch { jamRoomService.setParticipantName(name) } + } + + // ---------- Session lifecycle ---------- + + fun createSession() { + if (isBusy.value) return + isBusy.value = true + viewModelScope.launch { + try { + jamRoomService.createRoom() + .onFailure { e -> + _localError.value = e.message ?: getString(Res.string.jam_error_create) + } + .onSuccess { _localError.value = null } + } finally { + isBusy.value = false + } + } + } + + fun selectSetupMode(mode: JamSetupMode) { + setupMode.value = mode + _localError.value = null + } + + fun joinSession() = joinWithCode(codeInput.text.toString()) + + fun onCodeCopied() { + copyFeedbackJob?.cancel() + copyFeedbackJob = viewModelScope.launch { + codeCopied.value = true + delay(2_000) + codeCopied.value = false + } + } + + fun joinWithCode(input: String) { + if (isBusy.value) return + val code = JamRoomCode.normalize(input) + if (!JamRoomCode.isValid(code)) { + viewModelScope.launch { + _localError.value = getString(Res.string.jam_error_invalid_code) + } + return + } + isBusy.value = true + viewModelScope.launch { + try { + jamRoomService.joinRoom(code) + .onFailure { e -> + _localError.value = e.message ?: getString(Res.string.jam_error_join) + } + .onSuccess { _localError.value = null } + } finally { + isBusy.value = false + } + } + } + + fun shareRoomCode() { + val code = uiState.value.roomCode ?: return + viewModelScope.launch { + shareService.share( + getString(Res.string.jam_share_message, code), + getString(Res.string.jam_screen_title) + ) + } + } + + fun leave() { + if (isLeaving.value) return + isLeaving.value = true + viewModelScope.launch { + try { + jamRoomService.leaveRoom() + _localError.value = null + } finally { + isLeaving.value = false + } + } + } + + fun clearError() { + _localError.value = null + } + + // ---------- Host moderation ---------- + + fun kickParticipant(participantId: String) { + participantMenu.value = null + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + participantMenu.value = null + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } + + fun showParticipantMenu(participantId: String?) { + participantMenu.value = participantId + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt new file mode 100644 index 00000000..15a5ec0a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/PlayDestinationPicker.kt @@ -0,0 +1,70 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.jam + +import org.spotube.millennium.composables.MillenniumAlertDialog +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_destination_message +import spotube.composeapp.generated.resources.jam_destination_play_here +import spotube.composeapp.generated.resources.jam_destination_suggest +import spotube.composeapp.generated.resources.jam_destination_title +import spotube.composeapp.generated.resources.common_cancel + +/** + * Dialog shown when the user tries to play a collection while a jam session is active. + * The user picks between playing locally on their device or suggesting it to the jam session. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onSuggestToJam: () -> Unit) { + if (!visible) return + + MillenniumAlertDialog( + visible = true, + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.jam_destination_title)) }, + description = { Text(stringResource(Res.string.jam_destination_message)) }) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = onPlayLocally, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.jam_destination_play_here)) + } + MillenniumButton(onClick = onSuggestToJam) { + Text(stringResource(Res.string.jam_destination_suggest)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt new file mode 100644 index 00000000..f2f6056d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt @@ -0,0 +1,80 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library + +import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import kotlinx.coroutines.flow.distinctUntilChanged + +/** Only reports viewport geometry. The owning ViewModel decides when to load another page. */ +@Composable +internal fun LibraryMediaCollection( + items: List, + itemKey: (T) -> String, + viewMode: LibraryViewMode, + onViewportChanged: (Int?, Int) -> Unit, + itemContent: @Composable (T, Boolean) -> Unit, + skeleton: @Composable (Boolean) -> Unit, + loading: Boolean = false, + loadingMore: Boolean = false, + header: (@Composable (Boolean) -> Unit)? = null, + modifier: Modifier = Modifier +) { + val gridState = rememberLazyGridState() + val listState = rememberLazyListState() + val reportViewport by rememberUpdatedState(onViewportChanged) + val padding = PaddingValues(top = 16.dp, bottom = LocalAppShellBottomInset.current + 16.dp) + LaunchedEffect(viewMode, gridState, listState) { + val positions = if (viewMode == LibraryViewMode.List) snapshotFlow { + listState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } else snapshotFlow { + gridState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } + positions.distinctUntilChanged().collect { (last, total) -> reportViewport(last, total) } + } + if (viewMode == LibraryViewMode.List) { + LazyColumn( + modifier.fillMaxSize(), state = listState, contentPadding = padding, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(true) } } + else { + if (header != null) item(key = "library_header") { header(true) } + itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, true) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } } + } + } + } else { + LazyVerticalGrid( + GridCells.Adaptive(160.dp), modifier.fillMaxSize(), state = gridState, + contentPadding = padding, horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(false) } } + else { + if (header != null) item(key = "library_header") { header(false) } + itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, false) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt index bc1129cf..7fb8bd0f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryRepository.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library +package org.spotube.spotube.modules.library -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.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -299,8 +299,7 @@ class LibraryRepository( isPublic: Boolean, isCollaborating: Boolean, imageBase64: String, - trackIds: List, - ): MetadataPlaylist? { + trackIds: List): MetadataPlaylist? { return authPlugin?.let { plugin -> pluginManager.withScope { plugin.use { @@ -310,8 +309,7 @@ class LibraryRepository( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) playlistCache.invalidateAll() savedPlaylistIds.value += result.id result @@ -327,8 +325,7 @@ class LibraryRepository( isPublic: Boolean?, isCollaborating: Boolean?, imageBase64: String?, - trackIds: List?, - ): MetadataPlaylist? { + trackIds: List?): MetadataPlaylist? { return authPlugin?.let { plugin -> pluginManager.withScope { plugin.use { @@ -339,8 +336,7 @@ class LibraryRepository( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) playlistCache.invalidateAll() result } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt new file mode 100644 index 00000000..144286a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryScreen.kt @@ -0,0 +1,265 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.downloads.DownloadsScreen +import org.spotube.spotube.modules.library.album.LibraryAlbumsScreen +import org.spotube.spotube.modules.library.artist.LibraryArtistsScreen +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksScreen +import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsScreen +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken +import org.jetbrains.compose.resources.StringResource +import org.spotube.spotube.modules.plugin.guard.MetadataPluginGuard +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField +import compose.icons.FeatherIcons +import compose.icons.feathericons.Grid +import compose.icons.feathericons.List +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryScreen(libraryState: LibraryState = koinInject()) { + val state by libraryState.uiState.collectAsStateWithLifecycle() + val expandedDesktop = getPlatform().isDesktop() && + (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Expanded + val compactDesktop = getPlatform().isDesktop() && isCompact + val viewMode = state.viewModeOverride ?: defaultLibraryViewMode(isCompact) + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + + if (state.currentTab == LibraryTab.Downloads && !isCompact) { + DownloadsScreen( + headerInAppBar = true, + topContent = { if (!expandedDesktop) LibraryTabs(state, libraryState::onTabSelected) } + ) + return + } + + Column(Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = false, + title = { LibraryToolbarTitle(state, expandedDesktop, compactDesktop) }, + actions = { + LibraryToolbarActions( + state = state, + expandedDesktop = expandedDesktop, + onOpenSearch = libraryState::openSearch, + onCloseSearch = { + focusManager.clearFocus() + keyboard?.hide() + libraryState.closeSearch() + }, + onCreatePlaylist = libraryState::showCreatePlaylist, + onRefresh = libraryState::refreshLocalTracks, + onToggleView = { libraryState.toggleViewMode(isCompact) }, + viewMode = viewMode, + compactDesktop = compactDesktop + ) + } + ) + if (!expandedDesktop) { + LibraryTabs(state, libraryState::onTabSelected) + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + when (state.currentTab) { + LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { + LibraryPlaylistsScreen( + showCreatePlaylist = state.showCreatePlaylist, + viewMode = viewMode + ) + } + + LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { + LibraryAlbumsScreen(viewMode = viewMode) + } + LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { + LibraryArtistsScreen(viewMode = viewMode) + } + LibraryTab.LocalTracks -> LibraryLocalTracksScreen() + LibraryTab.Downloads -> DownloadsScreen() + } + } + } +} + +@Composable +private fun LibraryTabs(state: LibraryToolbarUiState, onTabSelected: (LibraryTab) -> Unit) { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(LibraryTab.entries) { tab -> + MillenniumButton( + onClick = { onTabSelected(tab) }, + style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary + ) { Text(stringResource(tab.titleResource)) } + } + } +} + +@Composable +internal fun LibraryToolbarTitle( + state: LibraryToolbarUiState, + expandedDesktop: Boolean, + compactDesktop: Boolean = false +) { + if (!expandedDesktop && state.searchMode) { + LibraryFilterField( + state.searchState, + state.currentTab.filterPlaceholder, + Modifier.fillMaxWidth(), + autoFocus = true + ) + } else if (!expandedDesktop) { + Text( + stringResource(if (compactDesktop) Res.string.library_toolbar_compact_title else Res.string.library_screen_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun RowScope.LibraryToolbarActions( + state: LibraryToolbarUiState, + expandedDesktop: Boolean, + onOpenSearch: () -> Unit, + onCloseSearch: () -> Unit, + onCreatePlaylist: () -> Unit, + onRefresh: () -> Unit, + onToggleView: () -> Unit = {}, + viewMode: LibraryViewMode = state.viewModeOverride ?: defaultLibraryViewMode(!expandedDesktop), + compactDesktop: Boolean = false +) { + val actionModifier = Modifier.size(if (compactDesktop) 32.dp else 40.dp) + if (!expandedDesktop && state.searchMode) { + MillenniumIconButton( + icon = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.library_action_close_search), + onClick = onCloseSearch, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } else { + if (state.canFilter) { + if (expandedDesktop) { + LibraryFilterField( + state.searchState, + state.currentTab.filterPlaceholder, + Modifier.width(300.dp) + ) + } else { + MillenniumIconButton( + icon = Iconsax.IconsaxSearchBroken, + contentDescription = stringResource(Res.string.library_action_search), + onClick = onOpenSearch, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + } + if (state.canCreatePlaylist) { + MillenniumIconButton( + icon = Iconsax.IconsaxAddSquare, + contentDescription = stringResource(Res.string.library_action_create_playlist), + onClick = onCreatePlaylist, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + if (state.canRefresh) { + MillenniumIconButton( + icon = Iconsax.IconsaxRefreshRight, + contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), + onClick = onRefresh, + loading = state.isRefreshing, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + if (state.canChangeView) { + MillenniumIconButton( + icon = if (viewMode == LibraryViewMode.List) FeatherIcons.Grid else FeatherIcons.List, + contentDescription = stringResource(if (viewMode == LibraryViewMode.List) Res.string.library_action_grid_view else Res.string.library_action_list_view), + onClick = onToggleView, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + } +} + +@Composable +private fun LibraryFilterField( + state: TextFieldState, + placeholder: StringResource, + modifier: Modifier, + autoFocus: Boolean = false +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { + if (autoFocus) focusRequester.requestFocus() + } + MillenniumTextField( + state = state, + placeholder = stringResource(placeholder), + minimumHeight = 36.dp, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt new file mode 100644 index 00000000..cb85e965 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryState.kt @@ -0,0 +1,136 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +enum class LibraryViewMode { Grid, List } + +data class LibraryToolbarUiState( + val currentTab: LibraryTab = LibraryTab.Playlists, + val searchMode: Boolean = false, + val searchState: TextFieldState = TextFieldState(), + val canFilter: Boolean = true, + val canCreatePlaylist: Boolean = true, + val canRefresh: Boolean = false, + val isRefreshing: Boolean = false, + val showCreatePlaylist: Boolean = false, + val canChangeView: Boolean = true, + val viewModeOverride: LibraryViewMode? = null +) + +/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */ +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryState : ViewModel() { + private val searchMode = MutableStateFlow(false) + val currentTab = MutableStateFlow(LibraryTab.Playlists) + private val searchStates = LibraryTab.entries.associateWith { TextFieldState() } + private val createPlaylistVisible = MutableStateFlow(false) + private val viewModes = MutableStateFlow>(emptyMap()) + private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) + val localRefreshRequests = refreshRequests.asSharedFlow() + private val localRefreshing = MutableStateFlow(false) + + val uiState = combine( + currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, + searchMode, + createPlaylistVisible, + viewModes, + localRefreshing + ) { (tab, query), searching, creating, modes, refreshing -> + LibraryToolbarUiState( + currentTab = tab, + searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), + searchState = searchStates.getValue(tab), + canFilter = tab != LibraryTab.Downloads, + canCreatePlaylist = tab == LibraryTab.Playlists, + canRefresh = tab == LibraryTab.LocalTracks, + isRefreshing = tab == LibraryTab.LocalTracks && refreshing, + showCreatePlaylist = tab == LibraryTab.Playlists && creating, + canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, + viewModeOverride = modes[tab] + ) + }.stateIn( + viewModelScope, SharingStarted.Eagerly, + LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists)) + ) + + fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() } + + fun setQuery(tab: LibraryTab, query: String) { + searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query) + } + + fun onTabSelected(tab: LibraryTab) { + currentTab.value = tab + createPlaylistVisible.value = false + if (tab == LibraryTab.Downloads) searchMode.value = false + } + + fun openSearch() { + if (currentTab.value != LibraryTab.Downloads) searchMode.value = true + } + + fun closeSearch() { + setQuery(currentTab.value, "") + searchMode.value = false + } + + fun showCreatePlaylist() { + if (currentTab.value == LibraryTab.Playlists) createPlaylistVisible.value = true + } + + fun dismissCreatePlaylist() { + createPlaylistVisible.value = false + } + + fun refreshLocalTracks() { + refreshRequests.tryEmit(Unit) + } + + /** Reported by the local tracks ViewModel so the toolbar's refresh action can show progress. */ + fun setLocalRefreshing(refreshing: Boolean) { + localRefreshing.value = refreshing + } + + fun toggleViewMode(isCompact: Boolean) { + val tab = currentTab.value + if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return + viewModes.update { modes -> + val current = modes[tab] ?: defaultLibraryViewMode(isCompact) + modes + (tab to if (current == LibraryViewMode.Grid) LibraryViewMode.List else LibraryViewMode.Grid) + } + } +} + +internal fun defaultLibraryViewMode(isCompact: Boolean) = + if (isCompact) LibraryViewMode.List else LibraryViewMode.Grid diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt new file mode 100644 index 00000000..c2e9aa22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryTab.kt @@ -0,0 +1,54 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library + +import androidx.compose.ui.graphics.vector.ImageVector +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusicDashboard +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.spotube.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.* + +enum class LibraryTab(val icon: ImageVector) { + Playlists(Iconsax.IconsaxMusicDashboard), + Albums(Iconsax.IconsaxCd), + Artists(Iconsax.User), + LocalTracks(Iconsax.IconsaxMusicLibrary), + Downloads(Iconsax.IconsaxDirectboxReceive); + + val titleResource: StringResource + get() = when (this) { + Playlists -> Res.string.library_tab_playlists + Albums -> Res.string.library_tab_albums + Artists -> Res.string.library_tab_artists + LocalTracks -> Res.string.library_tab_local_tracks + Downloads -> Res.string.library_tab_downloads + } + + val filterPlaceholder: StringResource + get() = when (this) { + Playlists -> Res.string.library_filter_playlists + Albums -> Res.string.library_filter_albums + Artists -> Res.string.library_filter_artists + LocalTracks -> Res.string.library_filter_local_tracks + Downloads -> Res.string.search_filter_placeholder + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt new file mode 100644 index 00000000..572f31e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -0,0 +1,76 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.album + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryAlbumsScreen( + modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, + viewModel: LibraryAlbumsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val current = state) { + is LibraryAlbumsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } + + else -> { + val data = current as? LibraryAlbumsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryAlbumsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_albums_no_results, + data.query + ) + else stringResource(Res.string.library_albums_empty) + ) + } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryAlbumsState.Loading, + loadingMore = current is LibraryAlbumsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + AlbumCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt similarity index 60% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 42534e03..6db962c1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -15,24 +15,30 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.album +package org.spotube.spotube.modules.library.album import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.library.LibraryRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface LibraryAlbumsState { data object Loading : LibraryAlbumsState @@ -46,43 +52,41 @@ sealed interface LibraryAlbumsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, + override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { album -> - album.title.contains(query, ignoreCase = true) || - album.artists.any { artist -> - artist.name.contains(query, ignoreCase = true) - } - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, + nextPagination = nextPagination ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, + override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { album -> - album.title.contains(query, ignoreCase = true) || - album.artists.any { artist -> - artist.name.contains(query, ignoreCase = true) - } - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } } + } else { + allItems + } } } @@ -92,11 +96,20 @@ sealed interface LibraryAlbumsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryAlbumsViewModel( private val repository: LibraryRepository, + private val libraryState: LibraryState ) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryAlbumsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Albums) + ) { state, query -> + when (state) { + is LibraryAlbumsState.Data.Loaded -> state.copy(query = query) + is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading) init { viewModelScope.launch { @@ -117,11 +130,11 @@ class LibraryAlbumsViewModel( val result = repository.savedAlbums() _state.value = LibraryAlbumsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination, + nextPagination = result?.nextPagination ) }.onFailure { e -> logger.e(e) { "Failed to load albums" } - _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") + _state.value = LibraryAlbumsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreData() = runCatching { @@ -132,21 +145,21 @@ class LibraryAlbumsViewModel( _state.value = LibraryAlbumsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination, + nextPagination = result?.nextPagination ) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } - _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") + _state.value = LibraryAlbumsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun onQueryChange(query: String) { - val currentState = _state.value - if (currentState is LibraryAlbumsState.Data) { - _state.value = when (currentState) { - is LibraryAlbumsState.Data.Loaded -> currentState.copy(query = query) - is LibraryAlbumsState.Data.LoadingMore -> currentState.copy(query = query) - } + libraryState.setQuery(LibraryTab.Albums, query) + } + + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt new file mode 100644 index 00000000..1ab6c08f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -0,0 +1,76 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.artist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryArtistsScreen( + modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, + viewModel: LibraryArtistsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (val current = state) { + is LibraryArtistsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } + + else -> { + val data = current as? LibraryArtistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryArtistsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_artists_no_results, + data.query + ) + else stringResource(Res.string.library_artists_empty) + ) + } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryArtistsState.Loading, + loadingMore = current is LibraryArtistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + ArtistCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + AvatarCard( + stringResource(Res.string.media_preview_subtitle), + stringResource(Res.string.media_artist), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt similarity index 65% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt index aeb1564c..430feef5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -15,24 +15,30 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.artist +package org.spotube.spotube.modules.library.artist 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.common.PaginationStrategy -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.library.LibraryRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface LibraryArtistsState { data object Loading : LibraryArtistsState @@ -46,37 +52,35 @@ sealed interface LibraryArtistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, + override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { artist -> - artist.name.contains(query, ignoreCase = true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, + nextPagination = nextPagination ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, + override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { artist -> - artist.name.contains(query, ignoreCase = true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) } + } else { + allItems + } } } @@ -86,11 +90,20 @@ sealed interface LibraryArtistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryArtistsViewModel( private val repository: LibraryRepository, + private val libraryState: LibraryState ) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryArtistsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Artists) + ) { state, query -> + when (state) { + is LibraryArtistsState.Data.Loaded -> state.copy(query = query) + is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading) init { viewModelScope.launch { @@ -115,14 +128,13 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri, + externalUri = it.externalUri ) } ?: emptyList(), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load artists" } - _state.value = LibraryArtistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryArtistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreData() = runCatching { @@ -135,27 +147,27 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri, + externalUri = it.externalUri ) } ?: emptyList() _state.value = LibraryArtistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + newItems, - nextPagination = result?.nextPagination, + nextPagination = result?.nextPagination ) } }.onFailure { e -> logger.e(e) { "Failed to load more artists" } - _state.value = LibraryArtistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryArtistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun onQueryChange(query: String) { - val currentState = _state.value - if (currentState is LibraryArtistsState.Data) { - _state.value = when (currentState) { - is LibraryArtistsState.Data.Loaded -> currentState.copy(query = query) - is LibraryArtistsState.Data.LoadingMore -> currentState.copy(query = query) - } + libraryState.setQuery(LibraryTab.Artists, query) + } + + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt new file mode 100644 index 00000000..e379c8db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -0,0 +1,342 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.spotube.spotube.core.ui.component.cards.MediaArtwork +import org.spotube.spotube.core.ui.component.cards.MediaEmptyState +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersDialogHost +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.pluralStringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryLocalTracksScreen( + modifier: Modifier = Modifier, + viewModel: LibraryLocalTracksViewModel = koinViewModel(), + foldersViewModel: LocalMediaFoldersViewModel = koinViewModel(), + navigationCommands: NavigationCommands = koinInject() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val permission = rememberLocalMediaPermissionState() + // Platform permission events only; the ViewModel decides whether/when to index media. + LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } + LocalTracksContent( + state, + onOpenFolder = { navigationCommands.navigateTo(Routes.LocalFolder(it)) }, + onPlayTrack = {}, onNavigateUp = {}, onPlayFolder = {}, + onRequestPermission = permission.requestPermission, modifier = modifier, + onManageFolders = foldersViewModel::open + ) + LocalMediaFoldersDialogHost(foldersViewModel) +} + +@Composable +internal fun LocalTracksContent( + state: LibraryLocalTracksState, + onOpenFolder: (String) -> Unit, + onPlayTrack: (String) -> Unit, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + onRequestPermission: () -> Unit, + modifier: Modifier = Modifier, + onManageFolders: () -> Unit = {} +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column( + Modifier.widthIn(max = 960.dp).fillMaxSize() + ) { + LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder, onManageFolders = onManageFolders) + when { + !state.canAccessMedia -> MediaEmptyState( + Iconsax.IconsaxFolderOpen, + stringResource(Res.string.library_local_permission_title), + Modifier.padding(bottom = inset), + stringResource(Res.string.library_local_permission_description), + action = { + MillenniumButton(onClick = onRequestPermission) { + Text( + stringResource( + Res.string.library_local_grant_access + ) + ) + } + }) + + state.isRefreshing && state.visibleFolders.isEmpty() && state.visibleTracks.isEmpty() -> Box( + Modifier.fillMaxSize().padding(bottom = inset), + contentAlignment = Alignment.Center + ) { + MillenniumCircularProgress(size = 40.dp) + } + + state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(state.visibleFolders, key = { it.path }) { folder -> + LocalFolderRow(folder) { onOpenFolder(folder.path) } + } + } + + state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(state.visibleTracks, key = { it.path }) { track -> + PlayableListTile( + track.title, + track.subtitle ?: stringResource(Res.string.media_unknown_artist), + imageModel = track.artwork, + onClick = { onPlayTrack(track.path) }, + onPlay = { onPlayTrack(track.path) }, + compact = true, + trailingContent = { + track.durationLabel?.let { + Text( + it, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + }) + } + } + + else -> { + val noFolders = state.currentFolderPath == null && state.query.isBlank() + MediaEmptyState( + Iconsax.IconsaxFolderOpen, + state.error ?: stringResource( + when { + state.query.isNotBlank() -> Res.string.library_local_no_results + state.currentFolderPath == null -> Res.string.library_local_empty_folders + else -> Res.string.library_local_empty_tracks + } + ), Modifier.padding(bottom = inset), + description = if (noFolders && state.error == null) + stringResource(Res.string.library_local_empty_folders_description) else null, + action = if (noFolders) ({ + MillenniumButton(onClick = onManageFolders) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderAdd, null, + modifier = Modifier.size(18.dp) + ) + Text(stringResource(Res.string.library_local_add_folder)) + } + } + }) else null + ) + } + } + } + } +} + +@Composable +internal fun LocalTracksBreadcrumb( + state: LibraryLocalTracksState, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + modifier: Modifier = Modifier, + onManageFolders: (() -> Unit)? = null +) { + val backDescription = stringResource(Res.string.library_local_back) + Row( + modifier.fillMaxWidth().height(40.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(14.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + if (state.currentFolderPath != null) { + MillenniumButton( + onClick = onNavigateUp, + style = MillenniumButtonStyle.Quiet, + modifier = Modifier.height(28.dp) + .semantics { contentDescription = backDescription }, + padding = PaddingValues(0.dp), + minSize = DpSize(0.dp, 28.dp), + foreground = AppTheme.colors.primary + ) { + Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall + ) + } + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(12.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + state.currentFolder?.name ?: stringResource(Res.string.library_local_folder), + style = AppTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } else Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Text( + if (state.currentFolderPath != null) pluralStringResource( + Res.plurals.library_local_tracks_count, + state.visibleTracks.size, + state.visibleTracks.size + ) + else stringResource( + Res.string.library_local_summary, + pluralStringResource( + Res.plurals.library_local_folders_count, + state.totalFolderCount, + state.totalFolderCount + ), + pluralStringResource( + Res.plurals.library_local_tracks_count, + state.totalTrackCount, + state.totalTrackCount + ) + ), + style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant, + maxLines = 1 + ) + if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp) + if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton( + onClick = onManageFolders, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxFolderAdd, + stringResource(Res.string.library_local_add_folder), + modifier = Modifier.size(22.dp) + ) + } + if (state.currentFolderPath != null) MillenniumIconButton( + onClick = onPlayFolder, enabled = state.canPlayFolder, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxPlay, + stringResource(Res.string.library_local_play_folder), + modifier = Modifier.size(22.dp) + ) + } + } +} + +@Composable +internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { + MillenniumListTile( + onClick = onClick, + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + contentPadding = PaddingValues(8.dp), + minimumHeight = 52.dp, + leading = { + MediaArtwork( + folder.artwork, + folder.name, + Modifier.size(36.dp), + placeholderIcon = Iconsax.IconsaxFolderOpen + ) + }, + title = { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = { + Text( + pluralStringResource( + Res.plurals.library_local_tracks_count, + folder.trackCount, + folder.trackCount + ), style = AppTheme.typography.bodySmall + ) + }, + trailing = { + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(16.dp) + ) + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt new file mode 100644 index 00000000..ab9440d2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -0,0 +1,199 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library.local_tracks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_local_refresh_failed + +data class LocalFolderUi( + val path: String, + val name: String, + val trackCount: Int, + val artwork: ByteArray? +) + +data class LocalTrackUi( + val path: String, + val title: String, + val subtitle: String?, + val durationLabel: String?, + val artwork: ByteArray? +) + +data class LibraryLocalTracksState( + val isRefreshing: Boolean = false, + val folders: List = emptyList(), + val currentFolderPath: String? = null, + val query: String = "", + val error: String? = null, + val canAccessMedia: Boolean = true, + val visibleFolders: List = emptyList(), + val visibleTracks: List = emptyList(), + val currentFolder: LocalMediaFolder? = null, + val totalFolderCount: Int = 0, + val totalTrackCount: Int = 0, + val canPlayFolder: Boolean = false +) + +internal fun localTracksUiState( + source: LibraryLocalTracksState, + query: String +): LibraryLocalTracksState { + val normalized = query.trim() + val folder = source.folders.firstOrNull { it.path == source.currentFolderPath } + val folders = source.folders.filter { + normalized.isEmpty() || it.name.contains( + normalized, + true + ) || it.tracks.any { track -> localTrackMatches(track, normalized) } + } + val tracks = folder?.tracks.orEmpty().filter { localTrackMatches(it, normalized) } + return source.copy( + query = query, + currentFolder = folder, + visibleFolders = folders.map { + LocalFolderUi( + it.path, + it.name, + it.trackCount, + it.tracks.firstOrNull { track -> track.coverBytes?.isNotEmpty() == true }?.coverBytes + ) + }, + visibleTracks = tracks.map { track -> + val seconds = track.durationMs / 1000 + LocalTrackUi( + track.path, track.name, + listOfNotNull( + track.artists.joinToString(", ").takeIf { it.isNotBlank() }, + track.album?.takeIf { it.isNotBlank() }).joinToString(" • ") + .takeIf { it.isNotBlank() }, + if (seconds > 0) "${seconds / 60}:${ + (seconds % 60).toString().padStart(2, '0') + }" else null, + track.coverBytes?.takeIf { it.isNotEmpty() }) + }, + totalFolderCount = source.folders.size, + totalTrackCount = source.folders.sumOf { it.trackCount }, + canPlayFolder = source.canAccessMedia && tracks.isNotEmpty() + ) +} + +private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean = query.isBlank() || + track.name.contains(query, true) || track.album?.contains( + query, + true +) == true || track.artists.any { it.contains(query, true) } + +class LibraryLocalTracksViewModel( + private val coordinator: LocalMediaLibraryCoordinator, + private val libraryState: LibraryState +) : ViewModel() { + private val requiresPermission = getPlatform().type == PlatformType.Android + private val _state = + MutableStateFlow(LibraryLocalTracksState(canAccessMedia = !requiresPermission)) + val state: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.LocalTracks) + ) { state, query -> localTracksUiState(state, query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), _state.value) + + init { + viewModelScope.launch { + libraryState.localRefreshRequests.collect { refresh() } + } + viewModelScope.launch { + coordinator.cacheState + .collect { cache -> + _state.update { + it.copy( + folders = cache.folders, + error = null + ) + } + } + } + + refreshIfEmpty() + refreshIfStale() + } + + fun onQueryChange(query: String) { + libraryState.setQuery(LibraryTab.LocalTracks, query) + } + + fun onPermissionChanged(granted: Boolean) { + val accessible = !requiresPermission || granted + val wasAccessible = _state.value.canAccessMedia + _state.update { it.copy(canAccessMedia = accessible) } + if (accessible && !wasAccessible) refresh() + } + + fun refresh() { + if (!_state.value.canAccessMedia || _state.value.isRefreshing) return + _state.update { it.copy(isRefreshing = true, error = null) } + libraryState.setLocalRefreshing(true) + viewModelScope.launch { + try { + runCatching { + coordinator.refreshNow("manual") + }.onFailure { throwable -> + if (throwable is CancellationException) throw throwable + val message = + throwable.message ?: getString(Res.string.library_local_refresh_failed) + _state.update { + it.copy(error = message) + } + } + } finally { + _state.update { it.copy(isRefreshing = false) } + libraryState.setLocalRefreshing(false) + } + } + } + + private fun refreshIfEmpty() { + if (_state.value.folders.isNotEmpty()) return + refresh() + } + + private fun refreshIfStale() { + if (!_state.value.canAccessMedia) return + viewModelScope.launch { + runCatching { + coordinator.refreshIfStale() + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt new file mode 100644 index 00000000..d3583913 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt @@ -0,0 +1,35 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.ApplicationMainBar + +@Composable +fun LocalFolderScreen(viewModel: LocalFolderViewModel, onBack: () -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + Column { + ApplicationMainBar( + backButton = true, + title = { Text(state.currentFolder?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) } + ) + LocalTracksContent( + state, + onOpenFolder = {}, + onPlayTrack = viewModel::playTrack, + onNavigateUp = onBack, + onPlayFolder = viewModel::playFolder, + onRequestPermission = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt new file mode 100644 index 00000000..01e4f6d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack + +class LocalFolderViewModel( + private val folderPath: String, + coordinator: LocalMediaLibraryCoordinator, + private val audioPlayerQueue: AudioPlayerQueue +) : ViewModel() { + val state: StateFlow = coordinator.cacheState + .map { cache -> + localTracksUiState( + LibraryLocalTracksState(folders = cache.folders, currentFolderPath = folderPath), + "" + ) + } + .stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), + localTracksUiState(LibraryLocalTracksState(currentFolderPath = folderPath), "") + ) + + fun playFolder() = play(0) + + fun playTrack(trackPath: String) { + val index = tracks().indexOfFirst { it.path == trackPath } + if (index >= 0) play(index) + } + + private fun tracks(): List = + state.value.currentFolder?.tracks.orEmpty() + + private fun play(startPosition: Int) { + val tracks = tracks() + if (tracks.isEmpty()) return + viewModelScope.launch { + audioPlayerQueue.load( + entries = tracks.map { + QueueEntry.LocalTrack( + name = it.name, + artists = it.artists, + duration = it.durationMs, + album = it.album, + coverBytes = it.coverBytes, + url = it.path + ) + }, + autoPlay = true, + startPosition = startPosition, + collectionEntry = null + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt index 3442d69a..44775f22 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaCacheRepository.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.datastore.preferences.core.edit -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.db.DatabaseKeys +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt index ecf551f9..9de2054d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media typealias LocalMediaChangeCallback = () -> Unit diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt index 882c5cd5..9b0f1740 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt @@ -15,17 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.settings.SettingsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class LocalMediaFoldersConfig( private val settingsRepository: SettingsRepository, - private val paths: Paths, -) { + private val paths: Paths) { val configuredRootsFlow: Flow> = settingsRepository.userSettings.map { settings -> val cacheRoot = if (settings.enableMusicCaching) { listOf(settings.cacheFolder ?: paths.getMusicCacheDirPath()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt index 60b03e58..f2d17713 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.di.injectLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -35,8 +35,7 @@ import org.koin.core.component.KoinComponent class LocalMediaLibraryCoordinator( private val discoveryService: LocalMediaDiscoveryService, private val cacheRepository: LocalMediaCacheRepository, - private val foldersConfig: LocalMediaFoldersConfig, -) : KoinComponent { + private val foldersConfig: LocalMediaFoldersConfig) : KoinComponent { private val logger by injectLogger() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -100,8 +99,7 @@ class LocalMediaLibraryCoordinator( }.onSuccess { folders -> val cache = LocalMediaCache( folders = folders, - indexedAtEpochMs = Clock.System.now().toEpochMilliseconds(), - ) + indexedAtEpochMs = Clock.System.now().toEpochMilliseconds()) cacheRepository.save(cache) _cacheState.value = cache }.onFailure { throwable -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt index d8264f6a..e6ad9ee6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import kotlinx.serialization.Serializable @@ -26,15 +26,13 @@ data class LocalMediaTrack( val artists: List, val durationMs: Long, val album: String? = null, - val coverBytes: ByteArray? = null, -) + val coverBytes: ByteArray? = null) @Serializable data class LocalMediaFolder( val path: String, val name: String, - val tracks: List, -) { + val tracks: List) { val trackCount: Int get() = tracks.size } @@ -42,5 +40,4 @@ data class LocalMediaFolder( @Serializable data class LocalMediaCache( val folders: List = emptyList(), - val indexedAtEpochMs: Long = 0, -) + val indexedAtEpochMs: Long = 0) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt similarity index 89% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt index b9fef9c4..1f22bca0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt @@ -15,14 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.compose.runtime.Composable data class LocalMediaPermissionState( val isGranted: Boolean, - val requestPermission: () -> Unit, -) + val requestPermission: () -> Unit) @Composable expect fun rememberLocalMediaPermissionState(): LocalMediaPermissionState diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt index 0ad5288a..c2c7cd5e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media class NoopLocalMediaDiscoveryService : LocalMediaDiscoveryService { override suspend fun discoverFolders(roots: List): List { @@ -24,8 +24,7 @@ class NoopLocalMediaDiscoveryService : LocalMediaDiscoveryService { override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { return null } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt similarity index 54% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt index c504d0b8..48326a1e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -15,8 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.playlist +package org.spotube.spotube.modules.library.playlist + +import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,13 +39,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,6 +54,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,16 +62,24 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_add_to_playlist_empty +import spotube.composeapp.generated.resources.library_add_to_playlist_loading +import spotube.composeapp.generated.resources.library_add_to_playlist_search_content_description +import spotube.composeapp.generated.resources.library_add_to_playlist_search_placeholder +import spotube.composeapp.generated.resources.library_add_to_playlist_title +import spotube.composeapp.generated.resources.library_local_tracks_count +import spotube.composeapp.generated.resources.common_cancel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddToPlaylistPicker( visible: Boolean, @@ -68,18 +87,22 @@ fun AddToPlaylistPicker( onDismiss: () -> Unit, onPlaylistSelected: (String) -> Unit, modifier: Modifier = Modifier, - libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f, -) { + libraryRepository: LibraryRepository = koinInject()) { if (!visible) return - val adaptiveInfo = currentWindowAdaptiveInfo() - val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium var playlists by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var searchQuery by remember { mutableStateOf("") } val scope = rememberCoroutineScope() + val searchState = rememberTextFieldState(searchQuery) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect { searchQuery = it } + } LaunchedEffect(visible, currentUserId) { if (visible) { @@ -107,21 +130,21 @@ fun AddToPlaylistPicker( val content: @Composable () -> Unit = { Column(modifier = Modifier.fillMaxSize()) { - TextField( - value = searchQuery, - onValueChange = { searchQuery = it }, + Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 12.dp), - placeholder = { Text("Search your playlists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = stringResource(Res.string.library_add_to_playlist_search_content_description)) + MillenniumTextField( + state = searchState, + + modifier = Modifier.weight(1f), + placeholder = stringResource(Res.string.library_add_to_playlist_search_placeholder)) + } PlaylistPickerContent( playlists = filteredPlaylists, @@ -129,46 +152,29 @@ fun AddToPlaylistPicker( onPlaylistSelected = { playlistId -> onPlaylistSelected(playlistId) onDismiss() - }, - ) + }) } } - if (isLargeScreen) { - AlertDialog( - onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text("Add to Your Playlist") - }, - text = { - Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { - content() + val pickerTitle = stringResource(Res.string.library_add_to_playlist_title) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + dialogTitle = pickerTitle, + title = { Text(pickerTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_cancel)) } - }, - confirmButton = { - OutlineButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) - } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = "Add to Your Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) - content() } + }) { + Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { + content() } } } @@ -177,26 +183,23 @@ fun AddToPlaylistPicker( private fun PlaylistPickerContent( playlists: List, isLoading: Boolean, - onPlaylistSelected: (String) -> Unit, -) { + onPlaylistSelected: (String) -> Unit) { if (isLoading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Loading your playlists...") + Text(stringResource(Res.string.library_add_to_playlist_loading)) } } else if (playlists.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("You don't own any playlists yet.\nCreate one to add tracks.") + Text(stringResource(Res.string.library_add_to_playlist_empty)) } } else { LazyColumn( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(playlists, key = { it.id }) { playlist -> + verticalArrangement = Arrangement.spacedBy(4.dp)) { + itemsWithUniqueKeys(playlists, id = { it.id }) { playlist -> PlaylistPickerRow( playlist = playlist, - onClick = { onPlaylistSelected(playlist.id) }, - ) + onClick = { onPlaylistSelected(playlist.id) }) } } } @@ -205,8 +208,7 @@ private fun PlaylistPickerContent( @Composable private fun PlaylistPickerRow( playlist: MetadataPlaylist, - onClick: () -> Unit, -) { + onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -214,8 +216,7 @@ private fun PlaylistPickerRow( .clickable(onClick = onClick) .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { val imageUrl = playlist.thumbnails.firstOrNull()?.url if (imageUrl != null) { AsyncImage( @@ -224,34 +225,29 @@ private fun PlaylistPickerRow( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Box( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = playlist.title.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) } } Column(modifier = Modifier.weight(1f)) { Text( text = playlist.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( - text = "${playlist.trackCount} tracks", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = pluralStringResource(Res.plurals.library_local_tracks_count, playlist.trackCount, playlist.trackCount), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt new file mode 100644 index 00000000..85a7f52a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -0,0 +1,96 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.playlist + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.LikedTracksCard +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.modules.library.LibraryMediaCollection +import org.spotube.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* + +@Composable +fun LibraryPlaylistsScreen( + modifier: Modifier = Modifier, + showCreatePlaylist: Boolean = false, + viewMode: LibraryViewMode = LibraryViewMode.Grid, + viewModel: LibraryPlaylistsViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle() + Column(modifier) { + when (val current = state) { + is LibraryPlaylistsState.Error -> Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } + + else -> { + val data = current as? LibraryPlaylistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_playlists_no_results, + data.query + ) + else stringResource(Res.string.library_playlists_empty) + ) + } + } else { + LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryPlaylistsState.Loading, + loadingMore = current is LibraryPlaylistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + header = { list -> + LikedTracksCard( + Modifier.fillMaxWidth(), + listMode = list + ) + }, + itemContent = { item, list -> + PlaylistCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + } + ) + } + } + } + PlaylistFormSheet( + visible = showCreatePlaylist, + formState = playlistFormState, + onDismiss = viewModel::dismissCreatePlaylist, + onSubmit = viewModel::createPlaylist, + onImagePicked = viewModel::onPlaylistFormImagePicked + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt similarity index 58% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 97f580bc..7ec3d6ca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -15,24 +15,31 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.playlist +package org.spotube.spotube.modules.library.playlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.library.LibraryRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import io.github.vinceglb.filekit.PlatformFile import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface LibraryPlaylistsState { data object Loading : LibraryPlaylistsState @@ -46,39 +53,37 @@ sealed interface LibraryPlaylistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, + override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { - it.title.contains(query, ignoreCase = true) || + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || (it.description?.contains(query, ignoreCase = true) == true) - } - } else { - allItems } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, + nextPagination = nextPagination ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, + override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { - it.title.contains(query, ignoreCase = true) || + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || (it.description?.contains(query, ignoreCase = true) == true) - } - } else { - allItems } + } else { + allItems + } } } @@ -88,11 +93,20 @@ sealed interface LibraryPlaylistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryPlaylistsViewModel( private val repository: LibraryRepository, + private val libraryState: LibraryState ) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryPlaylistsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.Playlists) + ) { state, query -> + when (state) { + is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query) + is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query) + else -> state + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading) init { viewModelScope.launch { @@ -113,11 +127,11 @@ class LibraryPlaylistsViewModel( val result = repository.savedPlaylists() _state.value = LibraryPlaylistsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination, + nextPagination = result?.nextPagination ) }.onFailure { e -> logger.e(e) { "Failed to load playlists" } - _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryPlaylistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreData() = runCatching { @@ -128,21 +142,47 @@ class LibraryPlaylistsViewModel( _state.value = LibraryPlaylistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination, + nextPagination = result?.nextPagination ) } }.onFailure { e -> logger.e(e) { "Failed to load more playlists" } - _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") + _state.value = LibraryPlaylistsState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun onQueryChange(query: String) { - val currentState = _state.value - if (currentState is LibraryPlaylistsState.Data) { - _state.value = when (currentState) { - is LibraryPlaylistsState.Data.Loaded -> currentState.copy(query = query) - is LibraryPlaylistsState.Data.LoadingMore -> currentState.copy(query = query) - } + libraryState.setQuery(LibraryTab.Playlists, query) + } + + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + + private val playlistForm = PlaylistFormController(viewModelScope, logger) + val playlistFormState: StateFlow = playlistForm.uiState + + fun onPlaylistFormImagePicked(file: PlatformFile) = playlistForm.onImagePicked(file) + + fun dismissCreatePlaylist() { + if (playlistFormState.value.isSubmitting) return + playlistForm.dismiss() + libraryState.dismissCreatePlaylist() + } + + fun createPlaylist(data: PlaylistFormData) { + playlistForm.submit(onSuccess = libraryState::dismissCreatePlaylist) { + repository.createPlaylist( + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64, + trackIds = emptyList() + ) + repository.invalidateCaches() + loadInitialData() } } @@ -152,31 +192,4 @@ class LibraryPlaylistsViewModel( loadInitialData() } } - - fun createPlaylist( - name: String, - description: String?, - isPublic: Boolean, - isCollaborating: Boolean, - imageBase64: String, - trackIds: List, - ) { - viewModelScope.launch { - runCatching { - repository.createPlaylist( - name = name, - description = description, - isPublic = isPublic, - isCollaborating = isCollaborating, - imageBase64 = imageBase64, - trackIds = trackIds, - ) - }.onFailure { e -> - logger.e(e) { "Failed to create playlist" } - }.onSuccess { - repository.invalidateCaches() - loadInitialData() - } - } - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt new file mode 100644 index 00000000..ed60c201 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormController.kt @@ -0,0 +1,96 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library.playlist + +import co.touchlab.kermit.Logger +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +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 kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString + +data class PlaylistFormUiState( + val isVisible: Boolean = false, + val isSubmitting: Boolean = false, + val isProcessingImage: Boolean = false, + /** Base64 of an image picked in this session; `null` keeps the playlist's current cover. */ + val pickedImageBase64: String? = null +) + +/** + * Async state of [PlaylistFormSheet] (visibility, image encoding, submission), owned by the + * ViewModel that opens the sheet and driven on its [scope]. + */ +class PlaylistFormController( + private val scope: CoroutineScope, + private val logger: Logger +) { + private val _uiState = MutableStateFlow(PlaylistFormUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun show() { + _uiState.value = PlaylistFormUiState(isVisible = true) + } + + fun dismiss() { + if (_uiState.value.isSubmitting) return + _uiState.value = PlaylistFormUiState() + } + + fun onImagePicked(file: PlatformFile) { + if (_uiState.value.isProcessingImage) return + _uiState.update { it.copy(isProcessingImage = true) } + scope.launch { + runCatching { + withContext(Dispatchers.Default) { file.readBytes().toByteString().base64() } + }.onSuccess { base64 -> + _uiState.update { it.copy(pickedImageBase64 = base64) } + }.onFailure { e -> + logger.e(e) { "Failed to read playlist cover image" } + } + _uiState.update { it.copy(isProcessingImage = false) } + } + } + + /** + * Runs [action]; the sheet closes (and [onSuccess] runs) only if it succeeds, and stays open + * (re-enabled) on failure. + */ + fun submit(onSuccess: () -> Unit = {}, action: suspend () -> Unit) { + val current = _uiState.value + if (current.isSubmitting || current.isProcessingImage) return + _uiState.update { it.copy(isSubmitting = true) } + scope.launch { + runCatching { action() } + .onSuccess { + _uiState.value = PlaylistFormUiState() + onSuccess() + } + .onFailure { e -> + logger.e(e) { "Failed to submit playlist form" } + _uiState.update { it.copy(isSubmitting = false) } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt new file mode 100644 index 00000000..7ef9e130 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -0,0 +1,312 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library.playlist + +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import androidx.compose.foundation.clickable +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +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.runtime.snapshotFlow +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.unit.dp +import coil3.compose.AsyncImage +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.PlatformFile +import kotlinx.coroutines.flow.drop +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_playlist_form_change_image +import spotube.composeapp.generated.resources.library_playlist_form_collaborative +import spotube.composeapp.generated.resources.library_playlist_form_collaborative_description +import spotube.composeapp.generated.resources.library_playlist_form_cover_content_description +import spotube.composeapp.generated.resources.library_playlist_form_cover_image +import spotube.composeapp.generated.resources.library_playlist_form_create +import spotube.composeapp.generated.resources.library_playlist_form_create_title +import spotube.composeapp.generated.resources.library_playlist_form_description_label +import spotube.composeapp.generated.resources.library_playlist_form_description_placeholder +import spotube.composeapp.generated.resources.library_playlist_form_edit_title +import spotube.composeapp.generated.resources.library_playlist_form_name_label +import spotube.composeapp.generated.resources.library_playlist_form_name_placeholder +import spotube.composeapp.generated.resources.library_playlist_form_no_image +import spotube.composeapp.generated.resources.library_playlist_form_pick_image +import spotube.composeapp.generated.resources.library_playlist_form_public +import spotube.composeapp.generated.resources.library_playlist_form_public_description +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.common_save + +data class PlaylistFormData( + val name: String = "", + val description: String = "", + val isPublic: Boolean = true, + val isCollaborating: Boolean = false, + val imageBase64: String = "", + val imagePreviewUrl: String? = null +) + +@Composable +fun PlaylistFormSheet( + visible: Boolean, + formState: PlaylistFormUiState, + onDismiss: () -> Unit, + onSubmit: (PlaylistFormData) -> Unit, + onImagePicked: (PlatformFile) -> Unit, + modifier: Modifier = Modifier, + initialData: PlaylistFormData? = null, + isEditing: Boolean = false +) { + if (!visible) return + + val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") } + var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") } + var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) } + var isCollaborating by remember(initialData) { + mutableStateOf( + initialData?.isCollaborating ?: false + ) + } + val imageBase64 = formState.pickedImageBase64 ?: initialData?.imageBase64.orEmpty() + val imagePreviewUrl = if (formState.pickedImageBase64 != null) null else initialData?.imagePreviewUrl + + val nameState = rememberTextFieldState(initialData?.name ?: "") + val descriptionState = rememberTextFieldState(initialData?.description ?: "") + + LaunchedEffect(nameState) { + snapshotFlow { nameState.text.toString() } + .drop(1) + .collect { name = it } + } + LaunchedEffect(descriptionState) { + snapshotFlow { descriptionState.text.toString() } + .drop(1) + .collect { description = it } + } + + val imagePicker = rememberFilePickerLauncher( + type = FileKitType.Image + ) { file -> + if (file != null) onImagePicked(file) + } + + val currentData = PlaylistFormData( + name = name, + description = description, + isPublic = isPublic, + isCollaborating = isCollaborating, + imageBase64 = imageBase64, + imagePreviewUrl = imagePreviewUrl + ) + + val handleImagePick = { imagePicker.launch() } + + val formTitle = stringResource( + if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title) + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + modifier = modifier, + dialogTitle = formTitle, + title = { Text(formTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + MillenniumButton( + onClick = onDismiss, + enabled = !formState.isSubmitting, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = { onSubmit(currentData) }, + enabled = name.isNotBlank() && !formState.isProcessingImage, + loading = formState.isSubmitting) { + Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) + } + } + }) { + PlaylistFormFields( + nameState = nameState, + descriptionState = descriptionState, + isPublic = isPublic, + onIsPublicChange = { isPublic = it }, + isCollaborating = isCollaborating, + onIsCollaboratingChange = { isCollaborating = it }, + imageBase64 = imageBase64, + imagePreviewUrl = imagePreviewUrl, + isProcessingImage = formState.isProcessingImage, + isImagePickEnabled = !formState.isSubmitting, + onPickImage = handleImagePick) + } +} + +@Composable +private fun PlaylistFormFields( + nameState: TextFieldState, + descriptionState: TextFieldState, + isPublic: Boolean, + onIsPublicChange: (Boolean) -> Unit, + isCollaborating: Boolean, + onIsCollaboratingChange: (Boolean) -> Unit, + imageBase64: String, + imagePreviewUrl: String?, + isProcessingImage: Boolean, + isImagePickEnabled: Boolean, + onPickImage: () -> Unit +) { + // AdaptiveDialogBottomSheet already scrolls its content; a second scroll here nests + // scrollables and crashes with infinite height constraints. + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + MillenniumTextField( + state = nameState, + label = stringResource(Res.string.library_playlist_form_name_label), + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.library_playlist_form_name_placeholder) + ) + + MillenniumTextField( + state = descriptionState, + label = stringResource(Res.string.library_playlist_form_description_label), + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.library_playlist_form_description_placeholder) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onIsPublicChange(!isPublic) } + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(Res.string.library_playlist_form_public), style = AppTheme.typography.bodyLarge) + Text( + stringResource(Res.string.library_playlist_form_public_description), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onIsCollaboratingChange(!isCollaborating) } + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(Res.string.library_playlist_form_collaborative), style = AppTheme.typography.bodyLarge) + Text( + stringResource(Res.string.library_playlist_form_collaborative_description), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(Res.string.library_playlist_form_cover_image), style = AppTheme.typography.bodyLarge) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + when { + imagePreviewUrl != null -> AsyncImage( + model = imagePreviewUrl, + contentDescription = stringResource(Res.string.library_playlist_form_cover_content_description), + modifier = Modifier.size(64.dp), + contentScale = ContentScale.Crop + ) + + imageBase64.isNotBlank() -> { + Text( + "✓", + style = AppTheme.typography.titleMedium, + color = AppTheme.colors.primary + ) + } + + else -> { + Text( + stringResource(Res.string.library_playlist_form_no_image), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + } + MillenniumButton( + onClick = onPickImage, + enabled = isImagePickEnabled, + loading = isProcessingImage) { + Text(stringResource(if (imageBase64.isNotBlank() || imagePreviewUrl != null) Res.string.library_playlist_form_change_image else Res.string.library_playlist_form_pick_image)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt new file mode 100644 index 00000000..e9df67d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsScreen.kt @@ -0,0 +1,348 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.lyrics + + +import org.spotube.millennium.composables.MillenniumBarsLoader +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.widthIn +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.ui.component.ApplicationBackButton +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error +import spotube.composeapp.generated.resources.lyrics_mode_plain +import spotube.composeapp.generated.resources.lyrics_mode_synced +import spotube.composeapp.generated.resources.lyrics_no_plain +import spotube.composeapp.generated.resources.lyrics_no_synced +import spotube.composeapp.generated.resources.common_close + +@Composable +fun LyricsScreen( + viewModel: LyricsViewModel = koinViewModel(), + modifier: Modifier = Modifier, + onClose: (() -> Unit)? = null) { + val uiState by viewModel.uiState.collectAsState() + val colors = MillenniumTheme.colors + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to colors.accent.copy(alpha = 0.14f), + 0.55f to colors.background, + 1f to colors.background + ) + ) + ) { + val isWide = maxWidth >= 840.dp + + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(48.dp), contentAlignment = Alignment.CenterStart) { + if (onClose != null) { + MillenniumIconButton( + onClick = onClose, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = stringResource(Res.string.common_close) + ) + } + } else { + ApplicationBackButton() + } + } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + MillenniumSegmentedButtonRow(Modifier.widthIn(max = 280.dp)) { + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.SYNCED, + onClick = { viewModel.setMode(LyricType.SYNCED) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_synced)) } + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.STATIC, + onClick = { viewModel.setMode(LyricType.STATIC) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_plain)) } + } + } + Spacer(Modifier.width(48.dp)) + } + + if (isWide) { + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 40.dp), + horizontalArrangement = Arrangement.spacedBy(40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TrackPane(uiState, Modifier.weight(0.38f)) + Column(Modifier.weight(0.62f).fillMaxHeight()) { + LyricsBody(uiState, viewModel) + } + } + } else { + CompactTrackHeader( + uiState, + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp) + ) + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + LyricsBody(uiState, viewModel) + } + } + } + } +} + +@Composable +private fun ColumnScope.LyricsBody(uiState: LyricsUiState, viewModel: LyricsViewModel) { + when { + uiState.isLoading -> CenteredMessage { + MillenniumBarsLoader(size = 48.dp) + } + + uiState.error != null -> CenteredMessage { + Text( + text = uiState.error ?: stringResource(Res.string.common_unknown_error), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.danger, + textAlign = TextAlign.Center + ) + } + + uiState.mode == LyricType.SYNCED -> SyncedLyricsContent( + syncedLyrics = uiState.syncedLyrics ?: emptyList(), + currentIndex = uiState.currentIndex, + onLineClick = viewModel::seekToLine + ) + + uiState.mode == LyricType.STATIC -> PlainLyricsContent( + plainLyrics = uiState.plainLyrics ?: "" + ) + } +} + +@Composable +private fun CoverArt(url: String, size: Dp, radius: Dp, elevation: Dp, modifier: Modifier = Modifier) { + val colors = MillenniumTheme.colors + Box( + modifier = modifier + .size(size) + .millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(radius), + borderColor = colors.border, + borderWidth = 1.dp, + shadowElevation = elevation + ) + ) { + AsyncImage( + model = url.takeIf { it.isNotBlank() }, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } +} + +@Composable +private fun TrackPane(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CoverArt( + url = uiState.coverUrl, + size = 280.dp, + radius = 24.dp, + elevation = 14.dp + ) + Spacer(Modifier.height(24.dp)) + Text( + text = uiState.trackTitle, + style = AppTheme.typography.headlineSmall, + color = MillenniumTheme.colors.text, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun CompactTrackHeader(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CoverArt(url = uiState.coverUrl, size = 48.dp, radius = 12.dp, elevation = 4.dp) + Column(Modifier.weight(1f)) { + Text( + text = uiState.trackTitle, + style = AppTheme.typography.titleSmall, + color = MillenniumTheme.colors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ColumnScope.CenteredMessage(content: @Composable () -> Unit) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { content() } +} + +@Composable +private fun ColumnScope.SyncedLyricsContent( + syncedLyrics: List, + currentIndex: Int, + onLineClick: (LyricsLine) -> Unit) { + if (syncedLyrics.isEmpty()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_synced), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center + ) + } + return + } + + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = currentIndex, + onLineClick = onLineClick, + modifier = Modifier.weight(1f).fillMaxWidth() + ) +} + +@Composable +private fun ColumnScope.PlainLyricsContent( + plainLyrics: String) { + if (plainLyrics.isBlank()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_plain), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center + ) + } + return + } + + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp) + ) { + item { + Text( + text = plainLyrics, + modifier = Modifier.fillMaxWidth(), + style = AppTheme.typography.titleMedium.copy(lineHeight = 34.sp), + color = MillenniumTheme.colors.text, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt similarity index 59% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt index c1615daa..41c7654a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/LyricsViewModel.kt @@ -15,23 +15,26 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.lyrics +package org.spotube.spotube.modules.lyrics import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.plugin.PluginManager import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.milliseconds @Serializable enum class LyricType { @@ -39,30 +42,37 @@ enum class LyricType { SYNCED } - data class LyricsUiState( val mode: LyricType = LyricType.SYNCED, val syncedLyrics: List? = null, val plainLyrics: String? = null, val isLoading: Boolean = false, val error: String? = null, -) + val currentIndex: Int = -1, + val trackTitle: String = "", + val trackArtists: String = "", + val coverUrl: String = "") class LyricsViewModel( private val pluginManager: PluginManager, - audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val _uiState = MutableStateFlow(LyricsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + + // Only re-emits when the active line changes, not on every position tick. + private val currentIndexFlow = combine( + _uiState.map { it.syncedLyrics }.distinctUntilChanged(), + audioPlayer.positionFlow + ) { lyrics, position -> activeLineIndex(lyrics, position.inWholeMilliseconds) } + .distinctUntilChanged() + + val uiState: StateFlow = combine(_uiState, currentIndexFlow) { state, index -> + state.copy(currentIndex = index) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LyricsUiState()) private var currentTrackId: String? = null - val positionMillisFlow: StateFlow = audioPlayer.positionFlow - .map { it.inWholeMilliseconds } - .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), 0L) - fun setMode(mode: LyricType) { _uiState.update { it.copy(mode = mode) } } @@ -90,8 +100,7 @@ class LyricsViewModel( it.copy( syncedLyrics = lyrics?.syncedLyrics, plainLyrics = lyrics?.plainLyrics, - isLoading = false, - ) + isLoading = false) } } } ?: run { @@ -110,6 +119,17 @@ class LyricsViewModel( is QueueEntry.StreamingTrack -> entry.track.id else -> null } + if (entry is QueueEntry.StreamingTrack) { + val track = entry.track + _uiState.update { + it.copy( + trackTitle = track.title, + trackArtists = track.artists.joinToString(", ") { artist -> artist.name }, + coverUrl = (track.album?.thumbnails ?: track.thumbnails) + ?.firstOrNull()?.url.orEmpty() + ) + } + } if (trackId != null && trackId != currentTrackId) { loadLyrics() } @@ -117,17 +137,15 @@ class LyricsViewModel( } } - fun currentLyricIndex(positionMillis: Long): Int { - val lyrics = _uiState.value.syncedLyrics ?: emptyList() - if (lyrics.isEmpty()) return -1 - var lastIndex = 0 - for (i in lyrics.indices) { - if (lyrics[i].time <= positionMillis) { - lastIndex = i - } else { - break - } + fun seekToLine(line: LyricsLine) { + viewModelScope.launch { + audioPlayer.seekTo(line.time.milliseconds) } - return lastIndex } -} \ No newline at end of file + + private fun activeLineIndex(lyrics: List?, positionMillis: Long): Int { + if (lyrics.isNullOrEmpty()) return -1 + val index = lyrics.indexOfLast { it.time <= positionMillis } + return index.coerceAtLeast(0) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt new file mode 100644 index 00000000..7d5133ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/lyrics/SyncedLyricsList.kt @@ -0,0 +1,196 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.lyrics + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.theme.MillenniumTheme + +private const val LYRICS_BLANK_LINE = "\u266A" + +/** + * Time-synced lyrics list that keeps the active line vertically centered. + * + * With [onLineClick] it renders Millennium list tiles (the active one is the selected tile); + * without it, it renders a passive compact preview. + */ +@Composable +fun SyncedLyricsList( + lines: List, + currentIndex: Int, + modifier: Modifier = Modifier, + onLineClick: ((LyricsLine) -> Unit)? = null +) { + val listState = rememberLazyListState() + val colors = MillenniumTheme.colors + + BoxWithConstraints(modifier = modifier) { + // Half-viewport padding lets the first/last lines reach the center too. + val edgePadding = maxHeight / 2 + + LaunchedEffect(currentIndex, lines.size) { + if (currentIndex !in lines.indices) return@LaunchedEffect + val visible = listState.layoutInfo.visibleItemsInfo + val itemHeight = visible.firstOrNull { it.index == currentIndex }?.size + ?: visible.firstOrNull()?.size + ?: 0 + listState.animateScrollToItem(index = currentIndex, scrollOffset = itemHeight / 2) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().then( + if (onLineClick != null) Modifier.fadingEdges(maxHeight * 0.18f) else Modifier + ), + contentPadding = PaddingValues(vertical = edgePadding), + verticalArrangement = Arrangement.spacedBy(if (onLineClick != null) 4.dp else 2.dp) + ) { + itemsIndexed(lines, key = { index, line -> "$index-${line.time}" }) { index, line -> + val isCurrent = index == currentIndex + val text = line.text.ifBlank { LYRICS_BLANK_LINE } + val color by animateColorAsState( + when { + isCurrent -> colors.accent + index < currentIndex -> colors.muted.copy(alpha = 0.55f) + else -> colors.muted + } + ) + if (onLineClick != null) { + ImmersiveLyricLine( + text = text, + distance = kotlin.math.abs(index - currentIndex), + isCurrent = isCurrent, + onClick = { onLineClick(line) } + ) + } else { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + style = AppTheme.typography.bodyMedium.copy( + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + color = color + ), + textAlign = TextAlign.Center, + maxLines = 2 + ) + } + } + } + } +} + +@Composable +private fun ImmersiveLyricLine( + text: String, + distance: Int, + isCurrent: Boolean, + onClick: () -> Unit +) { + val colors = MillenniumTheme.colors + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val targetAlpha = when (distance) { + 0 -> 1f + 1 -> 0.7f + 2 -> 0.5f + else -> 0.32f + } + val alpha by animateFloatAsState(targetAlpha, tween(300)) + val scale by animateFloatAsState(if (isCurrent) 1f else 0.9f, tween(300)) + val color by animateColorAsState(if (isCurrent) colors.accent else colors.text, tween(300)) + val hoverBackground by animateColorAsState( + if (hovered && !isCurrent) colors.accent.copy(alpha = 0.10f) else Color.Transparent + ) + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = if (hovered) maxOf(alpha, 0.85f) else alpha + } + .clip(RoundedCornerShape(14.dp)) + .background(hoverBackground) + .hoverable(interaction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + style = AppTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + color = color + ), + textAlign = TextAlign.Center + ) +} + +/** Fades the top and bottom [fade] of the content to transparent. */ +private fun Modifier.fadingEdges(fade: Dp): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fraction = (fade.toPx() / size.height).coerceIn(0f, 0.5f) + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Transparent, + fraction to Color.Black, + 1f - fraction to Color.Black, + 1f to Color.Transparent + ), + blendMode = BlendMode.DstIn + ) + } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt new file mode 100644 index 00000000..1f8ca56a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -0,0 +1,251 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.playlist + + +import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.jetbrains.compose.resources.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.playlist_add_tracks_add +import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description +import spotube.composeapp.generated.resources.playlist_add_tracks_done +import spotube.composeapp.generated.resources.playlist_add_tracks_hint +import spotube.composeapp.generated.resources.playlist_add_tracks_no_results +import spotube.composeapp.generated.resources.playlist_add_tracks_search_content_description +import spotube.composeapp.generated.resources.playlist_add_tracks_search_placeholder +import spotube.composeapp.generated.resources.playlist_add_tracks_title + +@Composable +fun AddTracksToPlaylistDialog( + visible: Boolean, + playlistId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier) { + if (!visible) return + + val viewModel: AddTracksToPlaylistViewModel = koinViewModel( + key = "add-tracks-$playlistId", + parameters = { parametersOf(playlistId) } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val dismiss = { + viewModel.reset() + onDismiss() + } + + val content: @Composable () -> Unit = { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically) { + MillenniumTextField( + state = state.queryState, + modifier = Modifier.weight(1f), + placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder)) + MillenniumIconButton( + icon = Iconsax.IconsaxSearch, + contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description), + onClick = viewModel::search, + loading = state.isSearching, + style = MillenniumIconButtonStyle.Secondary) + } + + when { + state.isSearching -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 40.dp) + } + } + state.hasSearched && state.results.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.playlist_add_tracks_no_results)) + } + } + state.results.isNotEmpty() -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + itemsWithUniqueKeys(state.results, id = { it.track.id }) { result -> + TrackSearchResultRow( + track = result.track, + isAdding = result.isAdding, + isAdded = result.isAdded, + onAdd = { viewModel.addTrack(result.track.id) }) + } + } + } + else -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.playlist_add_tracks_hint)) + } + } + } + } + } + + val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) + AdaptiveDialogBottomSheet( + onDismiss = dismiss, + modifier = modifier, + dialogTitle = dialogTitle, + title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, + actions = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton(onClick = dismiss) { + Text(stringResource(Res.string.playlist_add_tracks_done)) + } + } + }) { + Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { + content() + } + } +} + +@Composable +private fun TrackSearchResultRow( + track: MetadataTrack, + isAdding: Boolean, + isAdded: Boolean, + onAdd: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val imageUrl = track.album?.thumbnails?.firstOrNull()?.url + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = track.title, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center) { + Text( + text = track.title.take(1).uppercase(), + style = AppTheme.typography.titleMedium) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + + when { + isAdded -> { + MillenniumIcon( + imageVector = Iconsax.IconsaxCheckCircle, + contentDescription = stringResource(Res.string.playlist_add_tracks_added_content_description), + tint = AppTheme.colors.primary, + modifier = Modifier.size(24.dp)) + } + else -> { + MillenniumButton( + onClick = onAdd, + loading = isAdding, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.playlist_add_tracks_add)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt new file mode 100644 index 00000000..c120b64b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistViewModel.kt @@ -0,0 +1,131 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.playlist + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.search.SearchRepository +import kotlin.coroutines.cancellation.CancellationException + +data class AddTrackResultUi( + val track: MetadataTrack, + val isAdding: Boolean, + val isAdded: Boolean +) + +data class AddTracksToPlaylistUiState( + val queryState: TextFieldState = TextFieldState(), + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false +) + +private data class AddTracksState( + val results: List = emptyList(), + val isSearching: Boolean = false, + val hasSearched: Boolean = false, + val addingTrackIds: Set = emptySet(), + val addedTrackIds: Set = emptySet() +) + +class AddTracksToPlaylistViewModel( + private val playlistId: String, + private val searchRepository: SearchRepository, + private val libraryRepository: LibraryRepository +) : ViewModel() { + private val logger = Logger.withTag("AddTracksToPlaylistViewModel") + private val queryState = TextFieldState() + private val state = MutableStateFlow(AddTracksState()) + private var searchJob: Job? = null + + val uiState: StateFlow = state.map { current -> + AddTracksToPlaylistUiState( + queryState = queryState, + results = current.results.map { track -> + AddTrackResultUi( + track = track, + isAdding = track.id in current.addingTrackIds, + isAdded = track.id in current.addedTrackIds + ) + }, + isSearching = current.isSearching, + hasSearched = current.hasSearched + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + AddTracksToPlaylistUiState(queryState = queryState) + ) + + fun search() { + val query = queryState.text.toString() + if (query.isBlank() || state.value.isSearching) return + state.update { it.copy(isSearching = true, hasSearched = true) } + searchJob = viewModelScope.launch { + try { + val results = searchRepository.searchTracks(query).items.map { it.data } + state.update { it.copy(results = results) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to search tracks for \"$query\"" } + state.update { it.copy(results = emptyList()) } + } finally { + state.update { it.copy(isSearching = false) } + } + } + } + + fun addTrack(trackId: String) { + val current = state.value + if (trackId in current.addingTrackIds || trackId in current.addedTrackIds) return + state.update { it.copy(addingTrackIds = it.addingTrackIds + trackId) } + viewModelScope.launch { + try { + libraryRepository.addTracksToPlaylist(playlistId, listOf(trackId)) + state.update { it.copy(addedTrackIds = it.addedTrackIds + trackId) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + logger.e(e) { "Failed to add track $trackId to playlist $playlistId" } + } finally { + state.update { it.copy(addingTrackIds = it.addingTrackIds - trackId) } + } + } + } + + /** Clears the search so the dialog opens fresh next time; in-flight adds still complete. */ + fun reset() { + searchJob?.cancel() + queryState.clearText() + state.update { AddTracksState() } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt index c7d444f6..5aa1977d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistRepository.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.playlist +package org.spotube.spotube.modules.playlist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,8 +37,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class PlaylistRepository( val pluginManager: PluginManager, - private val libraryRepository: LibraryRepository, -) { + private val libraryRepository: LibraryRepository) { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val plugin diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt similarity index 61% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..370c8ca6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistScreen.kt @@ -15,14 +15,19 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.playlist +package org.spotube.spotube.modules.playlist + +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,18 +36,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker -import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData -import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import kotlinx.coroutines.flow.map +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.modules.library.playlist.PlaylistFormData +import org.spotube.spotube.modules.library.playlist.PlaylistFormSheet +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.playlist_add_tracks_button +import spotube.composeapp.generated.resources.playlist_loading_title +import spotube.composeapp.generated.resources.playlist_owner_unknown @Composable fun PlaylistScreen( @@ -53,19 +66,25 @@ fun PlaylistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - var showEditPlaylist by remember { mutableStateOf(false) } + val editPlaylistFormState by viewModel.editPlaylistFormState.collectAsStateWithLifecycle() var showAddTracksDialog by remember { mutableStateOf(false) } val dataState = state as? PlaylistScreenState.Data val playlist = dataState?.playlist val owner = playlist?.owner - val ownerName = owner?.displayName ?: owner?.username ?: "Unknown" + val ownerName = owner?.displayName ?: owner?.username ?: stringResource(Res.string.playlist_owner_unknown) val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty() @@ -81,34 +100,42 @@ fun PlaylistScreen( modifier = Modifier .fillMaxWidth() .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.Center, - ) { - OutlineButton(onClick = { showAddTracksDialog = true }) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - ) - Text("Add Tracks", modifier = Modifier.padding(start = 8.dp)) + horizontalArrangement = Arrangement.Center) { + MillenniumButton( + onClick = { showAddTracksDialog = true }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = null) + Text(stringResource(Res.string.playlist_add_tracks_button)) + } } } } } else null CollectionView( - title = playlist?.title ?: "Loading playlist...", + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, + title = playlist?.title ?: stringResource(Res.string.playlist_loading_title), description = playlist?.description.orEmpty(), imageURL = artworkUrl, ownerName = ownerName, ownerImageURL = ownerImageURL, onOwnerClick = {}, onPlay = viewModel::playPlaylist, - onShufflePlay = {}, + onShufflePlay = viewModel::shufflePlayPlaylist, onAddToQueue = viewModel::addPlaylistToQueue, isPlaying = isPlaying, isFollowing = savedPlaylistIds.contains(playlistId), + isFollowLoading = dataState?.isSaving == true, onFollowClick = viewModel::toggleSavedPlaylist, showFollowButton = playlistId != "saved_tracks", - onEdit = if (isOwner) { { showEditPlaylist = true } } else null, + onEdit = if (isOwner) viewModel::showEditPlaylist else null, isLoading = isLoading, error = errorMessage, onRetry = { viewModel.refresh() }, @@ -127,12 +154,18 @@ fun PlaylistScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, footerContent = footerContent, trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist PlaylistFormSheet( - visible = showEditPlaylist, - onDismiss = { showEditPlaylist = false }, + visible = editPlaylistFormState.isVisible, + formState = editPlaylistFormState, + onDismiss = viewModel::dismissEditPlaylist, + onImagePicked = viewModel::onEditPlaylistImagePicked, isEditing = true, initialData = loadedPlaylist?.let { PlaylistFormData( @@ -140,26 +173,15 @@ fun PlaylistScreen( description = it.description.orEmpty(), isPublic = true, isCollaborating = false, - imagePreviewUrl = it.thumbnails.firstOrNull()?.url, - ) + imagePreviewUrl = it.thumbnails.firstOrNull()?.url) }, - onSubmit = { data -> - viewModel.updatePlaylist( - name = data.name, - description = data.description.ifBlank { null }, - isPublic = data.isPublic, - isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64.ifBlank { null }, - ) - }, - ) + onSubmit = viewModel::updatePlaylist) AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) AddTracksToPlaylistDialog( visible = showAddTracksDialog, @@ -167,8 +189,6 @@ fun PlaylistScreen( onDismiss = { showAddTracksDialog = false viewModel.refresh() - }, - ) - }, - ) + }) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt similarity index 64% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt index 7cd51cbb..0d14463f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/PlaylistViewModel.kt @@ -15,25 +15,28 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.playlist +package org.spotube.spotube.modules.playlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.core.ui.component.TrackOptionsState -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.core.ui.component.TrackOptionsState +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -46,8 +49,16 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import io.github.vinceglb.filekit.PlatformFile +import org.spotube.spotube.modules.library.playlist.PlaylistFormController +import org.spotube.spotube.modules.library.playlist.PlaylistFormData +import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_unknown_error sealed interface PlaylistScreenState { data object Loading : PlaylistScreenState @@ -64,15 +75,13 @@ sealed interface PlaylistScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data { + override val isSaving: Boolean = false) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( playlist = playlist, isSaved = isSaved, tracks = tracks, nextPagination = nextPagination, - isSaving = isSaving, - ) + isSaving = isSaving) } data class LoadingMore( @@ -80,8 +89,7 @@ sealed interface PlaylistScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data + override val isSaving: Boolean = false) : Data } data class Error(val message: String) : PlaylistScreenState @@ -98,11 +106,16 @@ class PlaylistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(PlaylistScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.Playlist, playlistId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedPlaylistIds get() = libraryRepository.savedPlaylistIdsFlow @@ -124,8 +137,7 @@ class PlaylistViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -133,8 +145,7 @@ class PlaylistViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -174,11 +185,10 @@ class PlaylistViewModel( playlist = playlistInfo?.first, isSaved = playlistInfo?.second ?: false, tracks = tracksResult?.items ?: emptyList(), - nextPagination = tracksResult?.nextPagination, - ) + nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load playlist" } - _state.value = PlaylistScreenState.Error(e.message ?: "Unknown error") + _state.value = PlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } suspend fun loadMoreTracks() = runCatching { @@ -193,12 +203,11 @@ class PlaylistViewModel( isSaved = currentState.isSaved, tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - isSaving = currentState.isSaving, - ) + isSaving = currentState.isSaving) } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } - _state.value = PlaylistScreenState.Error(e.message ?: "Unknown error") + _state.value = PlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } fun loadNextTracksPage() { @@ -208,6 +217,8 @@ class PlaylistViewModel( } fun toggleSavedPlaylist() { + if ((_state.value as? PlaylistScreenState.Data)?.isSaving == true) return + setSaving(true) viewModelScope.launch { runCatching { val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false @@ -219,19 +230,47 @@ class PlaylistViewModel( }.onFailure { e -> logger.e(e) { "Failed to toggle saved playlist" } } + setSaving(false) + } + } + + private fun setSaving(isSaving: Boolean) { + _state.update { state -> + when (state) { + is PlaylistScreenState.Data.Loaded -> state.copy(isSaving = isSaving) + is PlaylistScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving) + else -> state + } } } fun playPlaylist() { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) + } + + fun shufflePlayPlaylist() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title, shuffle = true) } fun addPlaylistToQueue() { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) + } + + fun playPlaylistNext() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title) } fun playPlaylistFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Playlist, + id = playlistId, + title = title, + startTrack = track) } fun refresh() { @@ -241,30 +280,27 @@ class PlaylistViewModel( } } - fun updatePlaylist( - name: String?, - description: String?, - isPublic: Boolean?, - isCollaborating: Boolean?, - imageBase64: String?, - ) { - viewModelScope.launch { - runCatching { - libraryRepository.updatePlaylist( - id = playlistId, - name = name, - description = description, - isPublic = isPublic, - isCollaborating = isCollaborating, - imageBase64 = imageBase64, - trackIds = null, - ) - }.onFailure { e -> - logger.e(e) { "Failed to update playlist" } - }.onSuccess { - repository.invalidateCaches() - loadInitialData() - } + private val editPlaylistForm = PlaylistFormController(viewModelScope, logger) + val editPlaylistFormState: StateFlow = editPlaylistForm.uiState + + fun showEditPlaylist() = editPlaylistForm.show() + + fun dismissEditPlaylist() = editPlaylistForm.dismiss() + + fun onEditPlaylistImagePicked(file: PlatformFile) = editPlaylistForm.onImagePicked(file) + + fun updatePlaylist(data: PlaylistFormData) { + editPlaylistForm.submit { + libraryRepository.updatePlaylist( + id = playlistId, + name = data.name, + description = data.description.ifBlank { null }, + isPublic = data.isPublic, + isCollaborating = data.isCollaborating, + imageBase64 = data.imageBase64.ifBlank { null }, + trackIds = null) + repository.invalidateCaches() + loadInitialData() } } @@ -274,14 +310,24 @@ class PlaylistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -321,8 +367,7 @@ class PlaylistViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -356,34 +401,18 @@ class PlaylistViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksPlayNext(tracks, title) } val savedTrackIds diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt similarity index 65% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt index 9a642cd6..b933e110 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/BuiltInPlugins.kt @@ -15,7 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin + +import org.jetbrains.compose.resources.DrawableResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_logo_lrclib +import spotube.composeapp.generated.resources.plugin_logo_newpipe val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry( name = "NewPipe YouTube", @@ -31,8 +36,7 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry( contact = "", repository = "", bugs = "", - license = "", -) + license = "") val LRCLIB_BUILT_IN_PLUGIN = PluginEntry( name = "LRCLib Lyrics", version = "0.1.0", @@ -40,15 +44,24 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry( description = "LRCLib Lyrics plugin for fetching lyrics.", author = "Spotube Team", capabilities = listOf( - PluginCapability.NETWORK_REQUESTS, - ), + PluginCapability.NETWORK_REQUESTS), abilities = listOf(PluginAbility.LYRICS), contact = "", repository = "", bugs = "", - license = "", -) + license = "") val BUILT_IN_PLUGINS = listOf( NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - LRCLIB_BUILT_IN_PLUGIN, -) + LRCLIB_BUILT_IN_PLUGIN) + +/** + * The bundled placeholder logo for a built-in plugin, or `null` if it has none. + * + * Built-in plugins are not installed to disk, so they have no `logo.png`; these + * resources (in `composeResources/drawable`) stand in as their logos. + */ +fun builtInPluginLogo(plugin: PluginEntry): DrawableResource? = when (plugin.id) { + NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_newpipe + LRCLIB_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_lrclib + else -> null +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt index 91ffc9b3..86a8f30b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory 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 @@ -27,14 +27,12 @@ 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, -) + val items: List) @Serializable data class GitHubRepo( @@ -43,14 +41,12 @@ data class GitHubRepo( val description: String? = null, @SerialName("stargazers_count") val stargazersCount: Int = 0, @SerialName("html_url") val htmlUrl: String, - val owner: GitHubOwner, -) + val owner: GitHubOwner) @Serializable data class GitHubOwner( val login: String, - @SerialName("avatar_url") val avatarUrl: String, -) + @SerialName("avatar_url") val avatarUrl: String) @Serializable data class GitHubRelease( @@ -60,21 +56,15 @@ data class GitHubRelease( val assets: List, @SerialName("html_url") val htmlUrl: String, val prerelease: Boolean = false, - val draft: Boolean = false, -) + val draft: Boolean = false) @Serializable data class GitHubAsset( val name: String, - @SerialName("browser_download_url") val browserDownloadUrl: String, -) + @SerialName("browser_download_url") val browserDownloadUrl: String) -class GitHubPluginRepository { - private val httpClient = HttpClient { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } +/** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */ +class GitHubPluginRepository(private val httpClient: HttpClient) { suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse { return httpClient.get("https://api.github.com/search/repositories") { @@ -118,8 +108,4 @@ class GitHubPluginRepository { emptyList() } } - - fun close() { - httpClient.close() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt new file mode 100644 index 00000000..051ec896 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/MetadataAuthViewModel.kt @@ -0,0 +1,97 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.di.injectLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent + +data class MetadataAuthUiState( + /** True when the selected metadata plugin needs an account and the user isn't signed in. */ + val loginRequired: Boolean = false, + val pluginName: String? = null, + /** True while a login started from the prompt is still running. */ + val isLoggingIn: Boolean = false) + +/** + * Tells screens whether the selected metadata plugin is waiting for a login, so they can show a + * friendly sign-in prompt instead of surfacing the plugin's raw authorization exception. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MetadataAuthViewModel( + private val pluginManager: PluginManager) : ViewModel(), KoinComponent { + private val logger by injectLogger() + private val isLoggingIn = MutableStateFlow(false) + + val uiState: StateFlow = combine( + pluginManager.selectedMetadataPlugin.flatMapLatest { service -> + if (service == null) flowOf(false) else loginRequiredFlow(service) + }, + pluginManager.state, + isLoggingIn) { loginRequired, managerState, loggingIn -> + val name = (managerState as? PluginManagerStates.Data) + ?.selectedPlugins?.get(PluginAbility.METADATA)?.name + MetadataAuthUiState(loginRequired = loginRequired, pluginName = name, isLoggingIn = loggingIn) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) + + private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow = flow { + val requiresAuth = try { + service.use { coreAPI.requiresAuthentication } + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } + if (!requiresAuth) { + emit(false) + } else { + service.loggedInFlow.collect { emit(!it) } + } + } + + fun login() { + if (isLoggingIn.value) return + val service = pluginManager.selectedMetadataPlugin.value ?: return + // The login runs in the plugin manager's scope so it survives this screen; we only wait on it. + val task = pluginManager.launchTask { + runCatching { service.use { coreAPI.login() } } + .onFailure { logger.e(it) { "Metadata plugin login failed" } } + } + isLoggingIn.value = true + viewModelScope.launch { + try { + task.join() + } finally { + isLoggingIn.update { false } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt similarity index 70% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 0fe5276e..b0056403 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.db.DatabaseKeys -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.zipline.BuiltInPluginService -import dev.krtirtho.spotube.core.zipline.PluginService -import dev.krtirtho.spotube.core.zipline.ZiplinePluginService -import io.ktor.client.HttpClient +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.zipline.BuiltInPluginService +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.ZiplinePluginService import io.ktor.client.request.get import io.ktor.client.statement.bodyAsChannel import io.ktor.utils.io.readRemaining @@ -61,7 +61,35 @@ import okio.Path.Companion.toPath import okio.SYSTEM import okio.buffer import okio.use +import org.spotube.spotube.core.i18n.getString import org.koin.core.component.KoinComponent +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_action_install +import spotube.composeapp.generated.resources.plugin_action_replace +import spotube.composeapp.generated.resources.plugin_error_api_incompatible +import spotube.composeapp.generated.resources.plugin_error_api_version_invalid +import spotube.composeapp.generated.resources.plugin_error_app_api_version_invalid +import spotube.composeapp.generated.resources.plugin_error_builtin_add +import spotube.composeapp.generated.resources.plugin_error_builtin_remove +import spotube.composeapp.generated.resources.plugin_error_install_failed +import spotube.composeapp.generated.resources.plugin_error_invalid_plugin_json +import spotube.composeapp.generated.resources.plugin_error_plugin_json_missing +import spotube.composeapp.generated.resources.plugin_error_read_failed +import spotube.composeapp.generated.resources.plugin_error_replace_needs_confirmation +import spotube.composeapp.generated.resources.plugin_error_update_needs_confirmation +import spotube.composeapp.generated.resources.plugin_error_zipline_manifest_missing +import spotube.composeapp.generated.resources.plugin_prompt_incompatible_title +import spotube.composeapp.generated.resources.plugin_prompt_install_message +import spotube.composeapp.generated.resources.plugin_prompt_install_title +import spotube.composeapp.generated.resources.plugin_prompt_replace_downgrade +import spotube.composeapp.generated.resources.plugin_prompt_replace_generic +import spotube.composeapp.generated.resources.plugin_prompt_replace_invalid_incoming +import spotube.composeapp.generated.resources.plugin_prompt_replace_invalid_installed +import spotube.composeapp.generated.resources.plugin_prompt_replace_same_version +import spotube.composeapp.generated.resources.plugin_prompt_replace_title +import spotube.composeapp.generated.resources.plugin_prompt_update_message +import spotube.composeapp.generated.resources.plugin_prompt_update_title +import spotube.composeapp.generated.resources.update_action_update const val PLUGIN_API_VERSION = "0.0.1" @@ -69,20 +97,35 @@ interface PluginProvider { val selectedMetadataPlugin: StateFlow } +/** Narrow read-only view of the active audio plugin, used by consumers that only need to + * key caches or make calls against the currently selected audio plugin without depending + * on the full [PluginManager] (which also owns plugin installation/lifecycle machinery). */ +interface AudioPluginSource { + val selectedAudioPlugin: StateFlow +} + +/** Narrow read-only view of the active scrobble plugin, used by consumers that only need + * to submit scrobbles against the currently selected plugin without depending on the full + * [PluginManager] (which also owns plugin installation/lifecycle machinery). */ +interface ScrobblePluginSource { + val selectedScrobblePlugin: StateFlow +} class PluginManager( val database: Database, val paths: Paths, -) : KoinComponent, PluginProvider { + private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() + + // plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`). + private val pluginJsonParser = Json { ignoreUnknownKeys = true } private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() val pluginsDirPath: Path get() = pluginsDir - private val httpClient = HttpClient() - + private val httpClient get() = httpClientFactory.default enum class InstallPromptKind { INSTALL, @@ -99,18 +142,18 @@ class PluginManager( val message: String, val bytes: ByteArray? = null, val existingEntry: PluginEntry? = null, - val confirmLabel: String? = null, - ) + val confirmLabel: String? = null) val pendingPlugin = MutableStateFlow(null) val state: StateFlow = database.settingsDataStore.data .map { p -> val json = p[DatabaseKeys.PLUGINS_STATE_KEY] - val defaultSelectedPlugins = mapOf( - PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - PluginAbility.SCROBBLE to LRCLIB_BUILT_IN_PLUGIN, - ) + // Each category starts out with the built-in plugin that provides it. + val defaultSelectedPlugins = BUILT_IN_PLUGINS + .flatMap { plugin -> plugin.abilities.map { it to plugin } } + .distinctBy { it.first } + .toMap() if (json == null) { PluginManagerStates.Data( plugins = BUILT_IN_PLUGINS, @@ -129,12 +172,21 @@ class PluginManager( } else it }.toSet() - res.copy( - plugins = (plugins union BUILT_IN_PLUGINS).toList(), - selectedPlugins = res.selectedPlugins.ifEmpty { - defaultSelectedPlugins + val resolvedPlugins = (plugins union BUILT_IN_PLUGINS).toList() + val resolvedPluginsById = resolvedPlugins.associateBy { it.id } + // Drop selections for plugins that don't actually declare the ability + // (e.g. a scrobble selection left over from a removed built-in plugin) + // and always point selections at the resolved plugin entry. + val selectedPlugins = res.selectedPlugins.ifEmpty { defaultSelectedPlugins } + .mapNotNull { (ability, selected) -> + val plugin = resolvedPluginsById[selected.id] ?: return@mapNotNull null + if (ability in plugin.abilities) ability to plugin else null } - ) + .toMap() + + res.copy( + plugins = resolvedPlugins, + selectedPlugins = selectedPlugins) } catch (_: Exception) { PluginManagerStates.Data( BUILT_IN_PLUGINS, @@ -179,8 +231,7 @@ class PluginManager( val service = ZiplinePluginService( applicationName = plugin.name, manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}", - pluginInfo = plugin, - ) + pluginInfo = plugin) ziplineServices[ability] = service ziplineServicesByPluginID[plugin.id] = service service.start() @@ -245,14 +296,13 @@ class PluginManager( override val selectedMetadataPlugin = filterSelectedPluginByType(PluginAbility.METADATA) - val selectedAudioPlugin = + override val selectedAudioPlugin = filterSelectedPluginByType(PluginAbility.AUDIO) val selectedLyricsPlugin = filterSelectedPluginByType(PluginAbility.LYRICS) - val selectedScrobblePlugin = + override val selectedScrobblePlugin = filterSelectedPluginByType(PluginAbility.SCROBBLE) - fun launchTask(block: suspend CoroutineScope.() -> Unit): Job { return scope.launch(block = block) } @@ -265,7 +315,6 @@ class PluginManager( block() } - private suspend fun updatePluginsState(newState: PluginManagerStates.Data) { database.settingsDataStore.updateData { preferences -> preferences.toMutablePreferences().apply { @@ -282,6 +331,15 @@ class PluginManager( } } + /** Downloads and installs a newer plugin build over the installed one, without prompting. */ + suspend fun updatePluginFromURL(url: String) { + withContext(Dispatchers.IO) { + val response = httpClient.get(url) + val bytes = response.bodyAsChannel().readRemaining().readByteArray() + addPluginFromByteArray(bytes = bytes, allowReplacingInstalled = true) + } + } + /** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */ suspend fun preparePlugin(bytes: ByteArray) { withContext(Dispatchers.IO) { @@ -314,15 +372,15 @@ class PluginManager( val pluginJsonPath = tempDir / "plugin.json".toPath() if (!FileSystem.SYSTEM.exists(pluginJsonPath)) { - throw IllegalArgumentException("plugin.json not found in the zip file") + throw IllegalArgumentException(getString(Res.string.plugin_error_plugin_json_missing)) } val pluginJson = FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { - throw IllegalArgumentException("Invalid plugin.json format: ${e.message}") + throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } // Preserve logo.png before deleting temp dir so the permission dialog can show it @@ -336,7 +394,7 @@ class PluginManager( pendingPlugin.value = buildPendingPlugin(pluginEntry, bytes) } catch (e: Exception) { - throw Exception("Failed to read plugin: ${e.message}", e) + throw Exception(getString(Res.string.plugin_error_read_failed, e.message.orEmpty()), e) } finally { if (FileSystem.SYSTEM.exists(tempDir)) { try { @@ -348,22 +406,30 @@ class PluginManager( } } - /** Called when the user confirms installation/update/replacement in the dialog. */ - fun confirmInstall() { - val pending = pendingPlugin.value ?: return - val bytes = pending.bytes ?: return dismissInstall() + /** + * Called when the user confirms installation/update/replacement in the dialog. The prompt stays + * up until the install finishes, so the dialog can show progress. + * + * @return The install job, or `null` when there was nothing to install. + */ + fun confirmInstall(): Job? { + val pending = pendingPlugin.value ?: return null + val bytes = pending.bytes ?: run { dismissInstall(); return null } val allowReplacingInstalled = when (pending.kind) { InstallPromptKind.INSTALL -> false InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true - InstallPromptKind.INFO -> return dismissInstall() - InstallPromptKind.REPLACE -> true + InstallPromptKind.INFO -> run { dismissInstall(); return null } } - pendingPlugin.value = null - scope.launch { - addPluginFromByteArray( - bytes = bytes, - allowReplacingInstalled = allowReplacingInstalled - ) + return scope.launch { + try { + addPluginFromByteArray( + bytes = bytes, + allowReplacingInstalled = allowReplacingInstalled + ) + } finally { + // Only close our own prompt; another plugin may have been queued meanwhile. + pendingPlugin.compareAndSet(pending, null) + } } } @@ -374,8 +440,7 @@ class PluginManager( suspend fun addPluginFromByteArray( bytes: ByteArray, - allowReplacingInstalled: Boolean = false, - ) { + allowReplacingInstalled: Boolean = false) { withContext(Dispatchers.IO) { if (!FileSystem.SYSTEM.exists(pluginsDir)) { FileSystem.SYSTEM.createDirectories(pluginsDir) @@ -423,11 +488,11 @@ class PluginManager( val pluginJsonPath = tempDir / "plugin.json".toPath() if (!FileSystem.SYSTEM.exists(pluginJsonPath)) { - throw IllegalArgumentException("plugin.json not found in the zip file") + throw IllegalArgumentException(getString(Res.string.plugin_error_plugin_json_missing)) } val pluginManifestPath = tempDir / "manifest.zipline.json".toPath() if (!FileSystem.SYSTEM.exists(pluginManifestPath)) { - throw IllegalArgumentException("manifest.zipline.json not found in the zip file") + throw IllegalArgumentException(getString(Res.string.plugin_error_zipline_manifest_missing)) } val pluginJson = FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { source -> @@ -435,9 +500,9 @@ class PluginManager( } val pluginEntry = try { - Json.decodeFromString(pluginJson) + pluginJsonParser.decodeFromString(pluginJson) } catch (e: Exception) { - throw IllegalArgumentException("Invalid plugin.json format: ${e.message}") + throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) } ensurePluginApiCompatible(pluginEntry) @@ -446,8 +511,8 @@ class PluginManager( if (installedPlugin != null && !allowReplacingInstalled) { throw IllegalArgumentException( when (compareVersions(installedPlugin, pluginEntry)) { - VersionRelation.Update -> "A newer version of ${pluginEntry.name} is available. Confirm the update before installing." - VersionRelation.Replace -> "${pluginEntry.name} is already installed. Confirm replacing it before installing." + VersionRelation.Update -> getString(Res.string.plugin_error_update_needs_confirmation, pluginEntry.name) + VersionRelation.Replace -> getString(Res.string.plugin_error_replace_needs_confirmation, pluginEntry.name) } ) } @@ -477,7 +542,7 @@ class PluginManager( addPlugin(pluginEntry) } catch (e: Exception) { - throw Exception("Failed to install plugin: ${e.message}", e) + throw Exception(getString(Res.string.plugin_error_install_failed, e.message.orEmpty()), e) } finally { if (FileSystem.SYSTEM.exists(tempDir)) { try { @@ -508,7 +573,7 @@ class PluginManager( private suspend fun addPlugin(plugin: PluginEntry) { if (plugin in BUILT_IN_PLUGINS) { - throw IllegalArgumentException("Built-in plugins are already included and can't be added again.") + throw IllegalArgumentException(getString(Res.string.plugin_error_builtin_add)) } val currentState = state.value @@ -530,7 +595,7 @@ class PluginManager( suspend fun removePlugin(plugin: PluginEntry) { if (plugin in BUILT_IN_PLUGINS) { - throw IllegalArgumentException("Built-in plugins can't be removed.") + throw IllegalArgumentException(getString(Res.string.plugin_error_builtin_remove)) } val currentState = state.value @@ -576,50 +641,49 @@ class PluginManager( Replace, } - private fun buildPendingPlugin(pluginEntry: PluginEntry, bytes: ByteArray): PendingPlugin { + private suspend fun buildPendingPlugin(pluginEntry: PluginEntry, bytes: ByteArray): PendingPlugin { val apiError = getPluginApiCompatibilityError(pluginEntry) if (apiError != null) { return PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.INFO, - title = "Plugin API not compatible", - message = apiError, - ) + title = getString(Res.string.plugin_prompt_incompatible_title), + message = apiError) } val installedPlugin = findInstalledPlugin(pluginEntry.id) ?: return PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.INSTALL, - title = "Install plugin?", - message = "${pluginEntry.name} will be added to your installed plugins.", + title = getString(Res.string.plugin_prompt_install_title), + message = getString(Res.string.plugin_prompt_install_message, pluginEntry.name), bytes = bytes, - confirmLabel = "Install", - ) + confirmLabel = getString(Res.string.plugin_action_install)) return when (compareVersions(installedPlugin, pluginEntry)) { VersionRelation.Update -> PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.UPDATE, - title = "Update plugin?", - message = "An older version is installed (${installedPlugin.version}). Update to ${pluginEntry.version}?", + title = getString(Res.string.plugin_prompt_update_title), + message = getString( + Res.string.plugin_prompt_update_message, + installedPlugin.version, + pluginEntry.version), bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Update", - ) + confirmLabel = getString(Res.string.update_action_update)) VersionRelation.Replace -> PendingPlugin( entry = pluginEntry, kind = InstallPromptKind.REPLACE, - title = "Replace installed plugin?", + title = getString(Res.string.plugin_prompt_replace_title), message = buildReplaceMessage(installedPlugin, pluginEntry), bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Replace", - ) + confirmLabel = getString(Res.string.plugin_action_replace)) } } - private fun buildReplaceMessage( + private suspend fun buildReplaceMessage( installedPlugin: PluginEntry, incomingPlugin: PluginEntry ): String { @@ -627,11 +691,22 @@ class PluginManager( val installedVersion = parseSemVerOrNull(installedPlugin.version) return when { - incomingVersion == null -> "${incomingPlugin.name} uses an invalid semantic version (${incomingPlugin.version}) and can't be compared as an update. Replace the installed plugin anyway?" - installedVersion == null -> "The installed version (${installedPlugin.version}) can't be compared using semantic versioning. Replace it with ${incomingPlugin.version}?" - incomingVersion == installedVersion -> "Version ${incomingPlugin.version} is already installed. Replace the existing plugin with the supplied copy?" - incomingVersion < installedVersion -> "Installed version ${installedPlugin.version} is newer than the supplied version ${incomingPlugin.version}. Replace it anyway?" - else -> "Replace the installed plugin with the supplied copy?" + incomingVersion == null -> getString( + Res.string.plugin_prompt_replace_invalid_incoming, + incomingPlugin.name, + incomingPlugin.version) + installedVersion == null -> getString( + Res.string.plugin_prompt_replace_invalid_installed, + installedPlugin.version, + incomingPlugin.version) + incomingVersion == installedVersion -> getString( + Res.string.plugin_prompt_replace_same_version, + incomingPlugin.version) + incomingVersion < installedVersion -> getString( + Res.string.plugin_prompt_replace_downgrade, + installedPlugin.version, + incomingPlugin.version) + else -> getString(Res.string.plugin_prompt_replace_generic) } } @@ -662,17 +737,21 @@ class PluginManager( } } - private fun ensurePluginApiCompatible(pluginEntry: PluginEntry) { + private suspend fun ensurePluginApiCompatible(pluginEntry: PluginEntry) { getPluginApiCompatibilityError(pluginEntry)?.let { error -> throw IllegalArgumentException(error) } } - private fun getPluginApiCompatibilityError(pluginEntry: PluginEntry): String? { + private suspend fun getPluginApiCompatibilityError(pluginEntry: PluginEntry): String? { val pluginApiVersion = parseSemVerOrNull(pluginEntry.apiVersion) - ?: return "${pluginEntry.name} declares an invalid apiVersion (${pluginEntry.apiVersion}). Expected semantic versioning compatible with $PLUGIN_API_VERSION." + ?: return getString( + Res.string.plugin_error_api_version_invalid, + pluginEntry.name, + pluginEntry.apiVersion, + PLUGIN_API_VERSION) val appApiVersion = parseSemVerOrNull(PLUGIN_API_VERSION) - ?: return "Spotube plugin API version $PLUGIN_API_VERSION is invalid." + ?: return getString(Res.string.plugin_error_app_api_version_invalid, PLUGIN_API_VERSION) val compatible = if (pluginApiVersion.major == 0 || appApiVersion.major == 0) { pluginApiVersion.major == appApiVersion.major && pluginApiVersion.minor == appApiVersion.minor @@ -683,7 +762,11 @@ class PluginManager( return if (compatible) { null } else { - "${pluginEntry.name} targets plugin API ${pluginEntry.apiVersion}, but Spotube supports breaking plugin API $PLUGIN_API_VERSION. Install a plugin built for the same breaking API version." + getString( + Res.string.plugin_error_api_incompatible, + pluginEntry.name, + pluginEntry.apiVersion, + PLUGIN_API_VERSION) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt similarity index 56% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt index 1d7189e4..050dd859 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginModels.kt @@ -15,12 +15,21 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin import com.goncalossilva.murmurhash.MurmurHash3 +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +@Serializable enum class PluginCapability { @SerialName("persistent_storage") PERSISTENT_STORAGE, @@ -43,6 +52,38 @@ enum class PluginAbility { SCROBBLE, } +/** + * Decodes a list of enums while dropping values this app version doesn't know about, so a + * plugin.json written for another plugin API revision still parses. + */ +abstract class LenientEnumListSerializer>( + private val elementSerializer: KSerializer) : KSerializer> { + private val listSerializer = ListSerializer(elementSerializer) + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) = + listSerializer.serialize(encoder, value) + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder ?: return listSerializer.deserialize(decoder) + val array = jsonDecoder.decodeJsonElement() as? JsonArray + ?: throw SerializationException("Expected a JSON array") + return array.mapNotNull { element -> + try { + jsonDecoder.json.decodeFromJsonElement(elementSerializer, element) + } catch (_: SerializationException) { + null + } + } + } +} + +object LenientPluginCapabilityListSerializer : + LenientEnumListSerializer(PluginCapability.serializer()) + +object LenientPluginAbilityListSerializer : + LenientEnumListSerializer(PluginAbility.serializer()) + @Serializable data class PluginEntry( val name: String, @@ -50,13 +91,14 @@ data class PluginEntry( val apiVersion: String, val description: String, val author: String, + @Serializable(with = LenientPluginCapabilityListSerializer::class) val capabilities: List, + @Serializable(with = LenientPluginAbilityListSerializer::class) val abilities: List, val contact: String, val repository: String, val bugs: String, - val license: String, -) { + val license: String) { @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray()) .toUInt() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt new file mode 100644 index 00000000..0d52c279 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -0,0 +1,1256 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin + + +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumDotsLoader +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.extras.kebabToTitleCase +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.HeaderDisplayMode +import org.spotube.spotube.getPlatform +import org.spotube.spotube.modules.plugin.components.DefaultAbilityPluginGrid +import org.spotube.spotube.modules.plugin.components.PluginCard +import org.spotube.spotube.modules.plugin.components.PluginDialogActions +import org.spotube.spotube.modules.plugin.components.PluginDialogBody +import org.spotube.spotube.modules.plugin.components.PluginDialogTitle +import org.spotube.spotube.modules.plugin.components.PluginIconTile +import org.spotube.spotube.modules.plugin.components.PluginLogoTile +import org.spotube.spotube.modules.plugin.components.PluginPanel +import org.spotube.spotube.modules.plugin.components.PluginPill +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.User +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import spotube.composeapp.generated.resources.plugin_install_sheet_subtitle +import spotube.composeapp.generated.resources.plugin_install_file_hint +import spotube.composeapp.generated.resources.plugin_install_or +import spotube.composeapp.generated.resources.plugin_info_dialog_title +import spotube.composeapp.generated.resources.plugin_version_label +import org.spotube.spotube.modules.plugin.components.PluginInstallDialog +import org.spotube.spotube.modules.plugin.components.PluginLogo +import org.spotube.spotube.modules.plugin.components.PluginPermissionDialog +import org.spotube.spotube.modules.plugin.components.abilityIcon +import org.spotube.spotube.modules.plugin.components.accentColor +import org.spotube.spotube.modules.plugin.components.displayLabel +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.openUrlInBrowser +import org.spotube.spotube.resources.iconsax.CarbonGithubLogo +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAdd +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxBox +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxExportArrowBulk +import org.spotube.spotube.resources.iconsax.IconsaxGlobe +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2Bulk +import org.spotube.spotube.resources.iconsax.IconsaxLink +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import okio.Path +import kotlinx.coroutines.flow.collect +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_discover_badge_official +import spotube.composeapp.generated.resources.plugin_discover_badge_verified +import spotube.composeapp.generated.resources.plugin_discover_repository_content_description +import spotube.composeapp.generated.resources.plugin_discover_repository_host +import spotube.composeapp.generated.resources.plugin_discover_stars_content_description +import spotube.composeapp.generated.resources.plugin_info_abilities +import spotube.composeapp.generated.resources.plugin_info_api_version +import spotube.composeapp.generated.resources.plugin_info_author +import spotube.composeapp.generated.resources.plugin_info_capabilities +import spotube.composeapp.generated.resources.plugin_info_contact +import spotube.composeapp.generated.resources.plugin_info_label_format +import spotube.composeapp.generated.resources.plugin_info_license +import spotube.composeapp.generated.resources.plugin_info_report_bugs +import spotube.composeapp.generated.resources.plugin_info_repository +import spotube.composeapp.generated.resources.plugin_info_version +import spotube.composeapp.generated.resources.plugin_support_dialog_title +import spotube.composeapp.generated.resources.common_close +import spotube.composeapp.generated.resources.welcome_plugins_title +import spotube.composeapp.generated.resources.welcome_plugins_setup_guide +import spotube.composeapp.generated.resources.welcome_plugins_discover +import spotube.composeapp.generated.resources.welcome_plugins_retry +import spotube.composeapp.generated.resources.welcome_plugins_more +import spotube.composeapp.generated.resources.welcome_plugins_discovery_empty +import spotube.composeapp.generated.resources.welcome_plugins_discovery_error +import spotube.composeapp.generated.resources.welcome_plugins_sources +import spotube.composeapp.generated.resources.plugin_action_download +import spotube.composeapp.generated.resources.plugin_action_install_from_file +import spotube.composeapp.generated.resources.plugin_configure_title +import spotube.composeapp.generated.resources.plugin_empty_subtitle +import spotube.composeapp.generated.resources.plugin_empty_title +import spotube.composeapp.generated.resources.plugin_error_download_failed +import spotube.composeapp.generated.resources.plugin_error_enter_url +import spotube.composeapp.generated.resources.plugin_error_url_scheme +import spotube.composeapp.generated.resources.plugin_install_section_title +import spotube.composeapp.generated.resources.plugin_installed_plugins_count +import spotube.composeapp.generated.resources.plugin_screen_title +import spotube.composeapp.generated.resources.plugin_section_file_title +import spotube.composeapp.generated.resources.plugin_section_install +import spotube.composeapp.generated.resources.plugin_section_url_title +import spotube.composeapp.generated.resources.plugin_url_placeholder +import spotube.composeapp.generated.resources.settings_plugins_action_change +import spotube.composeapp.generated.resources.settings_plugins_action_select +import spotube.composeapp.generated.resources.settings_plugins_default_ability_title +import spotube.composeapp.generated.resources.settings_plugins_no_plugins +import spotube.composeapp.generated.resources.settings_plugins_no_selection +import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description + +private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") + +private val VERIFIED_PLUGIN_OWNERS = setOf() + +@Composable +fun PluginScreen( + viewModel: PluginViewModel = koinViewModel(), + onboarding: Boolean = false) { + Column(modifier = Modifier.fillMaxSize()) { + if (!onboarding) { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } + PluginScreenContent( + viewModel = viewModel, + onboarding = onboarding, + modifier = Modifier.weight(1f)) + } +} + +/** + * The plugin management UI without its own scaffold / app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [PluginScreen]. + */ +@Composable +internal fun PluginScreenContent( + viewModel: PluginViewModel, + onboarding: Boolean = false, + modifier: Modifier = Modifier) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + val platform = remember { getPlatform() } + val launcher = rememberFilePickerLauncher( + type = FileKitType.File( + extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug") + ) + ) { file -> + viewModel.onFileSelected(file) + } + + val state = uiState + if (state is PluginUiState.Data) { + state.pendingPlugin?.let { pending -> + PluginPermissionDialog( + pluginInfo = pending.entry, + title = pending.title, + message = pending.message, + confirmLabel = pending.confirmLabel, + existingPlugin = pending.existingEntry, + logoPath = state.pendingPluginLogoPath, + onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { + { viewModel.confirmInstall() } + } else { + null + }, + isConfirming = state.isConfirmingInstall, + onDismiss = { viewModel.dismissInstall() } + ) + } + + state.showPluginInfo?.let { plugin -> + PluginInfoDialog( + plugin = plugin, + logoPath = state.pluginInfoLogoPath, + onDismiss = { viewModel.dismissPluginInfo() } + ) + } + + state.showPluginSupport?.let { plugin -> + SupportDialog( + plugin = plugin, + supportText = state.supportText, + isLoading = state.isLoadingSupport, + onDismiss = { viewModel.dismissSupport() } + ) + } + + state.installDialogRepo?.let { repo -> + PluginInstallDialog( + repo = repo, + releases = state.releases, + isLoadingReleases = state.isLoadingReleases, + onDismiss = { viewModel.dismissInstallDialog() }, + onInstall = { release -> + viewModel.dismissInstallDialog() + viewModel.installPlugin(release, repo.id) + } + ) + } + + if (state.showInstallSheet) { + InstallSheet( + urlInput = state.urlInput, + urlError = state.urlError, + isLoadingUrl = state.isLoadingUrl, + isPreparingFile = state.isPreparingFile, + onUrlChange = { viewModel.onUrlInputChange(it) }, + onSubmitUrl = { viewModel.submitUrl() }, + onPickFile = { launcher.launch() }, + onDismiss = { viewModel.dismissInstallSheet() } + ) + } + } + + Box(modifier = modifier.fillMaxSize()) { + when (val data = uiState) { + is PluginUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { MillenniumCircularProgress(size = 40.dp) } + } + + is PluginUiState.Data -> { + Box(modifier = Modifier.fillMaxSize()) { + val discoverListState = rememberLazyListState() + LazyColumn( + state = discoverListState, + modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = 8.dp, + bottom = 24.dp + shellBottomInset + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (onboarding) { + item { + Column( + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_plugins_title), + style = AppTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold) + Text( + stringResource(Res.string.welcome_plugins_setup_guide), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + } + } + item { + MillenniumSegmentedButtonRow( + modifier = Modifier.padding(bottom = 12.dp)) { + MillenniumSegmentedButton( + selected = data.onboardingDiscover, + onClick = { viewModel.showOnboardingDiscovery(true) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { + Text(stringResource(Res.string.welcome_plugins_discover)) + } + MillenniumSegmentedButton( + selected = !data.onboardingDiscover, + onClick = { viewModel.showOnboardingDiscovery(false) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { + Text(stringResource(Res.string.welcome_plugins_sources)) + } + } + } + } + if (!onboarding || !data.onboardingDiscover) { + + item { + DefaultAbilityPluginGrid( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + selections = data.abilitySelections, + onSelected = { ability, plugin -> + viewModel.selectPlugin(ability, plugin) + }) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(Res.string.plugin_configure_title), + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + MillenniumButton(onClick = { viewModel.showInstallSheet() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = stringResource(Res.string.plugin_install_section_title)) + Text(stringResource(Res.string.plugin_install_section_title)) + } + } + } + } + + if (data.plugins.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier.size(72.dp) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) + ) { + Box(contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = AppTheme.colors.primary + ) + } + } + Text( + stringResource(Res.string.plugin_empty_title), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource(Res.string.plugin_empty_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + } + } else { + item { + Text( + pluralStringResource( + Res.plurals.plugin_installed_plugins_count, + data.plugins.size, + data.plugins.size + ), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 4.dp + ) + ) + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp)) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + data.plugins.forEachIndexed { index, item -> + if (index > 0) { + MillenniumSeparator() + } + PluginCard( + plugin = item.plugin, + isSelected = item.isSelected, + onRemove = { viewModel.removePlugin(item.plugin) }, + isLoggedIn = item.authState.isLoggedIn, + logoPath = item.logoPath, + isAuthInProgress = item.isAuthInProgress, + isRemoving = item.isRemoving, + onInfo = { viewModel.showPluginInfo(item.plugin) }, + onSupport = if (item.isSelected) { + { viewModel.loadSupport(item.plugin) } + } else null, + onLogin = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.login(item.plugin) } + } else null, + onLogout = if (item.isSelected && item.authState.requiresAuth) { + { viewModel.logout(item.plugin) } + } else null) + } + } + } + } + } + + } + if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) { + if (onboarding) { + item { + MillenniumButton( + onClick = viewModel::showInstallSheet, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.plugin_install_section_title)) + } + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxGlobe, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.primary + ) + Text( + stringResource(Res.string.welcome_plugins_discover), + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + if (data.discover.isLoading) { + item { + Box( + Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + MillenniumDotsLoader() + } + } + } + if (onboarding && data.discover.isInitialLoaded && !data.discover.isLoading && data.discover.repos.isEmpty() && data.discover.error == null) { + item { + Text( + stringResource(Res.string.welcome_plugins_discovery_empty), + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(12.dp) + ) + } + } + items( + data.discover.repos, + key = { it.id } + ) { repo -> + DiscoverRepoCard( + repo = repo, + isInstalling = data.discover.installingRepoId == repo.id, + onInstall = { viewModel.showInstallDialog(repo) } + ) + } + + if (data.discover.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + MillenniumDotsLoader() + } + } + } + + data.discover.error?.let { error -> + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_plugins_discovery_error), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.error, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 8.dp + ) + ) + MillenniumButton( + onClick = viewModel::retryDiscovery, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.welcome_plugins_retry)) + } + } + } + } + if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) { + item { + MillenniumButton( + onClick = viewModel::loadNextDiscoverPage, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.welcome_plugins_more)) + } + } + } + } + } + + val density = LocalDensity.current + val shouldLoadMore = remember(density) { + derivedStateOf { + val totalItems = discoverListState.layoutInfo.totalItemsCount + val lastVisibleIndex = + discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: 0 + totalItems > 0 && lastVisibleIndex >= totalItems - 3 + } + } + + // Pagination trigger stays in the composable because it is driven by + // LazyList layout information, which only exists in UI scope. + // The actual loading call is forwarded to the ViewModel. + LaunchedEffect(shouldLoadMore.value, onboarding) { + if (!onboarding && shouldLoadMore.value) { + viewModel.loadNextDiscoverPage() + } + } + } + } + } + } +} + +@Composable +private fun UrlError.text(): String = when (this) { + is UrlError.Empty -> stringResource(Res.string.plugin_error_enter_url) + is UrlError.InvalidScheme -> stringResource(Res.string.plugin_error_url_scheme) + is UrlError.DownloadFailed -> stringResource(Res.string.plugin_error_download_failed) + is UrlError.Message -> message +} + +@Composable +private fun InstallSheet( + urlInput: String, + urlError: UrlError?, + isLoadingUrl: Boolean, + isPreparingFile: Boolean, + onUrlChange: (String) -> Unit, + onSubmitUrl: () -> Unit, + onPickFile: () -> Unit, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { + PluginDialogTitle( + title = stringResource(Res.string.plugin_install_section_title), + icon = Iconsax.IconsaxBoxAdd) + }, + dialogTitle = stringResource(Res.string.plugin_install_section_title)) { + PluginDialogBody { + Text( + stringResource(Res.string.plugin_install_sheet_subtitle), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + + val urlState = rememberTextFieldState(urlInput) + LaunchedEffect(urlInput) { + if (urlState.text.toString() != urlInput) { + urlState.edit { replace(0, length, urlInput) } + } + } + LaunchedEffect(urlState) { + snapshotFlow { urlState.text.toString() }.collect { value -> + if (value != urlInput) onUrlChange(value) + } + } + + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxLink) + Text( + stringResource(Res.string.plugin_section_url_title), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + } + MillenniumTextField( + state = urlState, + label = "", + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.plugin_url_placeholder), + error = urlError?.text()) + MillenniumButton( + onClick = onSubmitUrl, + loading = isLoadingUrl, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Primary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text(stringResource(Res.string.plugin_action_download)) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumSeparator(modifier = Modifier.weight(1f)) + Text( + stringResource(Res.string.plugin_install_or), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + MillenniumSeparator(modifier = Modifier.weight(1f)) + } + + PluginPanel(modifier = Modifier.clickable(enabled = !isPreparingFile, onClick = onPickFile)) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxExportArrowBulk) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(Res.string.plugin_action_install_from_file), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + Text( + stringResource(Res.string.plugin_install_file_hint), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + if (isPreparingFile) { + MillenniumSpokeSpinner(size = 18.dp) + } else { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) + } + } + } + } + } +} + +@Composable +private fun PluginInfoDialog( + plugin: PluginEntry, + logoPath: Path?, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_info_dialog_title)) }, + dialogTitle = stringResource(Res.string.plugin_info_dialog_title), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_close), + onSecondary = onDismiss) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + PluginLogoTile(plugin = plugin, logoPath = logoPath) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + plugin.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PluginPill(plugin.author, icon = Iconsax.User) + PluginPill( + stringResource(Res.string.plugin_version_label, plugin.version), + tint = AppTheme.colors.primary) + } + } + } + if (plugin.description.isNotBlank()) { + Text( + plugin.description, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion) + if (plugin.license.isNotBlank()) { + DetailRow(stringResource(Res.string.plugin_info_license), plugin.license) + } + if (plugin.capabilities.isNotEmpty()) { + DetailChipsRow( + stringResource(Res.string.plugin_info_capabilities), + plugin.capabilities.map { it.name }) + } + if (plugin.abilities.isNotEmpty()) { + DetailChipsRow( + stringResource(Res.string.plugin_info_abilities), + plugin.abilities.map { ability -> ability.displayLabel() }) + } + if (plugin.repository.isNotBlank()) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) { + openUrlInBrowser(plugin.repository) + } + } + if (plugin.contact.isNotBlank()) { + DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact) + } + if (plugin.bugs.isNotBlank()) { + ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) { + openUrlInBrowser(plugin.bugs) + } + } + } + } + } + } +} + +@Composable +private fun SupportDialog( + plugin: PluginEntry, + supportText: String?, + isLoading: Boolean, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_support_dialog_title, plugin.name)) }, + dialogTitle = stringResource(Res.string.plugin_support_dialog_title, plugin.name), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_close), + onSecondary = onDismiss) + }) { + PluginDialogBody { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 36.dp) + } + } else { + PluginPanel { + Text( + supportText ?: "", + modifier = Modifier.padding(14.dp), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DiscoverRepoCard( + repo: GitHubRepo, + isInstalling: Boolean, + onInstall: () -> Unit) { + val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS + val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + repo.fullName.split("/") + .last() + .replace("spotube-plugin-", "") + .kebabToTitleCase(), + style = AppTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (isOfficial) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) + ) { + Text( + stringResource(Res.string.plugin_discover_badge_official), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } else if (isVerified) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF4CAF50).copy(alpha = 0.15f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxCheckCircle, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = Color(0xFF4CAF50) + ) + Text( + stringResource(Res.string.plugin_discover_badge_verified), + style = AppTheme.typography.labelSmall, + color = Color(0xFF4CAF50) + ) + } + } + } + } + if (!repo.description.isNullOrBlank()) { + Text( + repo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically + ) { + Text( + repo.owner.login, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxHeart, + contentDescription = stringResource(Res.string.plugin_discover_stars_content_description), + modifier = Modifier.size(11.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + repo.stargazersCount.toString(), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.surfaceVariant) + .clickable { + openUrlInBrowser(repo.htmlUrl) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + MillenniumIcon( + Iconsax.CarbonGithubLogo, + contentDescription = stringResource(Res.string.plugin_discover_repository_content_description), + modifier = Modifier.size(10.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(Res.string.plugin_discover_repository_host), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, + singleLine = true + ) + } + } + } + } + MillenniumButton( + onClick = onInstall, + loading = isInstalling, + style = MillenniumButtonStyle.Secondary + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = null) + Text(stringResource(Res.string.plugin_section_install)) + } + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(Res.string.plugin_info_label_format, label), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Text( + value, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface + ) + } +} + +@Composable +private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(Res.string.plugin_info_label_format, label), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Text( + value, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun DetailChipsRow(label: String, chips: List) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(Res.string.plugin_info_label_format, label), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + chips.forEach { chip -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) + ) { + Text( + chip, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } +} + +@Composable +fun DefaultAbilityPluginSelector( + selection: AbilitySelection, + onSelected: (PluginEntry?) -> Unit = { }) { + val plugins = selection.plugins + val selectedPlugin = selection.selectedPlugin + val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) + + val menuItems = buildList { + if (plugins.isNotEmpty()) { + plugins.forEach { plugin -> + val isSelected = selectedPlugin?.name == plugin.name + add( + AdaptiveMenuItem( + label = plugin.name, + onClick = { onSelected(plugin) }, + selected = isSelected) + ) + } + } else { + add( + AdaptiveMenuItem( + label = noPluginsText, + onClick = { }, + enabled = false) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) + ) { + MillenniumIcon( + imageVector = selection.ability.abilityIcon(), + contentDescription = stringResource( + Res.string.settings_plugins_plugin_content_description, + selection.ability.displayLabel() + ), + modifier = Modifier.padding(8.dp), + tint = selection.ability.accentColor() + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + selection.ability.displayLabel() + ), + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface + ) + if (selectedPlugin != null) { + Text( + selectedPlugin.name, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, + modifier = Modifier.padding(top = 4.dp) + ) + } else { + Text( + stringResource(Res.string.settings_plugins_no_selection), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + + AdaptiveDropdownBottomSheet( + items = menuItems, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) + ) { + MillenniumIcon( + imageVector = selection.ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.padding(8.dp), + tint = selection.ability.accentColor() + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + selection.ability.displayLabel() + ), + style = AppTheme.typography.titleMedium) + selectedPlugin?.let { + Text( + it.name, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + } + } + } + }, + trigger = { onClick -> + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + if (selectedPlugin != null) { + stringResource(Res.string.settings_plugins_action_change) + } else { + stringResource(Res.string.settings_plugins_action_select) + }) + MillenniumIcon( + imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp)) + } + } + }) + } +} + diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt similarity index 80% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt index 004580f9..7d685cfd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -15,17 +15,19 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin +package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.core.zipline.PluginService +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.core.zipline.PluginService import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -49,21 +51,21 @@ import org.koin.core.component.KoinComponent data class PluginAuthState( val requiresAuth: Boolean = false, - val isLoggedIn: Boolean = false, -) + val isLoggedIn: Boolean = false) data class PluginListItem( val plugin: PluginEntry, val isSelected: Boolean, val authState: PluginAuthState, val logoPath: Path?, -) + val isAuthInProgress: Boolean = false, + val isRemoving: Boolean = false) data class AbilitySelection( val ability: PluginAbility, val plugins: List, val selectedPlugin: PluginEntry?, -) + val pluginLogoPaths: Map = emptyMap()) data class PluginDiscoverState( val repos: List = emptyList(), @@ -73,8 +75,7 @@ data class PluginDiscoverState( val isLoadingMore: Boolean = false, val error: String? = null, val installingRepoId: Long? = null, - val isInitialLoaded: Boolean = false, -) + val isInitialLoaded: Boolean = false) sealed interface UrlError { data object Empty : UrlError @@ -87,6 +88,7 @@ sealed interface PluginUiState { data object Loading : PluginUiState data class Data( + val onboardingDiscover: Boolean = true, val plugins: List, val abilitySelections: List, val pendingPlugin: PluginManager.PendingPlugin? = null, @@ -94,6 +96,8 @@ sealed interface PluginUiState { val urlInput: String = "", val urlError: UrlError? = null, val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, val showInstallSheet: Boolean = false, val showPluginInfo: PluginEntry? = null, val pluginInfoLogoPath: Path? = null, @@ -103,8 +107,7 @@ sealed interface PluginUiState { val installDialogRepo: GitHubRepo? = null, val releases: List = emptyList(), val isLoadingReleases: Boolean = false, - val discover: PluginDiscoverState = PluginDiscoverState(), - ) : PluginUiState + val discover: PluginDiscoverState = PluginDiscoverState()) : PluginUiState } private data class PluginManagerSnapshot( @@ -114,13 +117,17 @@ private data class PluginManagerSnapshot( val audioPlugins: List, val lyricsPlugins: List, val scrobblePlugins: List, - val activeServices: Map?, -) + val activeServices: Map?) private data class PluginScreenState( + val onboardingDiscover: Boolean = true, val urlInput: String = "", val urlError: UrlError? = null, val isLoadingUrl: Boolean = false, + val isPreparingFile: Boolean = false, + val isConfirmingInstall: Boolean = false, + val authInProgressIds: Set = emptySet(), + val removingIds: Set = emptySet(), val showInstallSheet: Boolean = false, val showPluginInfo: PluginEntry? = null, val pluginInfoLogoPath: Path? = null, @@ -130,17 +137,16 @@ private data class PluginScreenState( val installDialogRepo: GitHubRepo? = null, val releases: List = emptyList(), val isLoadingReleases: Boolean = false, - val discover: PluginDiscoverState = PluginDiscoverState(), -) + val discover: PluginDiscoverState = PluginDiscoverState()) @OptIn(ExperimentalCoroutinesApi::class) class PluginViewModel( private val pluginManager: PluginManager, private val webViewController: WebViewController, -) : ViewModel(), KoinComponent { + httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent { private val logger by injectLogger() - private val gitHubRepo = GitHubPluginRepository() + private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json) private val _screenState = MutableStateFlow(PluginScreenState()) private val _allRepos = mutableListOf() @@ -169,8 +175,7 @@ class PluginViewModel( private class PaginationInfo( var currentPage: Int = 1, var hasMore: Boolean = true, - var totalCount: Int = 0, - ) + var totalCount: Int = 0) @Suppress("UNCHECKED_CAST") private val pluginManagerSnapshot: StateFlow = combine( @@ -180,8 +185,7 @@ class PluginViewModel( pluginManager.audioPlugins, pluginManager.lyricsPlugins, pluginManager.scrobblePlugins, - pluginManager.ziplineServices, - ) { values -> + pluginManager.ziplineServices) { values -> PluginManagerSnapshot( state = values[0] as PluginManagerStates, pendingPlugin = values[1] as PluginManager.PendingPlugin?, @@ -189,8 +193,7 @@ class PluginViewModel( audioPlugins = values[3] as List, lyricsPlugins = values[4] as List, scrobblePlugins = values[5] as List, - activeServices = values[6] as Map?, - ) + activeServices = values[6] as Map?) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), @@ -201,8 +204,7 @@ class PluginViewModel( audioPlugins = emptyList(), lyricsPlugins = emptyList(), scrobblePlugins = emptyList(), - activeServices = null, - ) + activeServices = null) ) private val pluginLogoPaths: StateFlow> = pluginManager.state @@ -234,46 +236,48 @@ class PluginViewModel( pluginManagerSnapshot, _screenState, pluginAuthStates, - pluginLogoPaths, - ) { snapshot, screen, authStates, logoPaths -> + pluginLogoPaths) { snapshot, screen, authStates, logoPaths -> when (val state = snapshot.state) { is PluginManagerStates.Loading -> PluginUiState.Loading is PluginManagerStates.Data -> PluginUiState.Data( + onboardingDiscover = screen.onboardingDiscover, plugins = state.plugins.map { plugin -> PluginListItem( plugin = plugin, isSelected = state.selectedPlugins.containsValue(plugin), authState = authStates[plugin.id] ?: PluginAuthState(), logoPath = logoPaths[plugin.id], - ) + isAuthInProgress = plugin.id in screen.authInProgressIds, + isRemoving = plugin.id in screen.removingIds) }, abilitySelections = listOf( AbilitySelection( PluginAbility.METADATA, snapshot.metadataPlugins, - state.selectedPlugins[PluginAbility.METADATA] - ), + state.selectedPlugins[PluginAbility.METADATA], + logoPaths), AbilitySelection( PluginAbility.AUDIO, snapshot.audioPlugins, - state.selectedPlugins[PluginAbility.AUDIO] - ), + state.selectedPlugins[PluginAbility.AUDIO], + logoPaths), AbilitySelection( PluginAbility.LYRICS, snapshot.lyricsPlugins, - state.selectedPlugins[PluginAbility.LYRICS] - ), + state.selectedPlugins[PluginAbility.LYRICS], + logoPaths), AbilitySelection( PluginAbility.SCROBBLE, snapshot.scrobblePlugins, - state.selectedPlugins[PluginAbility.SCROBBLE] - ), - ), + state.selectedPlugins[PluginAbility.SCROBBLE], + logoPaths)), pendingPlugin = snapshot.pendingPlugin, pendingPluginLogoPath = snapshot.pendingPlugin?.entry?.id?.let { getLogoPath(it) }, urlInput = screen.urlInput, urlError = screen.urlError, isLoadingUrl = screen.isLoadingUrl, + isPreparingFile = screen.isPreparingFile, + isConfirmingInstall = screen.isConfirmingInstall, showInstallSheet = screen.showInstallSheet, showPluginInfo = screen.showPluginInfo, pluginInfoLogoPath = screen.pluginInfoLogoPath, @@ -283,8 +287,7 @@ class PluginViewModel( installDialogRepo = screen.installDialogRepo, releases = screen.releases, isLoadingReleases = screen.isLoadingReleases, - discover = screen.discover, - ) + discover = screen.discover) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PluginUiState.Loading) @@ -365,20 +368,25 @@ class PluginViewModel( it.copy( urlError = e.message?.let { message -> UrlError.Message(message) } ?: UrlError.DownloadFailed, - isLoadingUrl = false, - ) + isLoadingUrl = false) } } } } fun onFileSelected(file: PlatformFile?) { - if (file == null) return + if (file == null || _screenState.value.isPreparingFile) return + _screenState.update { it.copy(isPreparingFile = true) } viewModelScope.launch { - runCatching { - pluginManager.preparePlugin(file.readBytes()) - }.onFailure { e -> - logger.e(e) { "Failed to prepare plugin from file" } + try { + runCatching { + pluginManager.preparePlugin(file.readBytes()) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to prepare plugin from file" } + } + } finally { + _screenState.update { it.copy(isPreparingFile = false) } } } } @@ -395,8 +403,7 @@ class PluginViewModel( _screenState.update { it.copy( showPluginInfo = plugin, - pluginInfoLogoPath = getLogoPath(plugin.id), - ) + pluginInfoLogoPath = getLogoPath(plugin.id)) } } @@ -409,8 +416,7 @@ class PluginViewModel( it.copy( showPluginSupport = plugin, isLoadingSupport = true, - supportText = null, - ) + supportText = null) } viewModelScope.launch { val service = findServiceForPlugin(plugin.id) @@ -434,8 +440,7 @@ class PluginViewModel( it.copy( showPluginSupport = null, supportText = null, - isLoadingSupport = false, - ) + isLoadingSupport = false) } } @@ -444,8 +449,7 @@ class PluginViewModel( it.copy( installDialogRepo = repo, isLoadingReleases = true, - releases = emptyList(), - ) + releases = emptyList()) } viewModelScope.launch { runCatching { @@ -468,35 +472,73 @@ class PluginViewModel( pluginManager.setSelectedPlugin(ability, plugin) } + fun showOnboardingDiscovery(show: Boolean) { + _screenState.update { it.copy(onboardingDiscover = show) } + } + fun removePlugin(plugin: PluginEntry) { + if (plugin.id in _screenState.value.removingIds) return + _screenState.update { it.copy(removingIds = it.removingIds + plugin.id) } viewModelScope.launch { - runCatching { - pluginManager.removePlugin(plugin) - }.onFailure { e -> - logger.e(e) { "Failed to remove plugin" } + try { + runCatching { + pluginManager.removePlugin(plugin) + }.onFailure { e -> + if (e is CancellationException) throw e + logger.e(e) { "Failed to remove plugin" } + } + } finally { + _screenState.update { it.copy(removingIds = it.removingIds - plugin.id) } } } } fun login(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return val service = findServiceForPlugin(plugin.id) ?: return - pluginManager.launchTask { + trackAuthTask(plugin.id, pluginManager.launchTask { service.use { coreAPI.login() } - } + }) } fun logout(plugin: PluginEntry) { + if (plugin.id in _screenState.value.authInProgressIds) return val service = findServiceForPlugin(plugin.id) ?: return - pluginManager.launchTask { + val task = pluginManager.launchTask { service.use { coreAPI.logout() } } - viewModelScope.launch { + trackAuthTask(plugin.id, task) { webViewController.clearData(plugin.id) } } + /** + * Marks [pluginId] busy until [task] (running in the plugin manager's scope, so it outlives this + * screen) and the optional [alongside] work finish, whatever their outcome. + */ + private fun trackAuthTask(pluginId: String, task: Job, alongside: suspend () -> Unit = {}) { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds + pluginId) } + viewModelScope.launch { + try { + alongside() + task.join() + } finally { + _screenState.update { it.copy(authInProgressIds = it.authInProgressIds - pluginId) } + } + } + } + fun confirmInstall() { - pluginManager.confirmInstall() + if (_screenState.value.isConfirmingInstall) return + val task = pluginManager.confirmInstall() ?: return + _screenState.update { it.copy(isConfirmingInstall = true) } + viewModelScope.launch { + try { + task.join() + } finally { + _screenState.update { it.copy(isConfirmingInstall = false) } + } + } } fun dismissInstall() { @@ -512,6 +554,11 @@ class PluginViewModel( return repos.filter { it.htmlUrl !in installedUrls } } + fun retryDiscovery() { + if (_screenState.value.discover.isLoading) return + loadFirstDiscoverPage() + } + private fun loadFirstDiscoverPage() { viewModelScope.launch { _screenState.update { @@ -532,8 +579,7 @@ class PluginViewModel( currentPage = 1, hasMore = _paginationInfo.hasMore, isLoading = false, - isInitialLoaded = true, - ) + isInitialLoaded = true) ) } }.onFailure { e -> @@ -543,8 +589,7 @@ class PluginViewModel( discover = it.discover.copy( isLoading = false, error = e.message, - isInitialLoaded = true, - ) + isInitialLoaded = true) ) } } @@ -571,8 +616,7 @@ class PluginViewModel( repos = filterInstalledRepos(_allRepos), currentPage = nextPage, hasMore = _paginationInfo.hasMore, - isLoadingMore = false, - ) + isLoadingMore = false) ) } }.onFailure { e -> @@ -581,8 +625,7 @@ class PluginViewModel( it.copy( discover = it.discover.copy( isLoadingMore = false, - error = e.message, - ) + error = e.message) ) } } @@ -590,7 +633,8 @@ class PluginViewModel( } fun installPlugin(release: GitHubRelease, repoId: Long) { - val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl + val smplugUrl = + release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl if (smplugUrl != null) { installPluginFromUrl(smplugUrl, repoId) } @@ -616,8 +660,4 @@ class PluginViewModel( } } - override fun onCleared() { - gitHubRepo.close() - super.onCleared() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt new file mode 100644 index 00000000..17f267d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt @@ -0,0 +1,387 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin.components + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.core.ui.component.HeaderDisplayMode +import org.spotube.spotube.modules.plugin.AbilitySelection +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxDocumentText +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxSound +import org.spotube.spotube.resources.iconsax.IconsaxTextalignLeft +import okio.Path +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_version_label +import spotube.composeapp.generated.resources.settings_plugins_ability_audio +import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics +import spotube.composeapp.generated.resources.settings_plugins_ability_metadata +import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble +import spotube.composeapp.generated.resources.settings_plugins_default_ability_title +import spotube.composeapp.generated.resources.settings_plugins_no_plugins +import spotube.composeapp.generated.resources.settings_plugins_no_selection +import kotlin.random.Random + +/** The accent color used for a plugin ability across the whole plugin UI. */ +internal fun PluginAbility.accentColor(): Color = when (this) { + PluginAbility.METADATA -> Color(0xFF34A853) + PluginAbility.AUDIO -> Color(0xFF1E88E5) + PluginAbility.LYRICS -> Color(0xFFF9A825) + PluginAbility.SCROBBLE -> Color(0xFF8E24AA) +} + +/** The icon used to represent a plugin ability. */ +internal fun PluginAbility.abilityIcon(): ImageVector = when (this) { + PluginAbility.METADATA -> Iconsax.IconsaxDocumentText + PluginAbility.AUDIO -> Iconsax.IconsaxMusic + PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft + PluginAbility.SCROBBLE -> Iconsax.IconsaxSound +} + +/** + * A vibrant, multi-hue gradient per ability, used by the Frutiger-Aero plugin tiles. + */ +internal fun PluginAbility.aeroGradient(): List = when (this) { + PluginAbility.METADATA -> listOf(Color(0xFFA8E063), Color(0xFF56AB2F), Color(0xFF11998E)) + PluginAbility.AUDIO -> listOf(Color(0xFF56CCF2), Color(0xFF2F80ED), Color(0xFF6A11CB)) + PluginAbility.LYRICS -> listOf(Color(0xFFFFE259), Color(0xFFFFA751), Color(0xFFFF5E62)) + PluginAbility.SCROBBLE -> listOf(Color(0xFFFF6FD8), Color(0xFF9D50BB), Color(0xFF3F0D7A)) +} + +@Composable +internal fun PluginAbility.displayLabel(): String = when (this) { + PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) + PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) + PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) + PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) +} + +/** A short "v1.2.3 · Author" summary shown under a plugin in the picker. */ +@Composable +private fun PluginEntry.metadataLabel(): String = buildString { + append(stringResource(Res.string.plugin_version_label, version)) + if (author.isNotBlank()) { + append(" · ").append(author) + } +} + +/** + * The 2x2 grid of default-plugin selectors shown on large screens. Each tile is a + * Frutiger-Aero style card representing one ability and its currently selected plugin. + */ +@Composable +internal fun DefaultAbilityPluginGrid( + selections: List, + onSelected: (PluginAbility, PluginEntry?) -> Unit, + modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + selections.chunked(2).forEach { rowSelections -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + rowSelections.forEach { selection -> + DefaultAbilityPluginCard( + selection = selection, + onSelected = { onSelected(selection.ability, it) }, + modifier = Modifier.weight(1f)) + } + repeat(2 - rowSelections.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun DefaultAbilityPluginCard( + selection: AbilitySelection, + onSelected: (PluginEntry?) -> Unit, + modifier: Modifier = Modifier) { + val ability = selection.ability + val selected = selection.selectedPlugin + val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) + val noSelectionText = stringResource(Res.string.settings_plugins_no_selection) + + val menuItems = buildList { + if (selection.plugins.isNotEmpty()) { + selection.plugins.forEach { plugin -> + add( + AdaptiveMenuItem( + label = plugin.name, + subtitle = plugin.metadataLabel(), + leadingContent = { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center) { + PluginLogo( + plugin = plugin, + logoPath = selection.pluginLogoPaths[plugin.id], + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = ability.accentColor()) + }) + } + }, + onClick = { onSelected(plugin) }, + selected = selected?.name == plugin.name) + ) + } + } else { + add( + AdaptiveMenuItem( + label = noPluginsText, + onClick = { }, + enabled = false) + ) + } + } + + AdaptiveDropdownBottomSheet( + items = menuItems, + modifier = modifier, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AbilityBadge(ability) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + ability.displayLabel()), + style = AppTheme.typography.titleMedium) + selected?.let { + Text( + it.name, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + } + } + } + }, + trigger = { onClick -> + AbilityPluginTile( + ability = ability, + selectedPlugin = selected, + logoPath = selection.pluginLogoPaths[selected?.id], + pluginName = selected?.name ?: noSelectionText, + version = selected?.version, + onClick = onClick) + }) +} + +@Composable +private fun AbilityPluginTile( + ability: PluginAbility, + selectedPlugin: PluginEntry?, + logoPath: Path?, + pluginName: String, + version: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier) { + val accent = ability.accentColor() + val gradient = ability.aeroGradient() + val shape = RoundedCornerShape(20.dp) + val seed = ability.ordinal + 1 + + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .clickable(onClick = onClick) + .heightIn(min = 148.dp) + .drawWithCache { + val background = Brush.linearGradient( + colors = gradient, + start = Offset.Zero, + end = Offset(size.width, size.height)) + onDrawBehind { + drawRect(background) + drawAeroDecorations(seed) + // Bottom scrim so the white text stays readable on light gradients. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.32f)), + startY = size.height * 0.3f, + endY = size.height)) + // Glossy top highlight. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.35f), Color.Transparent), + startY = 0f, + endY = size.height * 0.55f)) + } + }) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.35f)) + .border(1.dp, Color.White.copy(alpha = 0.6f), CircleShape), + contentAlignment = Alignment.Center) { + PluginLogo( + plugin = selectedPlugin, + logoPath = logoPath, + contentDescription = pluginName, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = accent) + }) + } + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color.White.copy(alpha = 0.85f)) + } + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = pluginName, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Text( + text = buildString { + append(ability.displayLabel()) + if (!version.isNullOrBlank()) { + append(" · ").append(stringResource(Res.string.plugin_version_label, version)) + } + }, + style = AppTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun AbilityBadge(ability: PluginAbility) { + val accent = ability.accentColor() + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(accent.copy(alpha = 0.12f))) { + MillenniumIcon( + imageVector = ability.abilityIcon(), + contentDescription = null, + modifier = Modifier.padding(8.dp), + tint = accent) + } +} + +/** + * Soft translucent bubbles and highlights behind a tile, giving the glossy + * Frutiger-Aero look. Deterministic per tile so it does not flicker on recomposition. + */ +private fun DrawScope.drawAeroDecorations(seed: Int) { + val random = Random(seed) + repeat(6) { + val center = Offset( + x = random.nextFloat() * size.width, + y = random.nextFloat() * size.height) + val radius = size.minDimension * (0.12f + random.nextFloat() * 0.4f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent), + center = center, + radius = radius), + radius = radius, + center = center) + } + repeat(3) { + val center = Offset( + x = random.nextFloat() * size.width, + y = random.nextFloat() * size.height) + val radius = size.minDimension * (0.03f + random.nextFloat() * 0.05f) + drawCircle( + color = Color.White.copy(alpha = 0.28f), + radius = radius, + center = center) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt similarity index 61% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt index ec481a72..4937e3bb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginCard.kt @@ -15,9 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.plugin.components +package org.spotube.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,10 +35,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,24 +46,18 @@ 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 okio.Path -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBox -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.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.User +import org.spotube.spotube.modules.plugin.BUILT_IN_PLUGINS +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBox +import org.spotube.spotube.resources.iconsax.IconsaxCheckSquare +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxTag +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_action_remove @@ -65,10 +66,6 @@ import spotube.composeapp.generated.resources.plugin_action_logout import spotube.composeapp.generated.resources.plugin_state_active import spotube.composeapp.generated.resources.plugin_state_builtin import spotube.composeapp.generated.resources.plugin_version_label -import spotube.composeapp.generated.resources.settings_plugins_ability_audio -import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics -import spotube.composeapp.generated.resources.settings_plugins_ability_metadata -import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble @Composable internal fun PluginCard( @@ -81,7 +78,8 @@ internal fun PluginCard( onInfo: (() -> Unit)? = null, onSupport: (() -> Unit)? = null, logoPath: Path? = null, -) { + isAuthInProgress: Boolean = false, + isRemoving: Boolean = false) { Row( modifier = Modifier .fillMaxWidth() @@ -90,32 +88,27 @@ internal fun PluginCard( horizontalArrangement = Arrangement.spacedBy(14.dp) ) { // Plugin icon - Surface( + Box( modifier = Modifier .size(44.dp) - .clip(RoundedCornerShape(10.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + .clip(RoundedCornerShape(10.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) ) { - if (logoPath != null) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(logoPath.toString()) - .crossfade(true) - .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) - ) - } - } + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Box(contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBox, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(22.dp) + ) + } + }) } // Text content @@ -129,7 +122,7 @@ internal fun PluginCard( ) { Text( plugin.name, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -137,25 +130,26 @@ internal fun PluginCard( ) // "Active" chip if (isSelected) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) ) { Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckSquare, contentDescription = null, modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) Text( stringResource(Res.string.plugin_state_active), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary ) } } @@ -165,8 +159,8 @@ internal fun PluginCard( if (plugin.description.isNotBlank()) { Text( plugin.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -181,16 +175,16 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.User, contentDescription = null, modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( plugin.author, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } if (plugin !in BUILT_IN_PLUGINS) @@ -198,16 +192,16 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxTag, contentDescription = null, modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( stringResource(Res.string.plugin_version_label, plugin.version), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } @@ -216,14 +210,15 @@ internal fun PluginCard( if (plugin.abilities.isNotEmpty()) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { plugin.abilities.forEach { ability -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) ) { Text( ability.displayLabel(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, modifier = Modifier.padding( horizontal = 6.dp, vertical = 2.dp @@ -245,42 +240,46 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically ) { if (onInfo != null) { - GhostIconButton(onClick = onInfo) { - Icon( + MillenniumIconButton( + onClick = onInfo, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxInformation, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } if (onSupport != null) { - GhostIconButton(onClick = onSupport) { - Icon( + MillenniumIconButton( + onClick = onSupport, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxHeart, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } if (plugin in BUILT_IN_PLUGINS) { Text( stringResource(Res.string.plugin_state_builtin), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + BorderStroke(1.dp, AppTheme.colors.outlineVariant), shape = RoundedCornerShape(6.dp) ) .padding(horizontal = 6.dp, vertical = 2.dp) ) } else { - GhostIconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + loading = isRemoving, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxTrash, contentDescription = stringResource(Res.string.plugin_action_remove), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } @@ -291,10 +290,13 @@ internal fun PluginCard( else -> null } authAction?.let { (action, label) -> - OutlineButton(onClick = action) { + MillenniumButton( + onClick = action, + loading = isAuthInProgress, + style = MillenniumButtonStyle.Secondary) { Text( text = stringResource(label), - style = MaterialTheme.typography.labelLarge + style = AppTheme.typography.labelLarge ) } } @@ -302,12 +304,3 @@ internal fun PluginCard( } } -@Composable -private fun PluginAbility.displayLabel(): String { - return when (this) { - PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) - PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) - PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) - PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) - } -} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt new file mode 100644 index 00000000..26e390ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginDialogParts.kt @@ -0,0 +1,251 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import okio.Path +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface + +// Shared building blocks for the plugin dialogs/sheets so they all read as one family and +// match the panel/tile language used by the plugin cards and the rest of the app. + +/** Body column for plugin dialogs; the adaptive dialog/sheet around it handles scrolling. */ +@Composable +internal fun PluginDialogBody(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content) +} + +/** Title for the phone bottom sheet; the desktop dialog shows the same text in its titlebar. */ +@Composable +internal fun PluginDialogTitle( + title: String, + icon: ImageVector? = null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (icon != null) { + PluginIconTile(icon, size = 40.dp) + } + Text( + title, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface) + } +} + +/** Rounded, softly bordered panel — the same surface the plugin cards use. */ +@Composable +internal fun PluginPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier + .fillMaxWidth() + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp), + content = content) +} + +@Composable +internal fun PluginIconTile( + icon: ImageVector, + modifier: Modifier = Modifier, + size: Dp = 38.dp, + tint: Color = AppTheme.colors.primary) { + Box( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(size / 3.4f)) + .background(tint.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(size * 0.5f), + tint = tint) + } +} + +@Composable +internal fun PluginLogoTile( + plugin: PluginEntry, + logoPath: Path?, + size: Dp = 56.dp) { + Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(size / 3.4f)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center) { + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = Iconsax.IconsaxBoxAdd, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(size * 0.5f)) + }) + } +} + +@Composable +internal fun PluginPill( + text: String, + icon: ImageVector? = null, + tint: Color = AppTheme.colors.onSurfaceVariant) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(tint.copy(alpha = 0.12f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (icon != null) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = tint) + } + Text( + text, + style = AppTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } +} + +/** Tinted info strip; [isWarning] switches it to the error tone. */ +@Composable +internal fun PluginCallout(text: String, isWarning: Boolean = false) { + val tint = if (isWarning) AppTheme.colors.error else AppTheme.colors.primary + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(tint.copy(alpha = 0.10f)) + .padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxInformation, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = tint) + Text( + text, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurface) + } +} + +@Composable +internal fun PluginSectionLabel(text: String, badge: String? = null) { + Row( + modifier = Modifier.padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text, + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface) + if (badge != null) { + PluginPill(badge, tint = AppTheme.colors.primary) + } + } +} + +/** Equal-width secondary + primary action pair. */ +@Composable +internal fun PluginDialogActions( + secondaryLabel: String, + onSecondary: () -> Unit, + primaryLabel: String? = null, + onPrimary: (() -> Unit)? = null, + primaryEnabled: Boolean = true, + primaryLoading: Boolean = false) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onSecondary, + modifier = Modifier.weight(1f), + style = if (primaryLabel != null) MillenniumButtonStyle.Secondary else MillenniumButtonStyle.Primary) { + Text(secondaryLabel) + } + if (primaryLabel != null && onPrimary != null) { + MillenniumButton( + onClick = onPrimary, + enabled = primaryEnabled, + loading = primaryLoading, + modifier = Modifier.weight(1f), + style = MillenniumButtonStyle.Primary) { + Text(primaryLabel) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt new file mode 100644 index 00000000..6a05841d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -0,0 +1,211 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumCircularProgress +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.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.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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 com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.GitHubRelease +import org.spotube.spotube.modules.plugin.GitHubRepo +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumDropdownMenuContent +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.plugin_action_install +import spotube.composeapp.generated.resources.plugin_install_no_releases +import spotube.composeapp.generated.resources.plugin_install_prerelease +import spotube.composeapp.generated.resources.plugin_install_release_notes +import spotube.composeapp.generated.resources.plugin_install_select_release_label +import spotube.composeapp.generated.resources.plugin_install_select_release_placeholder + +@Composable +fun PluginInstallDialog( + repo: GitHubRepo, + releases: List, + isLoadingReleases: Boolean, + onDismiss: () -> Unit, + onInstall: (GitHubRelease) -> Unit) { + var selectedRelease by remember { mutableStateOf(null) } + var expanded by remember { mutableStateOf(false) } + + LaunchedEffect(releases) { + if (selectedRelease == null && releases.isNotEmpty()) { + selectedRelease = releases.first() + } + } + + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(stringResource(Res.string.plugin_action_install)) }, + dialogTitle = stringResource(Res.string.plugin_action_install), + actions = { + PluginDialogActions( + secondaryLabel = stringResource(Res.string.common_cancel), + onSecondary = onDismiss, + primaryLabel = stringResource(Res.string.plugin_action_install), + onPrimary = { selectedRelease?.let(onInstall) }, + primaryEnabled = selectedRelease != null && !isLoadingReleases) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp))) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + repo.fullName, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + if (!repo.description.isNullOrBlank()) { + Text( + repo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis) + } + } + } + } + + if (isLoadingReleases) { + Box( + modifier = Modifier.fillMaxWidth().height(96.dp), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 36.dp) + } + } else if (releases.isEmpty()) { + PluginCallout(stringResource(Res.string.plugin_install_no_releases), isWarning = true) + } else { + PluginSectionLabel(stringResource(Res.string.plugin_install_select_release_label)) + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth(), + anchor = { + MillenniumButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text( + selectedRelease?.tagName + ?: stringResource(Res.string.plugin_install_select_release_placeholder), + style = AppTheme.typography.bodyMedium) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } + } + }) { + MillenniumDropdownMenuContent { + releases.forEach { release -> + MillenniumDropdownMenuItem( + label = release.tagName, + selected = release.tagName == selectedRelease?.tagName, + description = if (release.prerelease) { + stringResource(Res.string.plugin_install_prerelease) + } else null, + onClick = { + selectedRelease = release + expanded = false + }) + } + } + } + + selectedRelease?.let { release -> + if (release.prerelease) { + PluginCallout(stringResource(Res.string.plugin_install_prerelease), isWarning = true) + } + if (!release.body.isNullOrBlank()) { + PluginSectionLabel(stringResource(Res.string.plugin_install_release_notes)) + PluginPanel { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + .padding(12.dp)) { + Text( + release.body, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt new file mode 100644 index 00000000..531f99cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginLogo.kt @@ -0,0 +1,73 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin.components + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.modules.plugin.builtInPluginLogo +import okio.Path +import org.jetbrains.compose.resources.painterResource + +/** + * Renders a plugin's logo, resolving the best available source: + * + * 1. the plugin's own `logo.png` ([logoPath]) when it was installed from disk, + * 2. the bundled placeholder logo for built-in plugins (see [builtInPluginLogo]), + * 3. [fallback] (for example the ability icon) when no logo is available. + */ +@Composable +fun PluginLogo( + plugin: PluginEntry?, + logoPath: Path?, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Crop, + fallback: @Composable () -> Unit) { + val builtInLogo = plugin?.let { builtInPluginLogo(it) } + + when { + logoPath != null -> { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(logoPath.toString()) + .crossfade(true) + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier) + } + + builtInLogo != null -> { + Image( + painter = painterResource(builtInLogo), + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier) + } + + else -> fallback() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt new file mode 100644 index 00000000..c919aaa4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -0,0 +1,247 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ArrowRight +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginCapability +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxEye +import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare +import org.spotube.spotube.resources.iconsax.PhosphorDatabaseDuotone +import org.spotube.spotube.resources.iconsax.User +import okio.Path +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.common_close +import spotube.composeapp.generated.resources.plugin_permissions_api_diff +import spotube.composeapp.generated.resources.plugin_permissions_capability_network_desc +import spotube.composeapp.generated.resources.plugin_permissions_capability_network_title +import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_desc +import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_title +import spotube.composeapp.generated.resources.plugin_permissions_capability_webview_desc +import spotube.composeapp.generated.resources.plugin_permissions_capability_webview_title +import spotube.composeapp.generated.resources.plugin_permissions_compare_title +import spotube.composeapp.generated.resources.plugin_permissions_installed_label +import spotube.composeapp.generated.resources.plugin_permissions_none +import spotube.composeapp.generated.resources.plugin_permissions_requested_title +import spotube.composeapp.generated.resources.plugin_permissions_supplied_label +import spotube.composeapp.generated.resources.plugin_version_label + +@Composable +fun PluginPermissionDialog( + pluginInfo: PluginEntry, + title: String, + message: String, + confirmLabel: String?, + existingPlugin: PluginEntry? = null, + logoPath: Path? = null, + onConfirm: (() -> Unit)? = null, + isConfirming: Boolean = false, + onDismiss: () -> Unit) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { PluginDialogTitle(title) }, + dialogTitle = title, + actions = { + PluginDialogActions( + secondaryLabel = stringResource( + if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), + onSecondary = onDismiss, + primaryLabel = confirmLabel, + onPrimary = onConfirm, + primaryLoading = isConfirming) + }) { + PluginDialogBody { + PluginPanel { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp)) { + PluginLogoTile(plugin = pluginInfo, logoPath = logoPath) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + pluginInfo.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PluginPill(pluginInfo.author, icon = Iconsax.User) + PluginPill( + stringResource(Res.string.plugin_version_label, pluginInfo.version), + tint = AppTheme.colors.primary) + } + } + } + if (pluginInfo.description.isNotBlank()) { + Text( + pluginInfo.description, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis) + } + } + + PluginCallout(text = message, isWarning = confirmLabel == null) + + existingPlugin?.let { + InstalledComparisonPanel(installedPlugin = it, incomingPlugin = pluginInfo) + } + + PluginSectionLabel( + stringResource(Res.string.plugin_permissions_requested_title), + badge = pluginInfo.capabilities.size.takeIf { it > 0 }?.toString()) + + PluginPanel { + if (pluginInfo.capabilities.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(Iconsax.IconsaxCheckCircle) + Text( + stringResource(Res.string.plugin_permissions_none), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + } else { + pluginInfo.capabilities.forEachIndexed { index, capability -> + if (index > 0) MillenniumSeparator() + CapabilityRow(capability) + } + } + } + } + } +} + +@Composable +private fun InstalledComparisonPanel( + installedPlugin: PluginEntry, + incomingPlugin: PluginEntry) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PluginSectionLabel(stringResource(Res.string.plugin_permissions_compare_title)) + PluginPanel { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly) { + VersionStat( + label = stringResource(Res.string.plugin_permissions_installed_label), + value = installedPlugin.version) + MillenniumIcon( + imageVector = FeatherIcons.ArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppTheme.colors.onSurfaceVariant) + VersionStat( + label = stringResource(Res.string.plugin_permissions_supplied_label), + value = incomingPlugin.version, + highlighted = true) + } + Text( + stringResource( + Res.string.plugin_permissions_api_diff, + installedPlugin.apiVersion, + incomingPlugin.apiVersion), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + } + } +} + +@Composable +private fun VersionStat(label: String, value: String, highlighted: Boolean = false) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + label, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + Text( + stringResource(Res.string.plugin_version_label, value), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (highlighted) AppTheme.colors.primary else AppTheme.colors.onSurface) + } +} + +@Composable +private fun CapabilityRow(capability: PluginCapability) { + val (icon, label, description) = when (capability) { + PluginCapability.PERSISTENT_STORAGE -> Triple( + Iconsax.PhosphorDatabaseDuotone, + stringResource(Res.string.plugin_permissions_capability_storage_title), + stringResource(Res.string.plugin_permissions_capability_storage_desc)) + PluginCapability.NETWORK_REQUESTS -> Triple( + Iconsax.IconsaxWifiSquare, + stringResource(Res.string.plugin_permissions_capability_network_title), + stringResource(Res.string.plugin_permissions_capability_network_desc)) + PluginCapability.WEBVIEW -> Triple( + Iconsax.IconsaxEye, + stringResource(Res.string.plugin_permissions_capability_webview_title), + stringResource(Res.string.plugin_permissions_capability_webview_desc)) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PluginIconTile(icon) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + label, + style = AppTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium) + Text( + description, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt new file mode 100644 index 00000000..a2dfde04 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -0,0 +1,270 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin.guard + +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.LoginRequiredDisplay +import org.spotube.spotube.modules.plugin.MetadataAuthViewModel +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAdd +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxMusicLibrary +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.no_metadata_plugin_action_install +import spotube.composeapp.generated.resources.no_metadata_plugin_action_local +import spotube.composeapp.generated.resources.no_metadata_plugin_local_hint +import spotube.composeapp.generated.resources.no_metadata_plugin_action_select +import spotube.composeapp.generated.resources.no_metadata_plugin_description_install +import spotube.composeapp.generated.resources.no_metadata_plugin_description_select +import spotube.composeapp.generated.resources.no_metadata_plugin_step_discover +import spotube.composeapp.generated.resources.no_metadata_plugin_step_install +import spotube.composeapp.generated.resources.no_metadata_plugin_step_select +import spotube.composeapp.generated.resources.no_metadata_plugin_title + +/** + * Renders [content] only while a metadata plugin is selected. Otherwise shows a guided + * "no plugin" state with a call to action leading to the plugin manager, instead of letting + * the screen sit on an endless loading skeleton. + * + * @param backButton whether the placeholder's top bar shows a back button (for pushed screens). + * @param requireLogin also swap [content] for a friendly sign-in prompt while the plugin needs an + * account and the user isn't signed in (for screens that only make sense when logged in). + * @param showTopBar false when the host screen already draws its own app bar (e.g. Library tabs). + */ +@Composable +fun MetadataPluginGuard( + backButton: Boolean = false, + showTopBar: Boolean = true, + requireLogin: Boolean = false, + viewModel: MetadataPluginGuardViewModel = koinViewModel(), + authViewModel: MetadataAuthViewModel = koinViewModel(), + content: @Composable () -> Unit +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val authState by authViewModel.uiState.collectAsStateWithLifecycle() + + when (val s = state) { + is MetadataPluginGuardState.Available -> + if (requireLogin && authState.loginRequired) { + Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + if (showTopBar) ApplicationMainBar(backButton = backButton) + Box( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = LocalAppShellBottomInset.current), + contentAlignment = Alignment.Center) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + isLoggingIn = authState.isLoggingIn, + modifier = Modifier.widthIn(max = 420.dp)) + } + } + } else { + content() + } + is MetadataPluginGuardState.Loading -> Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) + is MetadataPluginGuardState.Missing -> NoMetadataPluginScreen( + hasInstalledPlugins = s.hasInstalledPlugins, + backButton = backButton, + showTopBar = showTopBar, + onOpenPlugins = viewModel::openPlugins, + onOpenLocalTracks = viewModel::openLocalTracks) + } +} + +@Composable +private fun NoMetadataPluginScreen( + hasInstalledPlugins: Boolean, + backButton: Boolean, + showTopBar: Boolean, + onOpenPlugins: () -> Unit, + onOpenLocalTracks: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + if (showTopBar) ApplicationMainBar(backButton = backButton) + + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = LocalAppShellBottomInset.current), + contentAlignment = Alignment.Center) { + Column( + modifier = Modifier + .widthIn(max = 420.dp) + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + HeroBadge() + + Text( + text = stringResource(Res.string.no_metadata_plugin_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = stringResource( + if (hasInstalledPlugins) Res.string.no_metadata_plugin_description_select + else Res.string.no_metadata_plugin_description_install), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + + if (!hasInstalledPlugins) { + MillenniumCard(modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Step(1, Res.string.no_metadata_plugin_step_discover) + Step(2, Res.string.no_metadata_plugin_step_install) + Step(3, Res.string.no_metadata_plugin_step_select) + } + } + } + + MillenniumButton( + onClick = onOpenPlugins, + style = MillenniumButtonStyle.Primary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + if (hasInstalledPlugins) Iconsax.IconsaxCheckCircle else Iconsax.IconsaxAdd, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text( + stringResource( + if (hasInstalledPlugins) Res.string.no_metadata_plugin_action_select + else Res.string.no_metadata_plugin_action_install)) + } + } + + MillenniumButton( + onClick = onOpenLocalTracks, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + Iconsax.IconsaxMusicLibrary, + contentDescription = null, + modifier = Modifier.size(18.dp)) + Text(stringResource(Res.string.no_metadata_plugin_action_local)) + } + } + + Text( + text = stringResource(Res.string.no_metadata_plugin_local_hint), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun HeroBadge() { + val primary = AppTheme.colors.primary + Box( + modifier = Modifier + .size(104.dp) + .clip(CircleShape) + .background(Brush.radialGradient(listOf(primary.copy(alpha = 0.22f), primary.copy(alpha = 0f)))), + contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBoxAdd, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = primary) + } + } +} + +@Composable +private fun Step(index: Int, label: StringResource) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + Text( + text = index.toString(), + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.primary) + } + Text( + text = stringResource(label), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt new file mode 100644 index 00000000..02cdf6cb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt @@ -0,0 +1,75 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.plugin.guard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.plugin.PluginAbility +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginManagerStates +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +sealed interface MetadataPluginGuardState { + data object Loading : MetadataPluginGuardState + data object Available : MetadataPluginGuardState + + /** No metadata plugin is selected. [hasInstalledPlugins] tells whether the user only needs + * to pick one (true) or has to install one first (false). */ + data class Missing(val hasInstalledPlugins: Boolean) : MetadataPluginGuardState +} + +class MetadataPluginGuardViewModel( + pluginManager: PluginManager, + private val navigationCommands: NavigationCommands, + private val libraryState: LibraryState +) : ViewModel() { + val uiState: StateFlow = pluginManager.state + .map { state -> + when (state) { + is PluginManagerStates.Loading -> MetadataPluginGuardState.Loading + is PluginManagerStates.Data -> + if (PluginAbility.METADATA in state.selectedPlugins) { + MetadataPluginGuardState.Available + } else { + MetadataPluginGuardState.Missing( + hasInstalledPlugins = state.plugins.any { PluginAbility.METADATA in it.abilities }) + } + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MetadataPluginGuardState.Loading) + + fun openPlugins() { + navigationCommands.navigateTo(Routes.Plugins) + } + + /** Local tracks are a plugin-free player, so offer them as the way forward. */ + fun openLocalTracks() { + libraryState.onTabSelected(LibraryTab.LocalTracks) + navigationCommands.navigateTo(Routes.Library) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt index e5a7a2e9..a2689682 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.saved_tracks +package org.spotube.spotube.modules.saved_tracks -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -50,6 +50,14 @@ class SavedTracksRepository( val savedTracksIdsFlow : StateFlow> = savedTrackIds.asStateFlow() + /** + * Bumped only when the library itself changes (save, remove, re-login). [savedTracksIdsFlow] + * also grows while pages and saved-states are fetched, so reloading the list on it restarted + * paging on every page (#3200). + */ + private val _libraryChanges = MutableStateFlow(0) + val libraryChanges: StateFlow = _libraryChanges.asStateFlow() + init { scope.launch { pluginManager.selectedMetadataPlugin @@ -71,6 +79,7 @@ class SavedTracksRepository( savedTracksCache.invalidateAll() totalCountCache.invalidateAll() savedTrackIds.value = emptySet() + _libraryChanges.value++ } suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) = @@ -109,6 +118,7 @@ class SavedTracksRepository( savedTracksCache.invalidateAll() totalCountCache.invalidateAll() savedTrackIds.value += ids.toSet() + _libraryChanges.value++ } } @@ -122,6 +132,7 @@ class SavedTracksRepository( savedTracksCache.invalidateAll() totalCountCache.invalidateAll() savedTrackIds.value -= ids.toSet() + _libraryChanges.value++ } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt similarity index 66% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt index 045a0380..b3da0317 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -15,21 +15,28 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.saved_tracks +package org.spotube.spotube.modules.saved_tracks import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker +import kotlinx.coroutines.flow.map +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.CollectionView +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.liked_tracks +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun SavedTracksScreen( @@ -39,6 +46,12 @@ fun SavedTracksScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() @@ -53,15 +66,18 @@ fun SavedTracksScreen( val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) CollectionView( - title = "Saved Tracks", - description = dataState?.let { "${it.totalCount} tracks" } ?: "", + isPlayLoading = playbackUiState.isPlayLoading, + isShuffleLoading = playbackUiState.isShuffleLoading, + isAddToQueueLoading = playbackUiState.isAddToQueueLoading, + title = stringResource(Res.string.saved_tracks_title), + description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "", imageURL = "", imageResource = Res.drawable.liked_tracks, - ownerName = "You", + ownerName = stringResource(Res.string.saved_tracks_owner_you), ownerImageURL = null, onOwnerClick = {}, onPlay = viewModel::playSavedTracks, - onShufflePlay = {}, + onShufflePlay = viewModel::shufflePlaySavedTracks, onAddToQueue = viewModel::addSavedTracksToQueue, isPlaying = isPlaying, isFollowing = false, @@ -85,13 +101,15 @@ fun SavedTracksScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) - }, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt similarity index 70% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 41fa1bc7..08644d14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.saved_tracks +package org.spotube.spotube.modules.saved_tracks import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,18 +28,21 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadManager -import dev.krtirtho.spotube.modules.library.LibraryRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.playback.CollectionPlaybackHelper +import org.spotube.spotube.core.remote.CollectionPlaybackUiState +import org.spotube.spotube.core.remote.RemoteCollectionType +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsContext +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadManager +import org.spotube.spotube.modules.library.LibraryRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -52,9 +55,12 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.* const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks" @@ -69,20 +75,17 @@ sealed interface SavedTracksScreenState { data class Loaded( override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val totalCount: Int = 0, - ) : Data { + override val totalCount: Int = 0) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( tracks = tracks, nextPagination = nextPagination, - totalCount = totalCount, - ) + totalCount = totalCount) } data class LoadingMore( override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val totalCount: Int = 0, - ) : Data + override val totalCount: Int = 0) : Data } data class Error(val message: String) : SavedTracksScreenState @@ -97,14 +100,23 @@ class SavedTracksViewModel( private val libraryRepository: LibraryRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(SavedTracksScreenState.Loading) val uiState: StateFlow = _state.asStateFlow() + + /** Loading flags for this screen's play / shuffle / add-to-queue buttons. */ + val playbackUiState: StateFlow = remotePlaybackController + .collectionPlaybackUiState(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState()) val savedTrackIdsFlow: StateFlow> get() = repository.savedTracksIdsFlow + // Tracks whose save/remove request is still in flight. + private val _pendingSavedTrackIds = MutableStateFlow>(emptySet()) + val pendingSavedTrackIds: StateFlow> = _pendingSavedTrackIds.asStateFlow() + private val _currentUserId = MutableStateFlow(null) val currentUserId: StateFlow = _currentUserId.asStateFlow() @@ -122,8 +134,7 @@ class SavedTracksViewModel( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -131,8 +142,7 @@ class SavedTracksViewModel( }.toSet(), blacklistedTrackIds = blacklistedTracks, blacklistedArtistIds = blacklistedArtists, - forceFavorite = true, - ) + forceFavorite = true) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext(forceFavorite = true)) init { @@ -142,7 +152,7 @@ class SavedTracksViewModel( .filterNotNull() .flatMapLatest { it.loggedInFlow } .distinctUntilChanged(), - repository.savedTracksIdsFlow + repository.libraryChanges ) { _, _ -> }.collect { loadInitialData() loadCurrentUser() @@ -171,11 +181,10 @@ class SavedTracksViewModel( _state.value = SavedTracksScreenState.Data.Loaded( tracks = tracksResult?.items ?: emptyList(), nextPagination = tracksResult?.nextPagination, - totalCount = totalCount, - ) + totalCount = totalCount) }.onFailure { e -> logger.e(e) { "Failed to load saved tracks" } - _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") + _state.value = SavedTracksScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } @@ -189,26 +198,37 @@ class SavedTracksViewModel( _state.value = SavedTracksScreenState.Data.Loaded( tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - totalCount = currentState.totalCount, - ) + totalCount = currentState.totalCount) }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } - _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") + _state.value = SavedTracksScreenState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } } } fun playSavedTracks() { - viewModelScope.launch { playbackHelper.playSavedTracks() } + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") + } + + fun shufflePlaySavedTracks() { + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.SavedTracks, + SAVED_TRACKS_COLLECTION_ID, + "Saved Tracks", + shuffle = true) } fun addSavedTracksToQueue() { - viewModelScope.launch { playbackHelper.addSavedTracksToQueue() } + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun playSavedTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) } + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.SavedTracks, + id = SAVED_TRACKS_COLLECTION_ID, + title = "Saved Tracks", + startTrack = track) } fun refresh() { @@ -224,14 +244,24 @@ class SavedTracksViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -263,8 +293,7 @@ class SavedTracksViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -298,34 +327,16 @@ class SavedTracksViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks") } suspend fun isSavedTracks(trackIds: List): List { @@ -340,6 +351,23 @@ class SavedTracksViewModel( repository.removeSavedTracks(trackIds) } + fun toggleSavedTrack(trackId: String, isSaved: Boolean) { + if (trackId in _pendingSavedTrackIds.value) return + _pendingSavedTrackIds.update { it + trackId } + viewModelScope.launch { + runCatching { + if (isSaved) { + repository.removeSavedTracks(listOf(trackId)) + } else { + repository.saveTracks(listOf(trackId)) + } + }.onFailure { e -> + logger.e(e) { "Failed to toggle saved track" } + } + _pendingSavedTrackIds.update { it - trackId } + } + } + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id return title == other.title && @@ -349,7 +377,6 @@ class SavedTracksViewModel( } } - sealed interface SavedState { data class Success(val data: T) : SavedState data object Loading : SavedState @@ -359,8 +386,7 @@ sealed interface SavedState { @Composable fun rememberIsSavedTracks( trackIds: List, - repository: SavedTracksRepository = koinInject(), -): SavedState> { + repository: SavedTracksRepository = koinInject()): SavedState> { val scope = rememberCoroutineScope() var state by remember { mutableStateOf>>(SavedState.Loading) } @@ -371,10 +397,10 @@ fun rememberIsSavedTracks( val result = repository.isSavedTracks(trackIds) state = SavedState.Success(result) }.onFailure { e -> - state = SavedState.Error(e.message ?: "Unknown error") + state = SavedState.Error(e.message ?: getString(Res.string.common_unknown_error)) } } } return state -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt similarity index 76% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt index 5ff73aeb..73c2af08 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchRepository.kt @@ -15,18 +15,18 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.search +package org.spotube.spotube.modules.search -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.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.modules.plugin.PluginManager +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.modules.plugin.PluginManager import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -34,6 +34,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_error_no_metadata_plugin class SearchRepository( private val pluginManager: PluginManager @@ -70,7 +73,7 @@ class SearchRepository( suspend fun loadSupportedSearchTypes(): List { return supportedTypesCache.get(Unit) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.supportedSearchTypes } } @@ -80,7 +83,7 @@ class SearchRepository( suspend fun searchAll(query: String): List { return allSearchCache.get(query) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.search(query) } } @@ -94,7 +97,7 @@ class SearchRepository( val key = query to pagination return trackSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchTracks(query, pagination) } } @@ -108,7 +111,7 @@ class SearchRepository( val key = query to pagination return albumSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchAlbums(query, pagination) } } @@ -122,7 +125,7 @@ class SearchRepository( val key = query to pagination return artistSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchArtists(query, pagination) } } @@ -136,7 +139,7 @@ class SearchRepository( val key = query to pagination return playlistSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchPlaylists(query, pagination) } } @@ -150,7 +153,7 @@ class SearchRepository( val key = query to pagination return userSearchCache.get(key) { val plugin = pluginManager.selectedMetadataPlugin.value - ?: throw IllegalStateException("No metadata plugin selected") + ?: throw IllegalStateException(getString(Res.string.search_error_no_metadata_plugin)) pluginManager.withScope { plugin.use { metadataSearchAPI.searchUsers(query, pagination) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt similarity index 62% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt index 0428244f..fdcdd734 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt @@ -15,7 +15,8 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.search +package org.spotube.spotube.modules.search + import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing @@ -35,25 +36,21 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -64,56 +61,100 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import compose.icons.FeatherIcons -import compose.icons.feathericons.X -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField -import dev.krtirtho.spotube.core.ui.base.ChipTab -import dev.krtirtho.spotube.core.ui.component.AlbumCard -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.ArtistCard -import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.component.PlaylistCard -import dev.krtirtho.spotube.core.ui.component.TrackList -import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction -import dev.krtirtho.spotube.core.ui.component.TrackOptionsState -import dev.krtirtho.spotube.core.ui.component.UserCard -import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import dev.krtirtho.spotube.core.ui.misc.SkeletonTree -import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.InconsaxClock +import com.composeunstyled.Text +import com.composeunstyled.currentWindowWidthBreakpoint import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumMarqueeProgress +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.ui.component.AlbumCard +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.component.ArtistCard +import org.spotube.spotube.core.ui.component.ErrorDisplay +import org.spotube.spotube.core.ui.component.PlaylistCard +import org.spotube.spotube.core.ui.component.TrackList +import org.spotube.spotube.core.ui.component.TrackOptionsAction +import org.spotube.spotube.core.ui.component.TrackOptionsState +import org.spotube.spotube.core.ui.component.UserCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.dragScrollable +import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.getPlatform +import org.spotube.spotube.isDesktop +import org.spotube.spotube.modules.blacklist.BlacklistRepository +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.library.playlist.AddToPlaylistPicker +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.InconsaxClock +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_tab_albums +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.library_tab_playlists +import spotube.composeapp.generated.resources.search_clear_history +import spotube.composeapp.generated.resources.search_loading_albums +import spotube.composeapp.generated.resources.search_loading_artists +import spotube.composeapp.generated.resources.search_loading_playlists +import spotube.composeapp.generated.resources.search_loading_tracks +import spotube.composeapp.generated.resources.search_loading_users +import spotube.composeapp.generated.resources.search_no_albums +import spotube.composeapp.generated.resources.search_no_artists +import spotube.composeapp.generated.resources.search_no_playlists +import spotube.composeapp.generated.resources.search_no_results +import spotube.composeapp.generated.resources.search_no_search_types +import spotube.composeapp.generated.resources.search_no_users +import spotube.composeapp.generated.resources.search_placeholder +import spotube.composeapp.generated.resources.search_results_title +import spotube.composeapp.generated.resources.search_searching +import spotube.composeapp.generated.resources.search_section_tracks +import spotube.composeapp.generated.resources.search_section_users +import spotube.composeapp.generated.resources.search_see_all +import spotube.composeapp.generated.resources.search_start_typing +import spotube.composeapp.generated.resources.search_start_typing_tracks +import spotube.composeapp.generated.resources.search_tab_all +import spotube.composeapp.generated.resources.search_warm_antennas +import spotube.composeapp.generated.resources.search_warm_cosmos +import spotube.composeapp.generated.resources.search_warm_crates +import spotube.composeapp.generated.resources.search_warm_music_gods +import spotube.composeapp.generated.resources.search_warm_records +import spotube.composeapp.generated.resources.search_warm_speakers +import spotube.composeapp.generated.resources.search_warm_vinyl private val GridMinCellSize = 180.dp @@ -121,20 +162,34 @@ private val GridMinCellSize = 180.dp fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val audioPlayerQueue: AudioPlayerQueue = koinInject() val shareService: ShareService = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val searchResultsTitle = stringResource(Res.string.search_results_title) val downloadsViewModel: DownloadsViewModel = koinViewModel() val libraryRepository: LibraryRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject() val navigationCommands: NavigationCommands = koinInject() val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) } var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } - var currentUserId by remember { mutableStateOf(null) } + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() - val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList()) - val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList()) + val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle( + emptyList() + ) + val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle( + emptyList() + ) + + val isExpanded = + currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop() fun isTrackBlacklisted(track: MetadataTrack): Boolean { val trackIds = blacklistedTracks.map { it.id }.toSet() @@ -148,10 +203,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } } - LaunchedEffect(Unit) { - currentUserId = libraryRepository.currentUser()?.id - } - val focusManager = LocalFocusManager.current fun playSingleTrack(track: MetadataTrack) { @@ -160,7 +211,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")), autoPlay = true, startPosition = 0, - collectionEntry = null, + collectionEntry = null ) } } @@ -171,14 +222,31 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent( + listOf( + QueueEntry.StreamingTrack( + track = track, + url = "" + ) + ) + ) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) + } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -197,6 +265,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { shareService.share(uri, track.title) } } + is TrackOptionsAction.AddToPlaylist -> { tracksToAddToPlaylist = listOf(track) showAddToPlaylistPicker = true @@ -216,7 +285,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track), + isBlacklisted = isTrackBlacklisted(track) ) } @@ -232,75 +301,74 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track), + isBlacklisted = isTrackBlacklisted(track) ) } fun bulkAddToQueue(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, searchResultsTitle) + } + + fun bulkAddToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) } fun bulkPlayNext(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, searchResultsTitle) } - Scaffold( - topBar = { ApplicationMainBar(backButton = false) } - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + .windowInsetsPadding(WindowInsets.statusBars) + ) { + if (!isExpanded) { + SearchBar( + query = state.query, + onQueryChange = viewModel::onQueryChange, + recentSearchSuggestions = state.recentSearchSuggestions, + onRecentSearchClick = { search -> + viewModel.applyRecentSearch(search) + focusManager.clearFocus() + }, + onClearAllRecentSearches = { + viewModel.clearAllRecentSearches() + focusManager.clearFocus() + }, + ) + } else { + ApplicationMainBar(backButton = false) + } Column( modifier = Modifier - .padding(innerPadding) - .fillMaxSize() + .fillMaxWidth() + .weight(1f) ) { Column( modifier = Modifier.fillMaxWidth() ) { - SearchBar( - query = state.query, - onQueryChange = viewModel::onQueryChange, - onClear = viewModel::clearQuery, - onSearch = { - focusManager.clearFocus() - }, - recentSearches = state.recentSearches, - onRecentSearchClick = { search -> - viewModel.applyRecentSearch(search) - focusManager.clearFocus() - }, - onRecentSearchRemove = viewModel::removeRecentSearch, - onClearAllRecentSearches = { - viewModel.clearAllRecentSearches() - focusManager.clearFocus() - }, - ) + if (isExpanded) { + SearchBar( + query = state.query, + onQueryChange = viewModel::onQueryChange, + recentSearchSuggestions = state.recentSearchSuggestions, + onRecentSearchClick = { search -> + viewModel.applyRecentSearch(search) + focusManager.clearFocus() + }, + onClearAllRecentSearches = { + viewModel.clearAllRecentSearches() + focusManager.clearFocus() + } + ) + } if (state.supportedSearchTypes.isNotEmpty()) { SearchTabs( types = state.supportedSearchTypes, selectedType = selectedType, - onTabSelected = viewModel::onTabSelected, + onTabSelected = viewModel::onTabSelected ) } } @@ -309,15 +377,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { state.isLoadingSearchTypes -> { SearchLoadingIndicator( modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } selectedType == null -> { SearchMessage( - message = "No search types available", + message = stringResource(Res.string.search_no_search_types), modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } @@ -341,6 +409,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -351,7 +422,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } @@ -370,6 +441,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -380,7 +454,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } @@ -390,7 +464,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { playlists = state.playlists, onLoadNextPage = viewModel::loadNextPlaylists, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } @@ -400,7 +474,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { albums = state.albums, onLoadNextPage = viewModel::loadNextAlbums, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } @@ -410,7 +484,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { artists = state.artists, onLoadNextPage = viewModel::loadNextArtists, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } @@ -420,7 +494,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { users = state.users, onLoadNextPage = viewModel::loadNextUsers, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() ) } } @@ -437,196 +511,101 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist.map { it.id } ) } - }, - ) + }) } } -private sealed interface SearchDropdownEntry { - val onClick: () -> Unit - - data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry - data class RecentSearch( - val query: String, - override val onClick: () -> Unit, - val onRemove: () -> Unit, - ) : SearchDropdownEntry -} - @Composable private fun SearchBar( query: String, onQueryChange: (String) -> Unit, - onClear: () -> Unit, - onSearch: () -> Unit, - recentSearches: List, + recentSearchSuggestions: List, onRecentSearchClick: (String) -> Unit, - onRecentSearchRemove: (String) -> Unit, - onClearAllRecentSearches: () -> Unit, + onClearAllRecentSearches: () -> Unit ) { - val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty() - val dropdownItems = remember(showRecentSearches, recentSearches) { - buildList { - if (showRecentSearches) { - add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches)) - recentSearches.forEach { search -> - add( - SearchDropdownEntry.RecentSearch( - query = search, - onClick = { onRecentSearchClick(search) }, - onRemove = { onRecentSearchRemove(search) }, - ) - ) - } - } + val hasRecentSearchSuggestions = recentSearchSuggestions.isNotEmpty() + val searchState = rememberTextFieldState(query) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() }.collect { onQueryChange(it) } + } + LaunchedEffect(query) { + if (searchState.text.toString() != query) { + searchState.edit { replace(0, length, query) } } } - Box( + MillenniumAutocomplete( + state = searchState, + suggestions = recentSearchSuggestions, + onSuggestionSelected = onRecentSearchClick, + suggestionLabel = { it }, + leadingContent = { + MillenniumIcon( + imageVector = Iconsax.InconsaxClock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + }, + placeholder = stringResource(Res.string.search_placeholder), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - ) { - AutocompleteTextField( - value = query, - onValueChange = onQueryChange, - items = dropdownItems, - onItemSelected = { entry -> entry.onClick() }, - placeholder = { - Text( - "Search songs, artists, albums...", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = { - if (query.isNotBlank()) { - IconButton( - onClick = onClear, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = FeatherIcons.X, - contentDescription = "Clear", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + .padding(bottom = 8.dp), + menuHeader = if (hasRecentSearchSuggestions) { + { + MillenniumListTile( + title = { + MillenniumText( + text = stringResource(Res.string.search_clear_history), + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium ) - } - } - }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), - itemContent = { entry, isSelected -> - when (entry) { - is SearchDropdownEntry.ClearAll -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else Color.Transparent - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp) - ) - Text( - "Clear all history", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - is SearchDropdownEntry.RecentSearch -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else Color.Transparent - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.InconsaxClock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - entry.query, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = entry.onRemove, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = FeatherIcons.X, - contentDescription = "Remove", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } - } - } - } - }, - ) - } + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.error + ) + }, + onClick = onClearAllRecentSearches, + minimumHeight = 36.dp, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + leadingSpacing = 8.dp, + shape = AppTheme.shapes.small + ) + } + } else { + null + } + ) } @Composable private fun SearchTabs( types: List, selectedType: MetadataSupportedSearchType?, - onTabSelected: (MetadataSupportedSearchType) -> Unit, + onTabSelected: (MetadataSupportedSearchType) -> Unit ) { + val listState = rememberLazyListState() LazyRow( - modifier = Modifier.fillMaxWidth(), + state = listState, + modifier = Modifier.fillMaxWidth().dragScrollable(listState), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { items(types) { type -> val isSelected = selectedType == type - ChipTab( - selected = isSelected, - onClick = { onTabSelected(type) } + MillenniumButton( + onClick = { onTabSelected(type) }, + style = if (isSelected) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + } ) { Text(type.tabTitle()) } @@ -651,13 +630,16 @@ private fun SearchAllTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { if (query.isBlank()) { - SearchMessage(message = "Start typing to search", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_start_typing), modifier = modifier) return } @@ -679,13 +661,13 @@ private fun SearchAllTab( ErrorDisplay( errorMessage = hasAnyError, onRetry = { }, - modifier = modifier, + modifier = modifier ) return } if (!hasAnyContent && !isLoadingAll) { - SearchMessage(message = "No results found", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_no_results), modifier = modifier) return } @@ -708,6 +690,9 @@ private fun SearchAllTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, @@ -715,10 +700,10 @@ private fun SearchAllTab( headerContent = { if (showTracks) { Text( - text = "Tracks", - style = MaterialTheme.typography.titleLarge, + text = stringResource(Res.string.search_section_tracks), + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp), + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp) ) } if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) { @@ -737,9 +722,8 @@ private fun SearchAllTab( ) { if (MetadataSupportedSearchType.PLAYLIST in supportedSearchTypes && playlists.items.isNotEmpty()) { SearchHorizontalSection( - title = "Playlists", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }, - ) { + title = stringResource(Res.string.library_tab_playlists), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }) { playlists.items.forEach { playlist -> item(key = playlist.id) { PlaylistCard(playlist = playlist) } } @@ -748,9 +732,8 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ALBUM in supportedSearchTypes && albums.items.isNotEmpty()) { SearchHorizontalSection( - title = "Albums", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }, - ) { + title = stringResource(Res.string.library_tab_albums), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }) { albums.items.forEach { album -> item(key = album.id) { AlbumCard(album = album) } } @@ -759,9 +742,8 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ARTIST in supportedSearchTypes && artists.items.isNotEmpty()) { SearchHorizontalSection( - title = "Artists", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }, - ) { + title = stringResource(Res.string.library_tab_artists), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }) { artists.items.forEach { artist -> item(key = artist.id) { ArtistCard(artist = artist) } } @@ -770,9 +752,8 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.USER in supportedSearchTypes && users.items.isNotEmpty()) { SearchHorizontalSection( - title = "Users", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }, - ) { + title = stringResource(Res.string.search_section_users), + onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }) { users.items.forEach { user -> item(key = user.id) { UserCard(user = user) } } @@ -795,18 +776,21 @@ private fun SearchTracksTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { if (query.isBlank()) { - SearchMessage(message = "Start typing to search tracks", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_start_typing_tracks), modifier = modifier) return } if (tracks.isLoading && tracks.items.isEmpty()) { - SearchLoadingIndicator(message = "Loading tracks...", modifier = modifier) + SearchLoadingIndicator(message = stringResource(Res.string.search_loading_tracks), modifier = modifier) return } @@ -824,11 +808,14 @@ private fun SearchTracksTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, simplified = true, - modifier = modifier, + modifier = modifier ) } @@ -837,7 +824,7 @@ private fun SearchAlbumsTab( query: String, albums: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { SearchGridTab( query = query, @@ -846,9 +833,9 @@ private fun SearchAlbumsTab( error = albums.error, hasNextPage = albums.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No albums found", - loadingMessage = "Loading albums...", - modifier = modifier, + emptyMessage = stringResource(Res.string.search_no_albums), + loadingMessage = stringResource(Res.string.search_loading_albums), + modifier = modifier ) { album -> AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) } @@ -859,7 +846,7 @@ private fun SearchArtistsTab( query: String, artists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { SearchGridTab( query = query, @@ -868,9 +855,9 @@ private fun SearchArtistsTab( error = artists.error, hasNextPage = artists.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No artists found", - loadingMessage = "Loading artists...", - modifier = modifier, + emptyMessage = stringResource(Res.string.search_no_artists), + loadingMessage = stringResource(Res.string.search_loading_artists), + modifier = modifier ) { artist -> ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) } @@ -881,7 +868,7 @@ private fun SearchPlaylistsTab( query: String, playlists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { SearchGridTab( query = query, @@ -890,9 +877,9 @@ private fun SearchPlaylistsTab( error = playlists.error, hasNextPage = playlists.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No playlists found", - loadingMessage = "Loading playlists...", - modifier = modifier, + emptyMessage = stringResource(Res.string.search_no_playlists), + loadingMessage = stringResource(Res.string.search_loading_playlists), + modifier = modifier ) { playlist -> PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) } @@ -903,7 +890,7 @@ private fun SearchUsersTab( query: String, users: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { SearchGridTab( query = query, @@ -912,9 +899,9 @@ private fun SearchUsersTab( error = users.error, hasNextPage = users.hasNextPage, onLoadNextPage = onLoadNextPage, - emptyMessage = "No users found", - loadingMessage = "Loading users...", - modifier = modifier, + emptyMessage = stringResource(Res.string.search_no_users), + loadingMessage = stringResource(Res.string.search_loading_users), + modifier = modifier ) { user -> UserCard(user = user, modifier = Modifier.fillMaxWidth()) } @@ -932,10 +919,10 @@ private fun SearchGridTab( emptyMessage: String, loadingMessage: String, modifier: Modifier = Modifier, - itemContent: @Composable (T) -> Unit, + itemContent: @Composable (T) -> Unit ) { if (query.isBlank()) { - SearchMessage(message = "Start typing to search", modifier = modifier) + SearchMessage(message = stringResource(Res.string.search_start_typing), modifier = modifier) return } @@ -960,7 +947,7 @@ private fun SearchGridTab( isLoading && items.isEmpty() -> { SearchLoadingIndicator( message = loadingMessage, - modifier = modifier, + modifier = modifier ) } @@ -968,7 +955,7 @@ private fun SearchGridTab( ErrorDisplay( errorMessage = error, onRetry = onLoadNextPage, - modifier = modifier, + modifier = modifier ) } @@ -983,7 +970,7 @@ private fun SearchGridTab( modifier = modifier.padding(horizontal = 12.dp), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(items) { item -> itemContent(item) @@ -995,7 +982,7 @@ private fun SearchGridTab( PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", - imageURL = "https://placehold.co/600x400", + imageURL = "https://placehold.co/600x400" ) } } @@ -1009,37 +996,37 @@ private fun SearchGridTab( private fun SearchHorizontalSection( title: String, onSeeAll: () -> Unit, - content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit ) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold ) Text( - text = "See all", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable(onClick = onSeeAll), + text = stringResource(Res.string.search_see_all), + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.primary, + modifier = Modifier.clickable(onClick = onSeeAll) ) } LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - content = content, + content = content ) } } @@ -1048,63 +1035,63 @@ private fun SearchHorizontalSection( private fun SearchTrackRow( index: Int, track: MetadataTrack, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { Row( modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Text( text = (index + 1).toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.End ) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = track.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, + style = AppTheme.typography.bodyLarge, + maxLines = 1 ) Text( text = track.artists.joinToString { it.name }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1 ) } if (track.album != null) Text( text = track.album!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) } } -private val WarmSearchMessages = listOf( - "Digging through the crates...", - "Searching the cosmos...", - "Warming up the speakers...", - "Dusting off the vinyl...", - "Consulting the music gods...", - "Tuning the antennas...", - "Flipping through the records...", +private val WarmSearchMessages: List = listOf( + Res.string.search_warm_crates, + Res.string.search_warm_cosmos, + Res.string.search_warm_speakers, + Res.string.search_warm_vinyl, + Res.string.search_warm_music_gods, + Res.string.search_warm_antennas, + Res.string.search_warm_records ) @Composable private fun SearchLoadingIndicator( message: String? = null, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { val infiniteTransition = rememberInfiniteTransition(label = "search-loading") val rotation by infiniteTransition.animateFloat( @@ -1112,18 +1099,18 @@ private fun SearchLoadingIndicator( targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, + repeatMode = RepeatMode.Restart ), - label = "icon-rotation", + label = "icon-rotation" ) val bounce by infiniteTransition.animateFloat( initialValue = -8f, targetValue = 8f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 800, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, + repeatMode = RepeatMode.Reverse ), - label = "icon-bounce", + label = "icon-bounce" ) val messageIndex by infiniteTransition.animateFloat( initialValue = 0f, @@ -1133,51 +1120,50 @@ private fun SearchLoadingIndicator( durationMillis = WarmSearchMessages.size * 2000, easing = LinearEasing ), - repeatMode = RepeatMode.Restart, + repeatMode = RepeatMode.Restart ), - label = "message-cycle", + label = "message-cycle" ) val displayMessage = - message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size] + message ?: stringResource(WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size]) Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center ) { Box( modifier = Modifier.size(64.dp), - contentAlignment = Alignment.Center, + contentAlignment = Alignment.Center ) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Searching", - tint = MaterialTheme.colorScheme.primary, + contentDescription = stringResource(Res.string.search_searching), + tint = AppTheme.colors.primary, modifier = Modifier .size(48.dp) .graphicsLayer { rotationZ = rotation translationY = bounce - }, - ) + }) } - LinearProgressIndicator( + MillenniumMarqueeProgress( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp), + .padding(horizontal = 32.dp, vertical = 16.dp) ) AnimatedContent( targetState = displayMessage, transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }, - label = "message-transition", + label = "message-transition" ) { msg -> Text( text = msg, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + textAlign = TextAlign.Center ) } } @@ -1187,18 +1173,18 @@ private fun SearchLoadingIndicator( private fun SearchMessage( message: String, modifier: Modifier = Modifier, - isError: Boolean = false, + isError: Boolean = false ) { Box( modifier = modifier, - contentAlignment = Alignment.Center, + contentAlignment = Alignment.Center ) { Text( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, textAlign = TextAlign.Center, - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(16.dp) ) } } @@ -1206,24 +1192,24 @@ private fun SearchMessage( @Composable private fun SearchMessageInline( message: String, - isError: Boolean = false, + isError: Boolean = false ) { Text( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) ) } @Composable private fun MetadataSupportedSearchType.tabTitle(): String = when (this) { - MetadataSupportedSearchType.ALL -> "All" - MetadataSupportedSearchType.TRACK -> "Tracks" - MetadataSupportedSearchType.PLAYLIST -> "Playlists" - MetadataSupportedSearchType.ALBUM -> "Albums" - MetadataSupportedSearchType.ARTIST -> "Artists" - MetadataSupportedSearchType.USER -> "Users" + MetadataSupportedSearchType.ALL -> stringResource(Res.string.search_tab_all) + MetadataSupportedSearchType.TRACK -> stringResource(Res.string.search_section_tracks) + MetadataSupportedSearchType.PLAYLIST -> stringResource(Res.string.library_tab_playlists) + MetadataSupportedSearchType.ALBUM -> stringResource(Res.string.library_tab_albums) + MetadataSupportedSearchType.ARTIST -> stringResource(Res.string.library_tab_artists) + MetadataSupportedSearchType.USER -> stringResource(Res.string.search_section_users) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt index 5f233b9a..2b3d5c42 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt @@ -15,22 +15,25 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.search +package org.spotube.spotube.modules.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -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.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent +import org.spotube.spotube.modules.library.LibraryRepository +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope @@ -41,14 +44,22 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import org.spotube.spotube.core.i18n.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.search_error_capabilities +import spotube.composeapp.generated.resources.search_error_search +import spotube.composeapp.generated.resources.search_error_tracks +import spotube.composeapp.generated.resources.search_error_albums +import spotube.composeapp.generated.resources.search_error_artists +import spotube.composeapp.generated.resources.search_error_playlists +import spotube.composeapp.generated.resources.search_error_users data class SearchPagedState( val items: List = emptyList(), val nextPagination: PaginationStrategy? = null, val hasNextPage: Boolean = true, val isLoading: Boolean = false, - val error: String? = null, -) + val error: String? = null) data class SearchScreenState( val query: String = "", @@ -60,15 +71,21 @@ data class SearchScreenState( val albums: SearchPagedState = SearchPagedState(), val artists: SearchPagedState = SearchPagedState(), val playlists: SearchPagedState = SearchPagedState(), - val users: SearchPagedState = SearchPagedState(), -) + val users: SearchPagedState = SearchPagedState()) { + val recentSearchSuggestions: List + get() = if (query.isBlank()) { + recentSearches + } else { + recentSearches.filter { it.contains(query, ignoreCase = true) } + } +} @OptIn(ExperimentalCoroutinesApi::class) class SearchScreenViewModel( private val repository: SearchRepository, private val savedTracksRepository: SavedTracksRepository, - private val database: Database, -) : ViewModel() { + private val libraryRepository: LibraryRepository, + private val database: Database) : ViewModel(), KoinComponent { companion object { private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches") private val SEARCH_TYPES_ORDER = listOf( @@ -77,19 +94,23 @@ class SearchScreenViewModel( MetadataSupportedSearchType.PLAYLIST, MetadataSupportedSearchType.ALBUM, MetadataSupportedSearchType.ARTIST, - MetadataSupportedSearchType.USER, - ) + MetadataSupportedSearchType.USER) private const val QUERY_DEBOUNCE_MS = 350L private const val RECENT_SEARCHES_LIMIT = 12 private const val TRACK_PAGE_SIZE = 20 private const val ALL_ROW_PAGE_SIZE = 12 } + private val logger by injectLogger() + private val json = Json { ignoreUnknownKeys = true } private val _state = MutableStateFlow(SearchScreenState()) val state: StateFlow = _state.asStateFlow() + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + private var queryJob: Job? = null init { @@ -101,6 +122,15 @@ class SearchScreenViewModel( } loadSupportedSearchTypes() + viewModelScope.launch { loadCurrentUser() } + } + + private suspend fun loadCurrentUser() { + runCatching { + _currentUserId.value = libraryRepository.currentUser()?.id + }.onFailure { e -> + logger.e(e) { "Failed to load current user" } + } } fun onQueryChange(query: String) { @@ -130,8 +160,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) } fun clearAllRecentSearches() { @@ -157,8 +186,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) if (_state.value.query.isNotBlank()) { viewModelScope.launch { refreshCurrentSelection(reset = true) @@ -232,8 +260,7 @@ class SearchScreenViewModel( _state.value = _state.value.copy( supportedSearchTypes = ordered, selectedSearchType = selectedType, - isLoadingSearchTypes = false, - ) + isLoadingSearchTypes = false) if (_state.value.query.isNotBlank()) { refreshCurrentSelection(reset = true) @@ -243,8 +270,7 @@ class SearchScreenViewModel( isLoadingSearchTypes = false, supportedSearchTypes = emptyList(), selectedSearchType = null, - tracks = SearchPagedState(error = it.message ?: "Failed to load search capabilities"), - ) + tracks = SearchPagedState(error = it.message ?: getString(Res.string.search_error_capabilities))) } } } @@ -257,8 +283,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) return } @@ -284,8 +309,7 @@ class SearchScreenViewModel( albums = SearchPagedState(isLoading = true), artists = SearchPagedState(isLoading = true), playlists = SearchPagedState(isLoading = true), - users = SearchPagedState(isLoading = true), - ) + users = SearchPagedState(isLoading = true)) if (MetadataSupportedSearchType.ALL in supported) { runCatching { @@ -305,41 +329,35 @@ class SearchScreenViewModel( items = tracks.take(TRACK_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), playlists = SearchPagedState( items = playlists.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), albums = SearchPagedState( items = albums.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), artists = SearchPagedState( items = artists.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), users = SearchPagedState( items = users.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), - ) + isLoading = false)) }.onFailure { + val message = it.message ?: getString(Res.string.search_error_search) _state.value = _state.value.copy( - tracks = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - playlists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - albums = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - artists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - users = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - ) + tracks = SearchPagedState(isLoading = false, error = message), + playlists = SearchPagedState(isLoading = false, error = message), + albums = SearchPagedState(isLoading = false, error = message), + artists = SearchPagedState(isLoading = false, error = message), + users = SearchPagedState(isLoading = false, error = message)) } return@coroutineScope } @@ -356,8 +374,7 @@ class SearchScreenViewModel( playlists = _state.value.playlists.copy(items = _state.value.playlists.items.take(ALL_ROW_PAGE_SIZE)), albums = _state.value.albums.copy(items = _state.value.albums.items.take(ALL_ROW_PAGE_SIZE)), artists = _state.value.artists.copy(items = _state.value.artists.items.take(ALL_ROW_PAGE_SIZE)), - users = _state.value.users.copy(items = _state.value.users.items.take(ALL_ROW_PAGE_SIZE)), - ) + users = _state.value.users.copy(items = _state.value.users.items.take(ALL_ROW_PAGE_SIZE))) } private suspend fun loadTracks(reset: Boolean) { @@ -390,16 +407,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( tracks = _state.value.tracks.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search tracks", - ) + error = throwable.message ?: getString(Res.string.search_error_tracks)) ) } } @@ -432,16 +447,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( albums = _state.value.albums.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search albums", - ) + error = throwable.message ?: getString(Res.string.search_error_albums)) ) } } @@ -474,16 +487,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( artists = _state.value.artists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search artists", - ) + error = throwable.message ?: getString(Res.string.search_error_artists)) ) } } @@ -516,16 +527,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( playlists = _state.value.playlists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search playlists", - ) + error = throwable.message ?: getString(Res.string.search_error_playlists)) ) } } @@ -558,16 +567,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( users = _state.value.users.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search users", - ) + error = throwable.message ?: getString(Res.string.search_error_users)) ) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt new file mode 100644 index 00000000..295b7536 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt @@ -0,0 +1,153 @@ +package org.spotube.spotube.modules.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.modules.settings.sections.JamNameSettingCard +import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.settingsSectionCard +import org.spotube.spotube.modules.settings.sections.settingsSectionHeader +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_error_connection_failed +import spotube.composeapp.generated.resources.settings_jam_section_connection +import spotube.composeapp.generated.resources.settings_jam_section_diagnostics +import spotube.composeapp.generated.resources.settings_jam_section_you +import spotube.composeapp.generated.resources.settings_jam_server_diagnostics_subtitle +import spotube.composeapp.generated.resources.settings_jam_server_note +import spotube.composeapp.generated.resources.settings_jam_server_test +import spotube.composeapp.generated.resources.settings_jam_server_test_fail +import spotube.composeapp.generated.resources.settings_jam_server_testing +import spotube.composeapp.generated.resources.settings_jam_server_title +import spotube.composeapp.generated.resources.settings_jam_server_url +import spotube.composeapp.generated.resources.settings_jam_server_url_invalid +import spotube.composeapp.generated.resources.settings_section_jam + +@Composable +fun JamSettingsScreen( + viewModel: JamSettingsViewModel = koinViewModel()) { + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { Text(stringResource(Res.string.settings_section_jam)) }) + JamSettingsContent( + viewModel = viewModel, + modifier = Modifier.weight(1f)) + } +} + +/** + * The Group Jam settings UI without its own app bar, so it can be embedded + * as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen]. + */ +@Composable +internal fun JamSettingsContent( + viewModel: JamSettingsViewModel = koinViewModel(), + modifier: Modifier = Modifier) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + Box( + modifier = modifier + .fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding) { + settingsSectionHeader(Res.string.settings_jam_section_you) + settingsSectionCard( + items = listOf( + { + JamNameSettingCard( + customName = uiState.settings.jamParticipantName, + defaultName = uiState.defaultName, + onNameSaved = viewModel::setParticipantName) + })) + settingsSectionHeader(Res.string.settings_jam_section_connection) + settingsSectionCard( + items = listOf( + { + val invalidUrl = stringResource(Res.string.settings_jam_server_url_invalid) + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_server_url), + value = uiState.settings.jamServerUrl, + placeholder = DEFAULT_JAM_SERVER_URL, + validate = { value -> + if (value.isBlank() || isJamServerUrl(value)) null else invalidUrl + }, + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_server_title)) + }, + onValueSaved = viewModel::setServerUrl) + })) + settingsSectionHeader(Res.string.settings_jam_section_diagnostics) + settingsSectionCard( + items = listOf( + { + val resultMessage = uiState.testResult?.fold( + onSuccess = { it }, + onFailure = { + stringResource( + Res.string.settings_jam_server_test_fail, + it.message ?: stringResource(Res.string.jam_error_connection_failed)) + }) + + SettingCardItem( + enabled = !uiState.isTesting, + title = stringResource(Res.string.settings_jam_server_test), + subtitle = when { + uiState.isTesting -> stringResource(Res.string.settings_jam_server_testing) + resultMessage != null -> resultMessage + else -> stringResource(Res.string.settings_jam_server_diagnostics_subtitle) + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxInformation, + stringResource(Res.string.settings_jam_server_test)) + }, + trailingContent = { + MillenniumButton( + onClick = viewModel::testConnection, + loading = uiState.isTesting) { + Text(stringResource(Res.string.settings_jam_server_test)) + } + }, + onClick = viewModel::testConnection) + })) + item { + Text( + text = stringResource(Res.string.settings_jam_server_note), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt new file mode 100644 index 00000000..ea58bd38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt @@ -0,0 +1,68 @@ +package org.spotube.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.jam.JamRoomClient +import org.spotube.spotube.core.jam.JamRoomService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class JamSettingsUiState( + val settings: UserSettings = UserSettings(), + val isTesting: Boolean = false, + val testResult: Result? = null, + /** This device's generated jam name, used while the user hasn't picked one. */ + val defaultName: String = "") + +class JamSettingsViewModel( + private val settingsRepository: SettingsRepository, + private val jamRoomClient: JamRoomClient, + private val jamRoomService: JamRoomService) : ViewModel() { + private val _uiState = MutableStateFlow(JamSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val name = jamRoomService.defaultParticipantName() + _uiState.update { it.copy(defaultName = name) } + } + viewModelScope.launch { + settingsRepository.userSettings.collect { settings -> + _uiState.update { it.copy(settings = settings) } + } + } + } + + fun setParticipantName(name: String) { + viewModelScope.launch { jamRoomService.setParticipantName(name) } + } + + /** A blank URL goes back to the public server. */ + fun setServerUrl(url: String) { + viewModelScope.launch { + val serverUrl = url.trim().trimEnd('/').ifBlank { DEFAULT_JAM_SERVER_URL } + settingsRepository.updateSettings(uiState.value.settings.copy(jamServerUrl = serverUrl)) + _uiState.update { it.copy(testResult = null) } + } + } + + fun testConnection() { + if (_uiState.value.isTesting) return + _uiState.update { it.copy(isTesting = true, testResult = null) } + viewModelScope.launch { + try { + val result = jamRoomClient.testConnection(uiState.value.settings.jamServerUrl) + _uiState.update { it.copy(testResult = result) } + } finally { + _uiState.update { it.copy(isTesting = false) } + } + } + } + + fun clearTestResult() { + _uiState.update { it.copy(testResult = null) } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt new file mode 100644 index 00000000..3a0b58b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersViewModel.kt @@ -0,0 +1,128 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.* + +data class LocalMediaFolderRowUi(val path: String, val name: String) + +data class LocalMediaFoldersUiState( + val isOpen: Boolean = false, + val configuredCount: Int = 0, + val singleConfiguredPath: String? = null, + val folders: List = emptyList(), + val isSaving: Boolean = false, + val canSave: Boolean = false, + val saveFailed: Boolean = false, + val automaticNote: StringResource? = null +) + +private data class FolderDraft( + val isOpen: Boolean = false, + val original: List = emptyList(), + val paths: List = emptyList(), + val isSaving: Boolean = false, + val saveFailed: Boolean = false +) + +/** The editor keeps a snapshot until Save; incoming settings updates never reset a draft. */ +class LocalMediaFoldersViewModel( + private val settings: StateFlow, + private val persistFolders: suspend (List) -> Unit +) : ViewModel() { + private val draft = MutableStateFlow(FolderDraft()) + + val uiState = combine(settings, draft, ::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, toUiState(settings.value, draft.value)) + + fun open() { + if (draft.value.isOpen || draft.value.isSaving) return + val paths = normalizedFolders(settings.value.localMediaFolders) + draft.value = FolderDraft(isOpen = true, original = paths, paths = paths) + } + + fun addFolder(path: String?) { + val normalized = normalizeMediaFolderPath(path.orEmpty()) + if (normalized.isBlank()) return + draft.update { current -> + if (!current.isOpen || current.isSaving || normalized in current.paths) current + else current.copy(paths = current.paths + normalized, saveFailed = false) + } + } + + fun removeFolder(path: String) { + draft.update { current -> + if (!current.isOpen || current.isSaving) current + else current.copy(paths = current.paths.filterNot { it == path }, saveFailed = false) + } + } + + fun dismiss() { + if (!draft.value.isSaving) draft.value = FolderDraft() + } + + fun save() { + val current = draft.value + if (!current.isOpen || current.isSaving || current.paths == current.original) return + val paths = current.paths.toList() + draft.value = current.copy(isSaving = true, saveFailed = false) + viewModelScope.launch { + try { + persistFolders(paths) + draft.value = FolderDraft() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + draft.update { it.copy(isSaving = false, saveFailed = true) } + } + } + } + + private fun toUiState(settings: UserSettings, draft: FolderDraft): LocalMediaFoldersUiState { + val configured = normalizedFolders(settings.localMediaFolders) + val hasDownloadFolder = !settings.overloadedDownloadFolder.isNullOrBlank() + return LocalMediaFoldersUiState( + isOpen = draft.isOpen, + configuredCount = configured.size, + singleConfiguredPath = configured.singleOrNull(), + folders = draft.paths.map { path -> + LocalMediaFolderRowUi( + path, + path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path }) + }, + isSaving = draft.isSaving, + canSave = draft.isOpen && draft.paths != draft.original, + saveFailed = draft.saveFailed, + automaticNote = when { + hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both + hasDownloadFolder -> Res.string.settings_local_media_folders_automatic_downloads + settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_cache + else -> null + } + ) + } +} + +private fun normalizedFolders(paths: List) = + paths.map(::normalizeMediaFolderPath).filter { it.isNotBlank() }.distinct() + +internal fun normalizeMediaFolderPath(path: String): String { + val trimmed = path.trim() + if (trimmed == "/" || trimmed == "\\") return trimmed + if (trimmed.length >= 3 && trimmed[0].isLetter() && trimmed[1] == ':' && + trimmed.substring(2).all { it == '/' || it == '\\' } + ) return trimmed.take(3) + return trimmed.trimEnd('/', '\\') +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt similarity index 86% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt index 2c1d3d3b..f7c2cd50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsConstants.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.compose.ui.graphics.Color import kotlinx.serialization.Serializable @@ -279,20 +279,50 @@ enum class CountryCode(val code: String, val displayName: String) { } } +/** + * App UI languages. Each one (except [SYSTEM]) must have a matching + * `composeResources/values-/strings.xml`. [displayName] is the language's own name + * (endonym), so it is intentionally not localized. + */ @Serializable enum class SupportedLanguages( val locale: String, val country: String, - val displayName: String + val displayName: String, + val isRtl: Boolean = false ) { - EN("en", "US", "English"), - BN("bn", "BD", "Bengali"); + /** Follow the device/OS language. */ + SYSTEM("", "", ""), + EN("en", "", "English"), + AR("ar", "", "العربية", isRtl = true), + BN("bn", "", "বাংলা"), + DE("de", "", "Deutsch"), + ES("es", "", "Español"), + FA("fa", "", "فارسی", isRtl = true), + FR("fr", "", "Français"), + HI("hi", "", "हिन्दी"), + ID("id", "", "Bahasa Indonesia"), + IT("it", "", "Italiano"), + JA("ja", "", "日本語"), + KO("ko", "", "한국어"), + NL("nl", "", "Nederlands"), + PL("pl", "", "Polski"), + PT_BR("pt", "BR", "Português (Brasil)"), + RU("ru", "", "Русский"), + TH("th", "", "ไทย"), + TR("tr", "", "Türkçe"), + UK("uk", "", "Українська"), + VI("vi", "", "Tiếng Việt"), + ZH_CN("zh", "CN", "简体中文"), + ZH_TW("zh", "TW", "繁體中文"); - companion object { - fun fromLocale(locale: String): SupportedLanguages? { - return entries.find { it.locale == locale } + /** BCP 47 tag (e.g. "zh-TW"), or null for [SYSTEM]. */ + val languageTag: String? + get() = when { + this == SYSTEM -> null + country.isEmpty() -> locale + else -> "$locale-$country" } - } } @Serializable diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt new file mode 100644 index 00000000..fe66b4d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -0,0 +1,123 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings + +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform +import org.spotube.spotube.PlatformType +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.spotube.millennium.theme.MillenniumPalette + +@Serializable +enum class Theme { + LIGHT, DARK, SYSTEM +} + +@Serializable +data class UserSettings( + // Language and Region + // Stored under a new name: the old "language" field was persisted as EN for everyone while + // it had no effect, so reading it would force English on existing users. + @SerialName("appLanguage") + val language: SupportedLanguages = SupportedLanguages.SYSTEM, + val country: CountryCode = CountryCode.BD, + + // Appearance + val theme: Theme = Theme.SYSTEM, + val palette: MillenniumPalette = MillenniumPalette.Blue, + + // Playback + val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(), + val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), + val enableMusicCaching: Boolean = true, + val cacheFolder: String? = null, + val cacheSizeLimitMB: Long = -1L, + val enableEndlessPlayback: Boolean = true, + val playbackProxyServerPort: Int = 14769, + + // Remote Control (LAN) + val allowRemoteControl: Boolean = false, + val allowedRemoteDevices: List = emptyList(), + val remoteControlDeviceName: String = "", + val remoteControlDeviceId: String = "", + + // Group Jam + val jamParticipantName: String = "", + /** Room server (https://github.com/team-spotube/jam-server); self-hosters point this at their own deployment. */ + val jamServerUrl: String = DEFAULT_JAM_SERVER_URL, + val lastJamCode: String = "", + /** Random per-install secret behind this device's jam identities (see `jamParticipantKey`). */ + val jamDeviceKey: String = "", + + // Downloads + val overloadedDownloadFolder: String? = null, // When null, uses default music folder + val localMediaFolders: List = emptyList(), + val downloadMusicFormat: AudioFormat = AudioFormat( + codec = "aac", + container = "mp4", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000)) + ), + val downloadMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), + + // Desktop + val minimizeToTray: Boolean = false, + val discordRichPresence: Boolean = true, + + // Updates + val autoCheckForUpdates: Boolean = true, + val ignoredAppUpdateVersion: String? = null, + val ignoredPluginUpdateVersions: Map = emptyMap(), + + // Support + val supportRemindersEnabled: Boolean = true, + val lastSupportReminderEpochMs: Long = 0L) + +/** The public Group Jam room server. */ +const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev" + +/** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */ +internal fun isJamServerUrl(url: String): Boolean = + Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim()) + +/** + * opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC. + * TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend). + */ +internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { + val aac = !isContainerPlayableOn("webm", platform) + return AudioFormat( + codec = if (aac) "aac" else "opus", + container = if (aac) "mp4" else "webm", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000))) +} + +/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */ +internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings = + if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this + else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt new file mode 100644 index 00000000..0c4f62c1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -0,0 +1,83 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import org.spotube.spotube.core.db.Database +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.* +import kotlinx.serialization.json.Json + +/** Narrow read-only view over the current [UserSettings], for consumers (like + * [org.spotube.spotube.core.server.StreamingUrlRepository]) that only need to read the + * user's streaming preferences without depending on the full [SettingsRepository]. */ +interface UserSettingsSource { + val userSettings: StateFlow +} + +/** [UserSettingsSource] that can also save, for consumers that update a setting or two. */ +interface UserSettingsStore : UserSettingsSource { + suspend fun updateSettings(newSettings: UserSettings) +} + +class SettingsRepository(private val database: Database) : UserSettingsStore { + companion object { + private val SETTINGS_KEY = stringPreferencesKey("user_settings") + + /** + * Lenient so settings written by older builds (which stored `accentColor` / + * `surfaceTheme` instead of the Millennium `palette`) still decode, and so a + * missing field falls back to its default. + */ + private val settingsJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + } + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> + val json = prefs[SETTINGS_KEY] + if (json != null) { + settingsJson.decodeFromString(json as String).withPlayableStreamingFormat() + } else { + UserSettings() // Default value + } + }.stateIn( + scope, + started = SharingStarted.Eagerly, + initialValue = UserSettings() // Default value + ) + + override suspend fun updateSettings(newSettings: UserSettings) { + database.settingsDataStore.edit { prefs -> + prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings) + } + } + + suspend fun getCurrentSettings(): UserSettings { + val preferences = database.settingsDataStore.data.first() + val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() + return settingsJson.decodeFromString(storedJson).withPlayableStreamingFormat() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt new file mode 100644 index 00000000..e79ebe0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt @@ -0,0 +1,167 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings + + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import spotube.composeapp.generated.resources.* +import org.spotube.spotube.modules.settings.sections.appearanceSection +import org.spotube.spotube.modules.settings.sections.cacheSection +import org.spotube.spotube.modules.settings.sections.desktopSection +import org.spotube.spotube.modules.settings.sections.downloadsSection +import org.spotube.spotube.modules.settings.sections.jamSection +import org.spotube.spotube.modules.settings.sections.languageRegionSection +import org.spotube.spotube.modules.settings.sections.playbackSection +import org.spotube.spotube.modules.settings.sections.pluginsSection +import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.updatesSection +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject + +/** + * Minimum width of the settings content area required to show the settings as an + * overlay dialog instead of a full-screen destination. + */ +private val SettingsDialogMinWidth = 720.dp + +/** + * On large screens settings is presented as a modal overlay so it does not sit next to + * the main navigation sidebar. On smaller screens it stays a full-screen destination. + */ +@Composable +fun SettingsScreen(settingsViewModel: SettingsViewModel) { + val navigatorCommands: NavigationCommands = koinInject() + + Box(modifier = Modifier.fillMaxSize()) { + if (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) { + SettingsDialog( + settingsViewModel = settingsViewModel, + onDismiss = { navigatorCommands.pop(Routes.Settings) }) + } else { + CompactSettingsScreen(settingsViewModel = settingsViewModel) + } + } +} + +@Composable +private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { + val navigatorCommands = koinInject() + val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Text(stringResource(Res.string.settings_screen_title)) + }, + backButton = false + ) + CompactSettingsContent( + settings = settingsState, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + contentPadding = contentPadding, + modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun CompactSettingsContent( + settings: UserSettings?, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier) { + val platformType = remember { getPlatform().type } + val isDesktopPlatform = platformType == PlatformType.Windows || + platformType == PlatformType.Linux || + platformType == PlatformType.MacOS + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding) { + pluginsSection( + navigatorCommands = navigatorCommands) + if (settings != null) { + languageRegionSection( + settings = settings, + settingsViewModel = settingsViewModel) + appearanceSection( + settings = settings, + settingsViewModel = settingsViewModel) + playbackSection( + settings = settings, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission) + jamSection( + settings = settings, + navigationCommands = navigatorCommands) + cacheSection( + settings = settings, + settingsViewModel = settingsViewModel) + downloadsSection( + settings = settings, + settingsViewModel = settingsViewModel) + if (isDesktopPlatform) { + desktopSection( + settings = settings, + settingsViewModel = settingsViewModel) + } + supportSection( + settings = settings, + settingsViewModel = settingsViewModel) + updatesSection( + settings = settings, + settingsViewModel = settingsViewModel) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt new file mode 100644 index 00000000..3ff64bac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt @@ -0,0 +1,339 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings + + +import org.spotube.millennium.composables.MillenniumCircularProgress +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSidebarItem +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform +import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.modules.blacklist.BlacklistContent +import org.spotube.spotube.modules.plugin.PluginScreenContent +import org.spotube.spotube.modules.settings.sections.appearanceSection +import org.spotube.spotube.modules.settings.sections.cacheSection +import org.spotube.spotube.modules.settings.sections.desktopSection +import org.spotube.spotube.modules.settings.sections.downloadsSection +import org.spotube.spotube.modules.settings.sections.languageRegionSection +import org.spotube.spotube.modules.settings.sections.playbackSection +import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.SettingsPageTitle +import org.spotube.spotube.modules.settings.sections.updatesSection +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxColorSwatch +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2 +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import org.spotube.spotube.resources.iconsax.IconsaxLanguageSquare +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_close +import spotube.composeapp.generated.resources.settings_blacklist_title +import spotube.composeapp.generated.resources.settings_screen_title +import spotube.composeapp.generated.resources.settings_section_about +import spotube.composeapp.generated.resources.settings_section_appearance +import spotube.composeapp.generated.resources.settings_section_caching +import spotube.composeapp.generated.resources.settings_section_desktop +import spotube.composeapp.generated.resources.settings_section_downloads +import spotube.composeapp.generated.resources.settings_section_jam +import spotube.composeapp.generated.resources.settings_section_language_region +import spotube.composeapp.generated.resources.settings_section_playback +import spotube.composeapp.generated.resources.settings_section_plugins +import spotube.composeapp.generated.resources.settings_section_support + +private val SettingsSidebarWidth = 236.dp + +/** + * The sections reachable from the large-screen settings sidebar, in display order. + * + * Each entry maps to either a list-based settings section or a standalone + * screen (Plugins, Blacklist, Group Jam) whose content is embedded in the tab. + */ +internal enum class SettingsTab( + val titleRes: StringResource, + val icon: ImageVector) { + Plugins(Res.string.settings_section_plugins, Iconsax.IconsaxBoxAdd), + LanguageRegion(Res.string.settings_section_language_region, Iconsax.IconsaxLanguageSquare), + Appearance(Res.string.settings_section_appearance, Iconsax.IconsaxColorSwatch), + Playback(Res.string.settings_section_playback, Iconsax.IconsaxMusicPlay), + Blacklist(Res.string.settings_blacklist_title, Iconsax.IconsaxForbidden), + GroupJam(Res.string.settings_section_jam, Iconsax.IconsaxSoundTwotone), + Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload), + Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive), + Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), + Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2), + About(Res.string.settings_section_about, Iconsax.IconsaxInformation), +} + +/** + * Large-screen settings, presented as a modal overlay (dialog) over the whole app so it + * never sits next to the main navigation sidebar. + */ +@Composable +internal fun SettingsDialog( + settingsViewModel: SettingsViewModel, + onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false)) { + // The dialog is its own surface; the player/bottom bars behind it should not + // reserve any inset inside the dialog content. + CompositionLocalProvider(LocalAppShellBottomInset provides 0.dp) { + SettingsPane( + settingsViewModel = settingsViewModel, + onClose = onDismiss) + } + } +} + +@Composable +private fun SettingsPane( + settingsViewModel: SettingsViewModel, + onClose: () -> Unit) { + val navigatorCommands: NavigationCommands = koinInject() + val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() + val contentPadding = remember { PaddingValues(top = 16.dp, bottom = 24.dp) } + + val platformType = remember { getPlatform().type } + val isDesktopPlatform = platformType == PlatformType.Windows || + platformType == PlatformType.Linux || + platformType == PlatformType.MacOS + + val tabs = remember(isDesktopPlatform) { + SettingsTab.entries.filter { it != SettingsTab.Desktop || isDesktopPlatform } + } + var selectedIndex by rememberSaveable { mutableStateOf(0) } + val selectedTab = tabs.getOrElse(selectedIndex) { tabs.first() } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center) { + Row( + modifier = Modifier + .widthIn(max = 1200.dp) + .heightIn(max = 860.dp) + .fillMaxWidth() + .fillMaxHeight() + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.large, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 16.dp)) { + SettingsSidebar( + tabs = tabs, + selectedTab = selectedTab, + onSelect = { selectedIndex = tabs.indexOf(it) }) + MillenniumSeparator( + modifier = Modifier.fillMaxHeight(), + vertical = true) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + SettingsPageTitle(selectedTab.titleRes) + } + MillenniumIconButton( + onClick = onClose, + modifier = Modifier.padding(end = 8.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.common_close)) + } + } + MillenniumSeparator() + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth()) { + SettingsTabContent( + tab = selectedTab, + settings = settingsState, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + contentPadding = contentPadding) + } + } + } + } +} + +@Composable +private fun SettingsSidebar( + tabs: List, + selectedTab: SettingsTab, + onSelect: (SettingsTab) -> Unit) { + LazyColumn( + modifier = Modifier + .width(SettingsSidebarWidth) + .fillMaxHeight() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + item { + Text( + stringResource(Res.string.settings_screen_title), + style = AppTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 16.dp) + ) + } + items(tabs, key = { it.name }) { tab -> + MillenniumSidebarItem( + label = stringResource(tab.titleRes), + onClick = { onSelect(tab) }, + selected = tab == selectedTab, + contentDescription = stringResource(tab.titleRes), + icon = tab.icon) + } + } +} + +@Composable +private fun SettingsTabContent( + tab: SettingsTab, + settings: UserSettings?, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + contentPadding: PaddingValues) { + when (tab) { + SettingsTab.Plugins -> { + PluginScreenContent(viewModel = koinViewModel()) + } + + SettingsTab.Blacklist -> { + BlacklistContent(viewModel = koinViewModel()) + } + + SettingsTab.GroupJam -> { + JamSettingsContent(viewModel = koinViewModel()) + } + + else -> { + if (settings == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center) { + MillenniumCircularProgress(size = 40.dp) + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding) { + when (tab) { + SettingsTab.LanguageRegion -> + languageRegionSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Appearance -> + appearanceSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Playback -> + playbackSection( + settings = settings, + settingsViewModel = settingsViewModel, + navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, + includeBlacklist = false, + includeHeader = false) + + SettingsTab.Caching -> + cacheSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Downloads -> + downloadsSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Desktop -> + desktopSection(settings, settingsViewModel, includeHeader = false) + + SettingsTab.Support -> + supportSection( + settings = settings, + settingsViewModel = settingsViewModel, + includeHeader = false) + + SettingsTab.About -> + updatesSection(settings, settingsViewModel, includeHeader = false) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt similarity index 83% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt index 88b4878d..c629111a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings +package org.spotube.spotube.modules.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -23,17 +23,21 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.spotube.spotube.core.paths.Paths interface SettingsProvider { val settingsState: StateFlow } - class SettingsViewModel( - private val repository: SettingsRepository + private val repository: SettingsRepository, + private val paths: Paths ) : ViewModel(), SettingsProvider { override val settingsState = repository.userSettings + /** Where downloads go when no override folder is set. */ + val defaultDownloadFolder: String = paths.getUserDownloadsDirPath() + fun updateSettings(transform: UserSettings.() -> UserSettings) { viewModelScope.launch { // Get current state, apply transform, and save diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt new file mode 100644 index 00000000..83c49be7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -0,0 +1,286 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.components + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumDialogDescription +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumRadioGroup +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.rememberTextFieldState + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.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.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import org.spotube.spotube.core.ui.component.AdaptiveMenuItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import kotlinx.coroutines.flow.drop +import spotube.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SwitchSettingCard( + title: String, + subtitle: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + icon: (@Composable () -> Unit)? = null +) { + SettingCardItem( + title = title, + subtitle = subtitle, + icon = icon, + trailingContent = { + MillenniumSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + contentSpacing = 0.dp + ) + }, + onClick = { + onCheckedChange(!checked) + } + ) +} + +@Composable +internal fun SelectionSettingCard( + title: String, + subtitle: String? = null, + selectedOption: T, + options: List, + optionLabel: @Composable (T) -> String, + onOptionSelected: (T) -> Unit, + dialogTitle: String = title, + icon: (@Composable () -> Unit)? = null, + inlineSelectorMinWidth: Dp = 700.dp, + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null +) { + var isDialogOpen by remember { mutableStateOf(false) } + + Box { + val isWideLayout = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + + SettingCardItem( + title = title, + subtitle = subtitle, + icon = icon, + trailingContent = { + AdaptiveDropdownBottomSheet( + items = options.map { option -> + AdaptiveMenuItem( + label = optionLabel(option), + onClick = { onOptionSelected(option) }, + selected = option == selectedOption + ) + }, + trigger = { onClick -> + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(optionLabel(selectedOption)) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + } + } + }, + filter = filter + ) + }, + onClick = { + if (isWideLayout) { + isDialogOpen = true + } + } + ) + + if (!isWideLayout && isDialogOpen) { + val labels = options.map { optionLabel(it) } + MillenniumDialog( + visible = true, + onDismissRequest = { isDialogOpen = false }, + title = dialogTitle + ) { + MillenniumRadioGroup( + value = selectedOption, + onValueChange = { option -> + onOptionSelected(option) + isDialogOpen = false + }, + options = options, + label = dialogTitle, + optionLabel = { option -> labels[options.indexOf(option)] }) + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Quiet + ) { + Text(stringResource(Res.string.common_close)) + } + } + } + } +} + +@Composable +internal fun TextInputSettingCard( + title: String, + subtitle: String? = null, + value: String, + onValueSaved: (String) -> Unit, + dialogTitle: String = title, + dialogDescription: String? = null, + placeholder: String = "", + normalize: (String) -> String = { it.trim() }, + validate: (String) -> String? = { null }, + icon: (@Composable () -> Unit)? = null, + inlineTextFieldMinWidth: Dp = 700.dp, + inlineTextFieldWidth: Dp = 220.dp, + enabled: Boolean = true +) { + var isDialogOpen by remember { mutableStateOf(false) } + + Box { + val isWideLayout = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium + val inlineState = rememberTextFieldState(value) + + LaunchedEffect(inlineState, isWideLayout) { + snapshotFlow { inlineState.text.toString() } + .drop(1) + .collect { draft -> + val normalizedValue = normalize(draft) + if (validate(normalizedValue) == null) { + onValueSaved(normalizedValue) + } + } + } + + SettingCardItem( + enabled = enabled, + title = title, + subtitle = subtitle, + icon = icon, + trailingContent = if (isWideLayout) { + { + MillenniumTextField( + state = inlineState, + modifier = Modifier.width(inlineTextFieldWidth), + placeholder = placeholder, + enabled = enabled + ) + } + } else { + null + }, + onClick = { + if (!isWideLayout) { + isDialogOpen = true + } + } + ) + + if (!isWideLayout && isDialogOpen) { + val dialogState = rememberTextFieldState(value) + var draft by remember(value, isDialogOpen) { mutableStateOf(value) } + + LaunchedEffect(dialogState) { + snapshotFlow { dialogState.text.toString() } + .drop(1) + .collect { draft = it } + } + + val normalizedValue = normalize(draft) + val errorMessage = validate(normalizedValue) + + MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { + MillenniumDialogHeader { + MillenniumDialogTitle(dialogTitle) + MillenniumDialogClose() + } + MillenniumDialogBody { + if (dialogDescription != null) { + MillenniumDialogDescription(dialogDescription) + } + MillenniumTextField( + state = dialogState, + label = title, + modifier = Modifier.fillMaxWidth(), + placeholder = placeholder, + error = errorMessage + ) + } + MillenniumDialogFooter { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = { + if (errorMessage == null) { + onValueSaved(normalizedValue) + isDialogOpen = false + } + }, + enabled = errorMessage == null + ) { + Text(stringResource(Res.string.common_save)) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt new file mode 100644 index 00000000..01795adf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -0,0 +1,284 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.settings.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import io.github.vinceglb.filekit.path +import org.spotube.millennium.composables.MillenniumDialog +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.VerticalScrollbar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.LocalMediaFolderRowUi +import org.spotube.spotube.modules.settings.LocalMediaFoldersUiState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxAddSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +internal fun LocalMediaFoldersEditorContent( + state: LocalMediaFoldersUiState, + onAddFolder: () -> Unit, + onRemoveFolder: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(Res.string.settings_local_media_folders_description), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + Row( + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + pluralStringResource( + Res.plurals.settings_local_media_folders_count, + state.folders.size, + state.folders.size + ), + style = AppTheme.typography.labelMedium, modifier = Modifier.weight(1f) + ) + MillenniumButton( + onClick = onAddFolder, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary, + padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + minSize = DpSize(0.dp, 36.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon(Iconsax.IconsaxAddSquare, null, modifier = Modifier.size(16.dp)) + Text( + stringResource(Res.string.settings_local_media_folders_add_action), + style = AppTheme.typography.labelMedium + ) + } + } + } + Box( + Modifier.fillMaxWidth() + .heightIn(min = if (state.folders.isEmpty()) 104.dp else 76.dp, max = 240.dp) + .millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + if (state.folders.isEmpty()) { + Column( + Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(Res.string.settings_local_media_folders_none_added), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } else { + LazyColumn( + Modifier.fillMaxWidth(), + state = listState, + contentPadding = PaddingValues(vertical = 4.dp) + ) { + itemsIndexed( + state.folders, + key = { _, folder -> folder.path }) { index, folder -> + if (index > 0) MillenniumSeparator(Modifier.padding(horizontal = 12.dp)) + LocalMediaFolderEditorRow( + folder, + !state.isSaving + ) { onRemoveFolder(folder.path) } + } + } + if (listState.canScrollForward || listState.canScrollBackward) { + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } + state.automaticNote?.let { note -> + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(note), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + if (state.saveFailed) Text( + stringResource(Res.string.settings_local_media_folders_save_error), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.error + ) + MillenniumSeparator() + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MillenniumButton( + onClick = onCancel, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.common_cancel)) + } + MillenniumButton(onClick = onSave, enabled = state.canSave, loading = state.isSaving) { + Text(stringResource(Res.string.common_save)) + } + } + } +} + +@Composable +private fun LocalMediaFolderEditorRow( + folder: LocalMediaFolderRowUi, + enabled: Boolean, + onRemove: () -> Unit +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(36.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(6.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary + ) + } + Box(Modifier.weight(1f)) { + MillenniumTooltip(folder.path) { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + folder.path, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp + ), + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + val removeLabel = + stringResource(Res.string.settings_local_media_folders_remove_named, folder.name) + MillenniumTooltip(removeLabel) { + MillenniumIconButton( + onClick = onRemove, + enabled = enabled, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon(Iconsax.IconsaxTrash, removeLabel, modifier = Modifier.size(16.dp)) + } + } + } +} + +/** Renders the "manage local folders" dialog (folder picker included) while [viewModel] has it open. + * Shared by Settings and the Local Tracks screen; callers trigger it via [LocalMediaFoldersViewModel.open]. */ +@Composable +fun LocalMediaFoldersDialogHost(viewModel: LocalMediaFoldersViewModel) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val pickerLauncher = + rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) } + if (state.isOpen) { + MillenniumDialog( + visible = true, + onDismissRequest = viewModel::dismiss, + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent( + state, { pickerLauncher.launch() }, viewModel::removeFolder, + viewModel::save, viewModel::dismiss + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt similarity index 75% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt index 7e34554f..2ca288b0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/components/SettingsCardItem.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.components +package org.spotube.spotube.modules.settings.components -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.krtirtho.spotube.core.ui.base.ListRowTile /** * Reusable setting card item component for consistent styling @@ -37,7 +37,7 @@ fun SettingCardItem( onClick: () -> Unit = {}, modifier: Modifier = Modifier ) { - ListRowTile( + MillenniumListTile( selected = false, enabled = enabled, onClick = onClick, @@ -46,19 +46,18 @@ fun SettingCardItem( title = { Text( title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface ) }, subtitle = subtitle?.let { { Text( it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant ) } }, - trailing = trailingContent, - ) + trailing = trailingContent) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt new file mode 100644 index 00000000..3186eb9e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt @@ -0,0 +1,269 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.core.ui.theming.isLightAppearance +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxColorSwatch +import org.spotube.spotube.resources.iconsax.IconsaxMagic +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.theme.MillenniumColorSchemes +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig +import org.spotube.millennium.theme.resolve +import spotube.composeapp.generated.resources.* + +internal fun LazyListScope.appearanceSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_appearance) + } + settingsSectionCard( + items = listOf( + { + SelectionSettingCard( + title = stringResource(Res.string.settings_theme_title), + subtitle = stringResource( + Res.string.settings_theme_subtitle_current, + settings.theme.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxColorSwatch, stringResource(Res.string.settings_theme_title)) + }, + selectedOption = settings.theme, + options = Theme.entries, + optionLabel = { it.displayLabel() }, + onOptionSelected = { theme -> + settingsViewModel.updateSettings { + copy(theme = theme) + } + } + ) + }, + { + AppearanceSettingCard( + selectedPalette = settings.palette, + icon = { + SettingsItemIcon( + Iconsax.IconsaxMagic, + stringResource(Res.string.settings_appearance_title) + ) + }, + onSaved = { palette -> + settingsViewModel.updateSettings { + copy(palette = palette) + } + }) + }) + ) +} + +@Composable +private fun Theme.displayLabel(): String { + return when (this) { + Theme.LIGHT -> stringResource(Res.string.settings_theme_light) + Theme.DARK -> stringResource(Res.string.settings_theme_dark) + Theme.SYSTEM -> stringResource(Res.string.settings_theme_system) + } +} + +@Composable +private fun MillenniumPalette.displayLabel(): String = when (this) { + MillenniumPalette.Blue -> stringResource(Res.string.settings_palette_blue) + MillenniumPalette.Orange -> stringResource(Res.string.settings_palette_orange) + MillenniumPalette.Lime -> stringResource(Res.string.settings_palette_lime) +} + +@Composable +private fun AppearanceSettingCard( + selectedPalette: MillenniumPalette, + icon: (@Composable () -> Unit)? = null, + onSaved: (MillenniumPalette) -> Unit) { + var isDialogOpen by remember { mutableStateOf(false) } + + SettingCardItem( + title = stringResource(Res.string.settings_appearance_title), + subtitle = stringResource( + Res.string.settings_appearance_subtitle_palette, + selectedPalette.displayLabel()), + icon = icon, + trailingContent = { + PaletteTrailingSwatch(selectedPalette) + }, + onClick = { isDialogOpen = true }) + + if (isDialogOpen) { + var draftPalette by remember(selectedPalette, isDialogOpen) { + mutableStateOf(selectedPalette) + } + + MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.settings_palette_dialog_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { + MillenniumText( + stringResource(Res.string.settings_palette_dialog_description), + color = AppTheme.colors.onSurfaceVariant) + MillenniumText( + stringResource(Res.string.settings_palette_label), + color = AppTheme.colors.onSurface) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumPalette.entries.forEach { palette -> + PaletteSwatch( + palette = palette, + label = palette.displayLabel(), + selected = palette == draftPalette, + onClick = { draftPalette = palette }) + } + } + + PalettePreview(draftPalette) + } + MillenniumDialogFooter { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.common_cancel)) + } + MillenniumButton( + onClick = { + onSaved(draftPalette) + isDialogOpen = false + }) { + MillenniumText(stringResource(Res.string.common_save)) + } + } + } + } +} + +@Composable +private fun PaletteSwatch( + palette: MillenniumPalette, + label: String, + selected: Boolean, + onClick: () -> Unit) { + val dark = !isLightAppearance() + val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) } + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(scheme.accent) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) AppTheme.colors.onSurface else AppTheme.colors.outlineVariant, + shape = CircleShape) + .clickable(onClick = onClick) + .semantics { contentDescription = label }) +} + +@Composable +private fun PaletteTrailingSwatch(palette: MillenniumPalette) { + val dark = !isLightAppearance() + val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) } + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(width = 46.dp, height = 28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(scheme.accent.copy(alpha = 0.35f))) + Box( + Modifier + .size(18.dp) + .clip(CircleShape) + .background(scheme.accent)) + } +} + +@Composable +private fun PalettePreview(palette: MillenniumPalette) { + val dark = !isLightAppearance() + MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(MillenniumTheme.colors.surface) + .border(1.dp, MillenniumTheme.colors.border, RoundedCornerShape(14.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumText( + stringResource(Res.string.settings_preview_label), + color = MillenniumTheme.colors.muted) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = {}) { + MillenniumText(stringResource(Res.string.settings_preview_button)) + } + MillenniumButton(onClick = {}, style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.settings_preview_secondary)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt similarity index 81% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt index 4ff417cc..53129924 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/CacheSection.kt @@ -15,26 +15,29 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.Folder import compose.icons.feathericons.HardDrive import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder -import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxStopCircle +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxFolder +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.resources.iconsax.IconsaxStopCircle import io.github.vinceglb.filekit.path import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import org.jetbrains.compose.resources.stringResource @@ -42,8 +45,10 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.cacheSection( settings: UserSettings, settingsViewModel: SettingsViewModel, -) { - settingsSectionHeader(Res.string.settings_section_caching) + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_caching) + } settingsSectionCard( items = listOf( { @@ -119,8 +124,7 @@ internal fun LazyListScope.cacheSection( } } ) - }, - ) + }) ) } @@ -128,8 +132,7 @@ internal fun LazyListScope.cacheSection( private fun CacheFolderSettingCard( enabled: Boolean, folder: String?, - onFolderSelected: (String?) -> Unit, -) { + onFolderSelected: (String?) -> Unit) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> if (directory != null) { val normalized = normalizePath(directory.path) @@ -151,17 +154,13 @@ private fun CacheFolderSettingCard( ) }, trailingContent = { - GhostIconButton( + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_cache_folder_title), onClick = { pickerLauncher.launch() }, enabled = enabled, - ) { - Icon( - Iconsax.IconsaxFolderAdd, - contentDescription = stringResource(Res.string.settings_cache_folder_title) - ) - } + style = MillenniumIconButtonStyle.Quiet) }, onClick = { pickerLauncher.launch() }, - enabled = enabled, - ) + enabled = enabled) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt index 54de88c7..ad3dc2e7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DesktopSection.kt @@ -15,23 +15,25 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2 -import dev.krtirtho.spotube.resources.iconsax.LineDiscord +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxImportArrow2 +import org.spotube.spotube.resources.iconsax.LineDiscord import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.desktopSection( settings: UserSettings, settingsViewModel: SettingsViewModel, -) { - settingsSectionHeader(Res.string.settings_section_desktop) + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_desktop) + } settingsSectionCard( items = listOf( { @@ -69,8 +71,7 @@ internal fun LazyListScope.desktopSection( } } ) - }, - ) + }) ) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt new file mode 100644 index 00000000..fb5289d4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt @@ -0,0 +1,216 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.lazy.LazyListScope + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.LocalMediaFoldersViewModel +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersDialogHost +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxFolder +import org.spotube.spotube.resources.iconsax.IconsaxFolderAdd +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import io.github.vinceglb.filekit.path +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_download_folder_current +import spotube.composeapp.generated.resources.settings_download_folder_default_path +import spotube.composeapp.generated.resources.settings_download_folder_title +import spotube.composeapp.generated.resources.settings_download_format_subtitle_current +import spotube.composeapp.generated.resources.settings_download_format_title +import spotube.composeapp.generated.resources.settings_download_quality_title +import spotube.composeapp.generated.resources.settings_local_media_folders_manage +import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_current +import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty +import spotube.composeapp.generated.resources.settings_local_media_folders_title +import spotube.composeapp.generated.resources.settings_section_downloads +import spotube.composeapp.generated.resources.settings_subtitle_current + +internal fun LazyListScope.downloadsSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true +) { + val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) + val downloadQualities = availableAudioQualities( + format = settings.downloadMusicFormat, + current = settings.downloadMusicQuality + ) + + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_downloads) + } + settingsSectionCard( + items = listOf( + { + DownloadFolderSettingCard( + folder = settings.overloadedDownloadFolder, + defaultFolder = settingsViewModel.defaultDownloadFolder, + onFolderSelected = { folder -> + settingsViewModel.updateSettings { + copy(overloadedDownloadFolder = folder) + } + } + ) + }, + { + LocalMediaFoldersSettingCard() + }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_download_format_title), + subtitle = stringResource( + Res.string.settings_download_format_subtitle_current, + settings.downloadMusicFormat.displayLabel() + ), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMusicPlay, + stringResource(Res.string.settings_download_format_title) + ) + }, + selectedOption = settings.downloadMusicFormat, + options = downloadFormats, + optionLabel = { it.displayLabel() }, + onOptionSelected = { format -> + settingsViewModel.updateSettings { + copy( + downloadMusicFormat = format, + downloadMusicQuality = format.resolveQuality(downloadMusicQuality) + ) + } + } + ) + }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_download_quality_title), + subtitle = stringResource( + Res.string.settings_subtitle_current, + settings.downloadMusicQuality.displayLabel() + ), + icon = { + SettingsItemIcon( + Iconsax.IconsaxVerticalSetting, + stringResource(Res.string.settings_download_quality_title) + ) + }, + selectedOption = settings.downloadMusicQuality, + options = downloadQualities, + optionLabel = { it.displayLabel() }, + onOptionSelected = { quality -> + settingsViewModel.updateSettings { + copy(downloadMusicQuality = quality) + } + } + ) + }) + ) +} + +@Composable +private fun DownloadFolderSettingCard( + folder: String?, + defaultFolder: String, + onFolderSelected: (String?) -> Unit +) { + val pickerLauncher = rememberDirectoryPickerLauncher { directory -> + if (directory != null) { + val normalized = normalizePath(directory.path) + if (normalized.isNotEmpty()) { + onFolderSelected(normalized) + } + } + } + + SettingCardItem( + title = stringResource(Res.string.settings_download_folder_title), + subtitle = folder?.let { + stringResource(Res.string.settings_download_folder_current, it) + } ?: stringResource(Res.string.settings_download_folder_default_path, defaultFolder), + icon = { + SettingsItemIcon( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.settings_download_folder_title) + ) + }, + trailingContent = { + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_download_folder_title), + onClick = { pickerLauncher.launch() }, + style = MillenniumIconButtonStyle.Quiet + ) + }, + onClick = { + pickerLauncher.launch() + } + ) +} + +@Composable +private fun LocalMediaFoldersSettingCard( + viewModel: LocalMediaFoldersViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) { + stringResource(Res.string.settings_local_media_folders_subtitle_empty) + } else stringResource( + Res.string.settings_local_media_folders_subtitle_current, + state.configuredCount + ) + + SettingCardItem( + title = stringResource(Res.string.settings_local_media_folders_title), + subtitle = subtitlePreview, + icon = { + SettingsItemIcon( + Iconsax.IconsaxFolder, + stringResource(Res.string.settings_local_media_folders_title) + ) + }, + trailingContent = { + MillenniumButton( + onClick = viewModel::open, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.settings_local_media_folders_manage)) + } + }, + onClick = viewModel::open + ) + + LocalMediaFoldersDialogHost(viewModel) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt new file mode 100644 index 00000000..843c3402 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt @@ -0,0 +1,58 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.core.jam.JamNames +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_name_description +import spotube.composeapp.generated.resources.jam_name_subtitle +import spotube.composeapp.generated.resources.jam_name_title +import spotube.composeapp.generated.resources.jam_name_too_long + +/** + * "Your name" card for Group Jam. Shared by the Jam screen and Jam settings so both stay + * identical, like the remote control device name card. + * + * [customName] is what the user saved (blank = none), [defaultName] the generated one. + */ +@Composable +internal fun JamNameSettingCard( + customName: String, + defaultName: String, + onNameSaved: (String) -> Unit, + enabled: Boolean = true +) { + val tooLong = stringResource(Res.string.jam_name_too_long, JamNames.MAX_LENGTH) + TextInputSettingCard( + title = stringResource(Res.string.jam_name_title), + subtitle = stringResource(Res.string.jam_name_subtitle, customName.ifBlank { defaultName }), + icon = { SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.jam_name_title)) }, + value = customName, + dialogDescription = stringResource(Res.string.jam_name_description, defaultName), + placeholder = defaultName, + normalize = JamNames::normalize, + validate = { value -> if (value.length > JamNames.MAX_LENGTH) tooLong else null }, + onValueSaved = onNameSaved, + enabled = enabled + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt new file mode 100644 index 00000000..fcbfff82 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt @@ -0,0 +1,54 @@ +/* + * 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. + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.lazy.LazyListScope + +import org.spotube.spotube.core.ui.theming.AppTheme +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_jam_server_title +import spotube.composeapp.generated.resources.settings_section_jam + +internal fun LazyListScope.jamSection( + settings: UserSettings, + navigationCommands: NavigationCommands) { + settingsSectionHeader(Res.string.settings_section_jam) + settingsSectionCard( + items = listOf( + { + SettingCardItem( + title = stringResource(Res.string.settings_jam_server_title), + subtitle = settings.jamServerUrl, + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_server_title)) + }, + trailingContent = { + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_jam_server_title), + tint = AppTheme.colors.onSurfaceVariant) + }, + onClick = { navigationCommands.navigateTo(Routes.JamSettings) }) + }) + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt similarity index 62% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt index e0e9545d..ac3c95f0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -15,28 +15,35 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope -import compose.icons.FeatherIcons -import compose.icons.feathericons.Globe -import compose.icons.feathericons.MapPin -import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.CountryCode -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.SupportedLanguages -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe -import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare +import androidx.compose.runtime.Composable +import org.spotube.spotube.modules.settings.CountryCode +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.SupportedLanguages +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxGlobe +import org.spotube.spotube.resources.iconsax.IconsaxLanguageSquare import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_country_subtitle_current +import spotube.composeapp.generated.resources.settings_country_title +import spotube.composeapp.generated.resources.settings_language_subtitle_current +import spotube.composeapp.generated.resources.settings_language_system_default +import spotube.composeapp.generated.resources.settings_language_title +import spotube.composeapp.generated.resources.settings_option_name_and_code +import spotube.composeapp.generated.resources.settings_section_language_region internal fun LazyListScope.languageRegionSection( settings: UserSettings, settingsViewModel: SettingsViewModel, -) { - settingsSectionHeader(Res.string.settings_section_language_region) + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_language_region) + } settingsSectionCard( items = listOf( { @@ -44,7 +51,7 @@ internal fun LazyListScope.languageRegionSection( title = stringResource(Res.string.settings_language_title), subtitle = stringResource( Res.string.settings_language_subtitle_current, - settings.language.displayName + languageLabel(settings.language) ), icon = { SettingsItemIcon( @@ -54,16 +61,13 @@ internal fun LazyListScope.languageRegionSection( }, selectedOption = settings.language, options = SupportedLanguages.entries, - optionLabel = { - stringResource(Res.string.settings_option_name_and_code, it.displayName, it.locale) - }, + optionLabel = { languageLabel(it) }, onOptionSelected = { value -> settingsViewModel.updateSettings { copy(language = value) } }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, - ) + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) }, { SelectionSettingCard( @@ -88,10 +92,16 @@ internal fun LazyListScope.languageRegionSection( copy(country = value) } }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, - ) - }, - ) + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) + }) ) } +@Composable +private fun languageLabel(language: SupportedLanguages): String = + if (language == SupportedLanguages.SYSTEM) { + stringResource(Res.string.settings_language_system_default) + } else { + stringResource(Res.string.settings_option_name_and_code, language.displayName, language.languageTag!!) + } + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt new file mode 100644 index 00000000..c60df146 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt @@ -0,0 +1,183 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + + +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.lazy.LazyListScope + +import org.spotube.spotube.core.ui.theming.AppTheme +import androidx.compose.runtime.Composable +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.Cast +import compose.icons.feathericons.Radio +import compose.icons.feathericons.Repeat +import compose.icons.feathericons.Server +import compose.icons.feathericons.Sliders +import spotube.composeapp.generated.resources.* +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SelectionSettingCard +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.CustomServer +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxForbidden +import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay +import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow +import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.playbackSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, + includeBlacklist: Boolean = true, + includeHeader: Boolean = true) { + val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) + val streamingQualities = availableAudioQualities( + format = settings.streamingMusicFormat, + current = settings.streamingMusicQuality) + + val cards: List<@Composable () -> Unit> = buildList { + add { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_format_title), + subtitle = stringResource( + Res.string.settings_streaming_format_subtitle_current, + settings.streamingMusicFormat.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title)) + }, + selectedOption = settings.streamingMusicFormat, + options = streamingFormats, + optionLabel = { it.displayLabel() }, + onOptionSelected = { format -> + settingsViewModel.updateSettings { + copy( + streamingMusicFormat = format, + streamingMusicQuality = format.resolveQuality(streamingMusicQuality)) + } + } + ) + } + add { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_quality_title), + subtitle = stringResource( + Res.string.settings_subtitle_current, + settings.streamingMusicQuality.displayLabel() + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title)) + }, + selectedOption = settings.streamingMusicQuality, + options = streamingQualities, + optionLabel = { it.displayLabel() }, + onOptionSelected = { quality -> + settingsViewModel.updateSettings { + copy(streamingMusicQuality = quality) + } + } + ) + } + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_endless_playback_title), + subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxRepeatArrow, + stringResource(Res.string.settings_enable_endless_playback_title) + ) + }, + checked = settings.enableEndlessPlayback, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableEndlessPlayback = enabled) + } + } + ) + } + addAll(remoteControlSettingCards(settings, settingsViewModel, requestLocalNetworkPermission)) + add { + val error_whole_number = stringResource(Res.string.settings_error_whole_number) + val error_port_range = stringResource(Res.string.settings_error_port_range) + + TextInputSettingCard( + title = stringResource(Res.string.settings_playback_port_title), + subtitle = stringResource( + Res.string.settings_playback_port_subtitle_current, + settings.playbackProxyServerPort + ), + icon = { + SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title)) + }, + value = settings.playbackProxyServerPort.toString(), + dialogDescription = stringResource(Res.string.settings_playback_port_description), + placeholder = stringResource(Res.string.settings_playback_port_placeholder), + normalize = { it.trim() }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> error_whole_number + port !in 1..65535 -> error_port_range + else -> null + } + }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(playbackProxyServerPort = value.toInt()) + } + } + ) + } + if (includeBlacklist) { + add { + 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 = { + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_blacklist_title), + tint = AppTheme.colors.onSurfaceVariant) + }, + onClick = { + navigatorCommands.navigateTo(Routes.Blacklist) + }) + } + } + } + + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_playback) + } + settingsSectionCard(items = cards) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt index 2258f117..d43a1ca9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PluginsSection.kt @@ -15,23 +15,25 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.Package -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxBoxAdd import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.pluginsSection( @@ -51,10 +53,10 @@ internal fun LazyListScope.pluginsSection( ) }, trailingContent = { - Icon( + MillenniumIcon( imageVector = FeatherIcons.ChevronRight, contentDescription = stringResource(Res.string.settings_plugins_manage_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) }, onClick = { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt new file mode 100644 index 00000000..5485f195 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt @@ -0,0 +1,92 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_allow_remote_control_subtitle +import spotube.composeapp.generated.resources.settings_allow_remote_control_title +import spotube.composeapp.generated.resources.settings_remote_device_name_default +import spotube.composeapp.generated.resources.settings_remote_device_name_description +import spotube.composeapp.generated.resources.settings_remote_device_name_placeholder +import spotube.composeapp.generated.resources.settings_remote_device_name_subtitle +import spotube.composeapp.generated.resources.settings_remote_device_name_title + +/** + * "Allow remote control" and "Device name" cards. Shared by the Playback settings section and the + * Devices screen so both stay identical. + */ +internal fun remoteControlSettingCards( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + requestLocalNetworkPermission: () -> Unit +): List<@Composable () -> Unit> = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } + } + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + } +) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt new file mode 100644 index 00000000..6d54f4de --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/SupportSection.kt @@ -0,0 +1,76 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.support.donationOptions +import org.spotube.spotube.modules.support.openDonation +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_section_support +import spotube.composeapp.generated.resources.settings_support_reminders_subtitle +import spotube.composeapp.generated.resources.settings_support_reminders_title + +/** Support settings: the periodic reminder toggle plus the donation destinations. */ +internal fun LazyListScope.supportSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_support) + } + settingsSectionCard( + items = buildList { + add { + SwitchSettingCard( + title = stringResource(Res.string.settings_support_reminders_title), + subtitle = stringResource(Res.string.settings_support_reminders_subtitle), + icon = { + SettingsItemIcon( + imageVector = Iconsax.IconsaxHeart2, + contentDescription = stringResource( + Res.string.settings_support_reminders_title)) + }, + checked = settings.supportRemindersEnabled, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(supportRemindersEnabled = enabled) + } + }) + } + donationOptions.forEach { option -> + add { + SettingCardItem( + title = stringResource(option.title), + subtitle = stringResource(option.subtitle), + icon = { + SettingsItemIcon( + imageVector = option.icon, + contentDescription = stringResource(option.title)) + }, + onClick = { openDonation(option) }) + } + } + }) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt similarity index 80% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt index 0981d0e7..dbf5af6d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/UpdatesSection.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.modules.settings.SettingsViewModel -import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshArrow2 +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxRefreshArrow2 import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.settings_section_updates @@ -32,8 +32,10 @@ import spotube.composeapp.generated.resources.settings_updates_auto_check_title internal fun LazyListScope.updatesSection( settings: UserSettings, settingsViewModel: SettingsViewModel, -) { - settingsSectionHeader(Res.string.settings_section_updates) + includeHeader: Boolean = true) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_updates) + } settingsSectionCard( items = listOf { SwitchSettingCard( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt similarity index 57% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt index 1776e14f..34849447 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt @@ -15,86 +15,90 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.settings.sections +package org.spotube.spotube.modules.settings.sections + +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import dev.krtirtho.spotube.core.ui.base.Card +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_audio_format_label +import spotube.composeapp.generated.resources.settings_audio_quality_lossless +import spotube.composeapp.generated.resources.settings_audio_quality_lossy private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), -) + AudioQuality.Lossy(bitrate = 256_000)) +// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg). internal val streamingFormatPresets = listOf( AudioFormat( codec = "opus", container = "webm", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "aac", container = "mp4", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "vorbis", container = "ogg", - qualities = standardLossyQualities, - ), -) + qualities = standardLossyQualities) +).filter { isContainerPlayableOn(it.container, currentPlatform) } internal val downloadFormatPresets = listOf( AudioFormat( codec = "aac", container = "mp4", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "mp3", container = "mp3", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "opus", container = "webm", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "flac", container = "flac", qualities = listOf( AudioQuality.Lossless(sampleRate = 44_100, channels = 2), - AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ), - ), -) + AudioQuality.Lossless(sampleRate = 48_000, channels = 2)))) internal fun LazyListScope.settingsSectionHeader(title: StringResource) { item { Text( text = stringResource(title), - style = MaterialTheme.typography.labelMedium, + style = AppTheme.typography.labelMedium, color = Color.Gray, modifier = Modifier .fillMaxWidth() @@ -103,22 +107,37 @@ internal fun LazyListScope.settingsSectionHeader(title: StringResource) { } } +/** + * A page-level title for a settings destination (used by the large-screen sidebar + * layout, where each section is its own page rather than one entry in a long list). + */ +@Composable +internal fun SettingsPageTitle(title: StringResource) { + Text( + text = stringResource(title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) +} + @Composable internal fun SettingsItemIcon( imageVector: ImageVector, contentDescription: String, - tint: Color = MaterialTheme.colorScheme.primary, -) { - Surface( - shape = RoundedCornerShape(8.dp), - color = tint.copy(alpha = 0.12f), - ) { + tint: Color = AppTheme.colors.primary) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(tint.copy(alpha = 0.12f))) { Box(modifier = Modifier.padding(8.dp)) { - Icon( + MillenniumIcon( imageVector = imageVector, contentDescription = contentDescription, - tint = tint, - ) + tint = tint) } } } @@ -145,8 +164,9 @@ internal fun availableAudioQualities(format: AudioFormat, current: AudioQuality) return options } +@Composable internal fun AudioFormat.displayLabel(): String { - return "${codec.uppercase()} in ${container.uppercase()} • ${preferredQuality().displayLabel()}" + return stringResource(Res.string.settings_audio_format_label, codec.uppercase(), container.uppercase()) } internal fun AudioFormat.preferredQuality(): AudioQuality { @@ -157,16 +177,17 @@ internal fun AudioFormat.resolveQuality(preferred: AudioQuality): AudioQuality { return qualities.firstOrNull { it == preferred } ?: preferredQuality() } +@Composable internal fun AudioQuality.displayLabel(): String { return when (this) { - is AudioQuality.Lossy -> "${bitrate / 1000} kbps" + is AudioQuality.Lossy -> stringResource(Res.string.settings_audio_quality_lossy, bitrate / 1000) is AudioQuality.Lossless -> { val sampleRateLabel = if (sampleRate % 1000 == 0) { "${sampleRate / 1000}" } else { "${sampleRate / 1000.0}" } - "$sampleRateLabel kHz • $channels ch" + stringResource(Res.string.settings_audio_quality_lossless, sampleRateLabel, channels) } } } @@ -176,27 +197,25 @@ internal fun normalizePath(path: String): String { } internal fun LazyListScope.settingsSectionCard( - items: List<@Composable () -> Unit>, -) { + items: List<@Composable () -> Unit>) { item { - Card( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.medium, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 6.dp) + .padding(top = 4.dp, bottom = 4.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 4.dp) - ) { - items.forEachIndexed { index, content -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - content() + items.forEachIndexed { index, content -> + if (index > 0) { + MillenniumSeparator() } + content() } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt new file mode 100644 index 00000000..9dc42cc4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppBottombar.kt @@ -0,0 +1,67 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumBottomNavigationBar +import org.spotube.millennium.composables.MillenniumBottomNavigationItem +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.spotube.spotube.NavigationItem +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.bottombarTabs +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.downloads.DownloadBadgeIndicator + +@Composable +fun AppBottombar( + navigator: Navigator, + navigationState: NavigationState, + modifier: Modifier = Modifier, + libraryBadge: @Composable () -> Unit = { DownloadBadgeIndicator() } +) { + MillenniumBottomNavigationBar(modifier = modifier) { + bottombarTabs.filterIsInstance>() + .forEach { tab -> + val selected = navigationState.topLevelRoute == tab.route + val title = stringResource(tab.title) + MillenniumBottomNavigationItem( + selected = selected, + onClick = { navigator.navigate(tab.route) }, + contentDescription = title, + icon = { + MillenniumIcon( + imageVector = tab.icon, + contentDescription = null, + tint = LocalTextStyle.current.color + ) + }, + label = { Text(title) }, + badge = if (tab.route == Routes.Library) { + libraryBadge + } else { + null + } + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt similarity index 51% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt index c43ef433..6487bad8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt @@ -15,8 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -28,35 +30,41 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio 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.safeDrawing import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState + +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState + +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -66,9 +74,6 @@ 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.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight @@ -77,52 +82,79 @@ 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.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -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.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.Slider -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle -import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon -import dev.krtirtho.spotube.modules.downloads.DownloadStatus -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle -import dev.krtirtho.spotube.resources.iconsax.InconsaxClock -import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.downloads.DownloadProgressIcon +import org.spotube.spotube.modules.downloads.DownloadStatus +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.lyrics.LyricsViewModel +import org.spotube.spotube.modules.lyrics.SyncedLyricsList +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4 +import org.spotube.spotube.resources.iconsax.IconsaxArrowSquareUp +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.InconsaxClock +import org.spotube.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import kotlin.time.Duration.Companion.milliseconds - +import org.spotube.spotube.core.i18n.getString +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.downloads_status_cancelled +import spotube.composeapp.generated.resources.downloads_status_failed +import spotube.composeapp.generated.resources.downloads_status_queued +import spotube.composeapp.generated.resources.player_alternative_source +import spotube.composeapp.generated.resources.player_collapse_content_description +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_download +import spotube.composeapp.generated.resources.player_download_already_downloaded +import spotube.composeapp.generated.resources.player_download_retrying +import spotube.composeapp.generated.resources.player_download_started +import spotube.composeapp.generated.resources.player_download_status_downloaded +import spotube.composeapp.generated.resources.player_download_status_downloading +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_expand_lyrics_content_description +import spotube.composeapp.generated.resources.player_go_to_album +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_lyrics +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_now_playing +import spotube.composeapp.generated.resources.player_options_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_track_details +import spotube.composeapp.generated.resources.queue_title // The expanded player on small screens // on the top left it has a angle/chevron down that can be used to collapse the player back to the @@ -137,7 +169,6 @@ import kotlin.time.Duration.Companion.milliseconds // below that we have the playback controls and the progress bar, which should be similar to the // large player but with a different layout to fit the smaller screen // And at the very end we will have the volume slider -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppExpandedPlayer( modifier: Modifier = Modifier, @@ -152,16 +183,22 @@ fun AppExpandedPlayer( onSleepTimer: () -> Unit = {}, audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), + ) ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val downloadsViewModel: DownloadsViewModel = koinViewModel() val navigationCommands: NavigationCommands = koinInject() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() val currentTrack = remember(currentEntry) { (currentEntry as? QueueEntry.StreamingTrack)?.track } @@ -171,15 +208,17 @@ fun AppExpandedPlayer( downloads.lastOrNull { it.track?.id == t.id } } } - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostState = remember { MillenniumSnackbarHostState() } val albumArtModifier = rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + val coverModel = playerUiState.coverModel var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - var showMoreOptionsSheet by remember { mutableStateOf(false) } - val moreOptionsSheetState = rememberModalBottomSheetState() + val moreOptionsSheetState = rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded + ) + PlayerOptionDialogs(viewModel = playerOptionsViewModel) LaunchedEffect(playerUiState.progress, isSeeking) { if (!isSeeking) { @@ -198,18 +237,22 @@ fun AppExpandedPlayer( } fun onSkipPrevious() { + if (isJamGuest) return scope.launch { audioPlayer.skipToPrevious() } } fun onSkipNext() { + if (isJamGuest) return scope.launch { audioPlayer.skipToNext() } } fun onShuffleToggle() { + if (isJamGuest) return scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } } fun onLoopToggle() { + if (isJamGuest) return scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } } @@ -226,16 +269,24 @@ fun AppExpandedPlayer( } } - Scaffold( - modifier = modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - ) { innerPadding -> - if (showMoreOptionsSheet) { - ModalBottomSheet( - onDismissRequest = { - showMoreOptionsSheet = false - }, - sheetState = moreOptionsSheetState + LaunchedEffect(moreOptionsSheetState.bottomSheetState.currentDetent) { + if (moreOptionsSheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + playerOptionsViewModel.dismissMoreOptionsSheet() + } + } + + LaunchedEffect(playerOptionsUiState.isMoreOptionsSheetOpen) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { + moreOptionsSheetState.animateTo(SheetDetent.FullyExpanded) + } + } + + Box(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { + MillenniumModalBottomSheet( + state = moreOptionsSheetState, + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp), + contentSpacing = 4.dp ) { // Grid of Options LazyVerticalGrid( @@ -243,51 +294,57 @@ fun AppExpandedPlayer( contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { item { OptionTile( icon = Iconsax.SwapHorizontal2, - label = "Alternative Source", + label = stringResource(Res.string.player_alternative_source), onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() onAlternativeSource() - }, - ) + }) } item { val download = currentDownload val status = download?.status val statusLabel = when (status) { - is DownloadStatus.Completed -> "Downloaded" - is DownloadStatus.Failed -> "Failed" - is DownloadStatus.Cancelled -> "Cancelled" - is DownloadStatus.Queued -> "Queued" - is DownloadStatus.Downloading -> "Downloading..." - null -> "Download" + is DownloadStatus.Completed -> stringResource(Res.string.player_download_status_downloaded) + is DownloadStatus.Failed -> stringResource(Res.string.downloads_status_failed) + is DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled) + is DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued) + is DownloadStatus.Downloading -> stringResource(Res.string.player_download_status_downloading) + null -> stringResource(Res.string.player_download) } val statusColor = when (status) { - is DownloadStatus.Completed -> MaterialTheme.colorScheme.primary - is DownloadStatus.Failed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurfaceVariant } - Surface( + Box( modifier = Modifier .aspectRatio(1f) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 2.dp + ) .clickable { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() when (status) { is DownloadStatus.Completed -> { scope.launch { - snackbarHostState.showSnackbar("Already downloaded") + snackbarHostState.showSnackbar(getString(Res.string.player_download_already_downloaded)) } } is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { download?.let { downloadsViewModel.retry(it.id) } scope.launch { - snackbarHostState.showSnackbar("Retrying download...") + snackbarHostState.showSnackbar(getString(Res.string.player_download_retrying)) } } @@ -301,32 +358,28 @@ fun AppExpandedPlayer( downloadsViewModel.downloadTrack(track) scope.launch { snackbarHostState.showSnackbar( - message = "Downloading ${track.title}" + message = getString(Res.string.player_download_started, track.title) ) } } } } onDownloadTrack() - }, - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 1.dp, - ) { + }) { Column( modifier = Modifier .fillMaxSize() .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center ) { if (status is DownloadStatus.Downloading) { DownloadProgressIcon( track = currentTrack, icon = Iconsax.IconsaxDirectboxReceive, - contentDescription = "Download", + contentDescription = stringResource(Res.string.player_download), modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurface, + tint = AppTheme.colors.onSurface ) } else { val icon = when (status) { @@ -336,25 +389,25 @@ fun AppExpandedPlayer( is DownloadStatus.Queued -> Iconsax.InconsaxClock null -> Iconsax.IconsaxDirectboxReceive } - Icon( + MillenniumIcon( imageVector = icon, contentDescription = statusLabel, modifier = Modifier.size(28.dp), tint = when (status) { - is DownloadStatus.Completed -> MaterialTheme.colorScheme.primary - is DownloadStatus.Failed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurface - }, + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurface + } ) } Spacer(modifier = Modifier.height(8.dp)) Text( text = statusLabel, - style = MaterialTheme.typography.labelSmall, + style = AppTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, - color = statusColor, + color = statusColor ) } } @@ -362,26 +415,35 @@ fun AppExpandedPlayer( item { OptionTile( icon = Iconsax.InconsaxClock, - label = "Sleep Timer", + label = stringResource(Res.string.player_sleep_timer), onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() + playerOptionsViewModel.showSleepTimerDialog() onSleepTimer() - }, - ) + }) } item { OptionTile( icon = Iconsax.IconsaxCd, - label = "Go to Album", + label = stringResource(Res.string.player_go_to_album), onClick = { - showMoreOptionsSheet = false + playerOptionsViewModel.dismissMoreOptionsSheet() val albumId = currentTrack?.album?.id if (albumId != null) { navigationCommands.navigateTo(Routes.Album(albumId)) + onCollapse() } onGoToAlbum() - }, - ) + }) + } + item { + OptionTile( + icon = Iconsax.Iconsax3DotsMore, + label = stringResource(Res.string.player_track_details), + onClick = { + playerOptionsViewModel.dismissMoreOptionsSheet() + playerOptionsViewModel.showTrackDetails() + }) } } } @@ -390,10 +452,10 @@ fun AppExpandedPlayer( Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(4.dp)) Row( @@ -401,18 +463,27 @@ fun AppExpandedPlayer( .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton(onClick = onCollapse) { - Icon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") + MillenniumIconButton( + onClick = onCollapse, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxArrowDown4, + contentDescription = stringResource(Res.string.player_collapse_content_description) + ) } Text( - text = "Now Playing", - style = MaterialTheme.typography.titleMedium, + text = stringResource(Res.string.player_now_playing), + style = AppTheme.typography.titleMedium, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, - maxLines = 1, + maxLines = 1 ) - GhostIconButton(onClick = { showMoreOptionsSheet = true }) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") + MillenniumIconButton( + onClick = playerOptionsViewModel::showMoreOptionsSheet, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.Iconsax3DotsMore, contentDescription = stringResource(Res.string.player_options_content_description)) } } @@ -420,7 +491,7 @@ fun AppExpandedPlayer( modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 24.dp), - contentAlignment = Alignment.Center, + contentAlignment = Alignment.Center ) { Box( modifier = Modifier @@ -428,13 +499,13 @@ fun AppExpandedPlayer( .aspectRatio(1f) .then(albumArtModifier) .clip(RoundedCornerShape(22.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .background(AppTheme.colors.surfaceContainerHighest) ) { AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, + contentScale = ContentScale.Crop ) } } @@ -448,38 +519,41 @@ fun AppExpandedPlayer( ) { PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, + savedTracksViewModel = savedTracksViewModel ) Column( modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = playerUiState.title, - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, + textAlign = TextAlign.Center ) - Text( - text = playerUiState.artists, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + // The page opens beneath the player, so get out of the way + onCollapse() + }, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center ) } - IconButton( + MillenniumIconButton( onClick = onQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape) + style = MillenniumIconButtonStyle.Secondary ) { - Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title)) } } Column(modifier = Modifier.padding(top = 20.dp)) { - Slider( + MillenniumSlider( value = seekProgress, onValueChange = { seekProgress = it @@ -490,6 +564,7 @@ fun AppExpandedPlayer( onSeekFinished() }, enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + label = stringResource(Res.string.player_seek_label), modifier = Modifier.fillMaxWidth() ) Row( @@ -497,17 +572,17 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(top = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { Text( text = formatPlayerTime(playerUiState.position), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) Text( text = formatPlayerTime(playerUiState.displayDuration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } Row( @@ -515,49 +590,64 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(top = 16.dp, bottom = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton(onClick = ::onShuffleToggle) { - Icon( + MillenniumIconButton( + onClick = ::onShuffleToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( Iconsax.IconsaxShuffle, - contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", + contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), tint = if (playerUiState.isShuffling) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } ) } - GhostIconButton(onClick = ::onSkipPrevious) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = ::onPlayPause, - theme = invertedButtonStyle().copyShape(CircleShape), - modifier = Modifier - .size(72.dp), + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet ) { - Icon( + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) + } + MillenniumIconButton( + onClick = ::onPlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description), modifier = Modifier.size(30.dp), + tint = LocalTextStyle.current.color ) } - GhostIconButton(onClick = ::onSkipNext) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) } - GhostIconButton(onClick = ::onLoopToggle) { - Icon( + MillenniumIconButton( + onClick = ::onLoopToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.ONE -> Iconsax.IconsaxRepeateOne LoopState.ALL -> Iconsax.IconsaxRepeatMusic }, - contentDescription = "Loop mode ${playerUiState.loopState.name}", + contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), tint = if (playerUiState.loopState == LoopState.NONE) { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } else { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } ) } @@ -568,10 +658,14 @@ fun AppExpandedPlayer( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), - audioPlayer = audioPlayer, - onExpand = onExpandLyrics, + onExpand = onExpandLyrics ) } + + MillenniumSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } @@ -579,14 +673,14 @@ fun AppExpandedPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, + animatedVisibilityScope: AnimatedVisibilityScope? ): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedVisibilityScope ) } } @@ -594,103 +688,50 @@ private fun rememberSharedAlbumArtModifier( @Composable private fun LyricsPreviewCard( modifier: Modifier = Modifier, - audioPlayer: AudioPlayerInterface, onExpand: () -> Unit, viewModel: LyricsViewModel = koinViewModel() ) { val uiState by viewModel.uiState.collectAsState() val syncedLyrics = uiState.syncedLyrics ?: emptyList() val isLoading = uiState.isLoading - - val listState = rememberLazyListState() - val positionMillis by audioPlayer.positionFlow.collectAsState(initial = 0.milliseconds) - - val currentIndex by remember { - derivedStateOf { - if (syncedLyrics.isEmpty()) return@derivedStateOf -1 - var idx = 0 - for (i in syncedLyrics.indices) { - if (syncedLyrics[i].time <= positionMillis.inWholeMilliseconds) { - idx = i - } else { - break - } - } - idx - } - } - - LaunchedEffect(currentIndex) { - if (currentIndex >= 0 && syncedLyrics.isNotEmpty()) { - val centerIndex = currentIndex.coerceIn(0, syncedLyrics.lastIndex) - listState.animateScrollToItem(centerIndex, scrollOffset = -50) - } - } - if (syncedLyrics.isEmpty() || isLoading) return - Surface( - modifier = modifier - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onExpand), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - tonalElevation = 2.dp, + MillenniumCard( + modifier = modifier.clickable(onClick = onExpand), + contentPadding = PaddingValues(12.dp), + contentSpacing = 8.dp, + shadowElevation = 2.dp ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.player_lyrics), + style = AppTheme.typography.labelMedium, + color = MillenniumTheme.colors.muted + ) + MillenniumIconButton( + onClick = onExpand, + style = MillenniumIconButtonStyle.Quiet ) { - Text( - text = "Lyrics", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + MillenniumIcon( + Iconsax.IconsaxArrowSquareUp, + contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), + tint = MillenniumTheme.colors.muted ) - GhostIconButton( - onClick = onExpand, - modifier = Modifier.size(28.dp), - ) { - Icon( - Iconsax.IconsaxArrowSquareUp, - contentDescription = "Expand lyrics", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxWidth() - .height(120.dp), - contentPadding = PaddingValues(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - val displayLines = syncedLyrics.take(6) - itemsIndexed(displayLines) { index, line -> - val isCurrent = - index == currentIndex.coerceIn(0, displayLines.lastIndex.coerceAtLeast(0)) - Text( - text = line.text.ifBlank { "..." }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - style = MaterialTheme.typography.bodySmall.copy( - fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, - color = if (isCurrent) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - } - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } } } + + // Passive preview: no onLineClick, so lines never seek playback. + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = uiState.currentIndex, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) } } @@ -699,38 +740,35 @@ private fun OptionTile( icon: ImageVector, label: String, onClick: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { - Surface( + MillenniumCard( modifier = modifier .aspectRatio(1f) .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 1.dp, + contentPadding = PaddingValues(12.dp) ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = icon, - contentDescription = label, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + MillenniumIcon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + style = AppTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = AppTheme.colors.onSurfaceVariant + ) + } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt similarity index 56% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt index 59178e07..fd64f559 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt @@ -15,8 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress +import org.spotube.millennium.theme.millenniumSurface +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -31,47 +39,39 @@ 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -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.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_nothing_playing import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface + +internal val FloatingPlayerContentHeight = 64.dp +internal val FloatingPlayerBottomBleed = 16.dp // The floating player on small screens that appears above the floating AppBottombar // @@ -92,18 +92,12 @@ fun AppFloatingPlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), + ) ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val scope = rememberCoroutineScope() val albumArtModifier = rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } - var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - - LaunchedEffect(playerUiState.progress) { - seekProgress = playerUiState.progress - } fun onPlayPause() { scope.launch { @@ -115,34 +109,58 @@ fun AppFloatingPlayer( } } - Surface( + FloatingPlayerContent( + playerUiState = playerUiState, + modifier = modifier, + albumArtModifier = albumArtModifier, + onPlayPause = ::onPlayPause, + heartButton = { + PlayerHeartButton( + audioPlayerQueue = audioPlayerQueue, + savedTracksViewModel = savedTracksViewModel + ) + } + ) +} + +@Composable +internal fun FloatingPlayerContent( + playerUiState: PlayerUiState, + modifier: Modifier = Modifier, + albumArtModifier: Modifier = Modifier, + onPlayPause: () -> Unit, + heartButton: @Composable () -> Unit +) { + val coverModel = playerUiState.coverModel + + Box( modifier = modifier .fillMaxWidth() - .height(76.dp), - shape = RoundedCornerShape(24.dp, 24.dp), - color = MaterialTheme.colorScheme.inverseSurface, - contentColor = MaterialTheme.colorScheme.inverseOnSurface, - tonalElevation = 3.dp, + .height(FloatingPlayerContentHeight + FloatingPlayerBottomBleed) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + ) ) { - Box( + Column( modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + // Only the surface extends beneath the navigation bar, not the controls. + .padding(bottom = FloatingPlayerBottomBleed) ) { - LinearProgressIndicator( - progress = { seekProgress.coerceIn(0f, 1f) }, - color = MaterialTheme.colorScheme.inverseOnSurface, - trackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.22f), + MillenniumProgress( + progress = playerUiState.progress, modifier = Modifier .fillMaxWidth() .height(2.dp) ) - + Spacer(modifier = Modifier.height(10.dp)) Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp) - .padding(bottom = 22.dp, top = 8.dp) - .align(Alignment.Center), + .padding(horizontal = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -152,31 +170,36 @@ fun AppFloatingPlayer( ) { Box( modifier = Modifier - .size(48.dp) + .size(42.dp) .then(albumArtModifier) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + .clip(RoundedCornerShape(6.dp)) + .background(AppTheme.colors.onSurface.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center ) { - AsyncImage( + if (coverModel != null) AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier .fillMaxSize(), - contentScale = ContentScale.Crop, + contentScale = ContentScale.Crop + ) + else MillenniumIcon( + Iconsax.IconsaxMusic, contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } Column(modifier = Modifier.padding(start = 10.dp)) { Text( - text = playerUiState.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.inverseOnSurface, + text = if (playerUiState.queue.isEmpty()) stringResource(Res.string.player_nothing_playing) else playerUiState.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( + if (playerUiState.queue.isNotEmpty()) Text( text = playerUiState.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.72f), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -184,21 +207,16 @@ fun AppFloatingPlayer( } Row(verticalAlignment = Alignment.CenterVertically) { - PlayerHeartButton( - audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, - ) + heartButton() Spacer(modifier = Modifier.size(8.dp)) - IconButton( - onClick = ::onPlayPause, + MillenniumIconButton( + onClick = onPlayPause, enabled = playerUiState.queue.isNotEmpty(), - theme = LocalBaseUITheme.current.iconButtons.outline.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) + style = MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description) ) } } @@ -211,14 +229,14 @@ fun AppFloatingPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, + animatedVisibilityScope: AnimatedVisibilityScope? ): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = animatedVisibilityScope ) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt new file mode 100644 index 00000000..3490f2fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt @@ -0,0 +1,524 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + + +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import org.spotube.spotube.resources.iconsax.IconsaxCd +import org.spotube.spotube.resources.iconsax.IconsaxMoon +import org.spotube.spotube.resources.iconsax.IconsaxInformation +import androidx.compose.foundation.background +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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 com.composeunstyled.AnchorSide +import com.composeunstyled.Text +import com.composeunstyled.LocalTextStyle +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.blurEffect +import dev.chrisbanes.haze.hazeEffect +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.downloads.DownloadProgressIcon +import org.spotube.spotube.modules.downloads.DownloadsViewModel +import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross +import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow +import org.spotube.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSlider +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_go_to_album +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_track_details +import kotlin.time.Duration.Companion.milliseconds +import spotube.composeapp.generated.resources.player_alternative_source_content_description +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_download +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_lyrics +import spotube.composeapp.generated.resources.player_more_options_content_description +import spotube.composeapp.generated.resources.player_mute_content_description +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_or_pause_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.player_unmute_content_description +import spotube.composeapp.generated.resources.player_volume_label +import spotube.composeapp.generated.resources.queue_title + +// The main player on large screens shown at the bottom of the screen permanently. It should be +// below the sidebar. And should be similarly give a floating vibe with a translucent background +// just like the AppBottombar. +// +// On the left it contains the album art, song title, artists and like btn grouped together +// On the center it contains the playback controls and the progress bar +// On the right it contains the queue btn, download button btn, alternative track source button +// and three dots for options grouped together and below these buttons the volume slider will be shown +@Composable +fun AppLargePlayer( + modifier: Modifier = Modifier, + hazeState: HazeState, + onQueue: () -> Unit = {}, + onAlternativeSource: () -> Unit = {}, + onMoreOptions: () -> Unit = {}, + onLyrics: () -> Unit = {}, + audioPlayer: AudioPlayerInterface = koinInject(), + audioPlayerQueue: AudioPlayerQueue = koinInject(), + downloadsViewModel: DownloadsViewModel = koinViewModel(), + playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(), + savedTracksViewModel: SavedTracksViewModel = koinViewModel( + key = SAVED_TRACKS_COLLECTION_ID, + parameters = { parametersOf() } + ) +) { + val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val scope = rememberCoroutineScope() + val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() + val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track + val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() + val navigationCommands: NavigationCommands = koinInject() + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } + var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } + val coverModel = playerUiState.coverModel + + PlayerOptionDialogs(viewModel = playerOptionsViewModel) + + LaunchedEffect(playerUiState.progress, isSeeking) { + if (!isSeeking) { + seekProgress = playerUiState.progress + } + } + + LaunchedEffect(playerUiState.volume) { + if (playerUiState.volume > 0f) { + lastNonZeroVolume = playerUiState.volume + } + } + + fun onPlayPause() { + scope.launch { + if (playerUiState.isPlaying) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + } + + fun onSkipPrevious() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToPrevious() } + } + + fun onSkipNext() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToNext() } + } + + fun onShuffleToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } + } + + fun onLoopToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } + } + + fun onSeekFinished() { + val durationMillis = playerUiState.seekDuration.inWholeMilliseconds + if (durationMillis <= 0L) return + scope.launch { + audioPlayer.seekTo( + (durationMillis * seekProgress.coerceIn( + 0f, + 1f + )).toLong().milliseconds + ) + } + } + + // A translucent tint (not near-opaque) so the blurred art behind still reads through, and + // the material's watery caustics sit on top of it. + val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f) + + Column( + modifier = modifier + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + MillenniumSeparator(modifier = Modifier.fillMaxWidth()) + Box( + modifier = Modifier + .fillMaxWidth() + .hazeEffect(state = hazeState) { + blurEffect { + blurRadius = 20.dp + colorEffects = listOf( + HazeColorEffect.tint(surfaceTint) + ) + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.surfaceContainerHighest) + ) { + AsyncImage( + model = coverModel, + contentDescription = playerUiState.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + Column( + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 12.dp) + ) { + Text( + text = playerUiState.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + PlayerArtistsText( + artists = playerUiState.artistList, + onArtistClick = { artistId -> + navigationCommands.navigateTo(Routes.Artist(artistId)) + }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(10.dp)) + PlayerHeartButton( + audioPlayerQueue = audioPlayerQueue, + savedTracksViewModel = savedTracksViewModel + ) + } + + Column( + modifier = Modifier.weight(1.1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MillenniumSlider( + value = seekProgress, + onValueChange = { + seekProgress = it + isSeeking = true + }, + onValueChangeFinished = { + isSeeking = false + onSeekFinished() + }, + enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatPlayerTime(playerUiState.position), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = formatPlayerTime(playerUiState.displayDuration), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + MillenniumIconButton( + onClick = ::onShuffleToggle, + enabled = !isJamGuest, + style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxShuffle, + contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), + tint = if (playerUiState.isShuffling) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) + } + MillenniumIconButton( + onClick = ::onPlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) + } + MillenniumIconButton( + onClick = ::onLoopToggle, + enabled = !isJamGuest, + style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = when (playerUiState.loopState) { + LoopState.NONE -> Iconsax.IconsaxRepeateMusic + LoopState.ONE -> Iconsax.IconsaxRepeateOne + LoopState.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), + tint = if (playerUiState.loopState == LoopState.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary + } + ) + } + } + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.End + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onQueue + ) { + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title)) + } + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + val track = (currentEntry as? QueueEntry.StreamingTrack)?.track + if (track != null) { + downloadsViewModel.downloadTrack(track) + } + }) { + DownloadProgressIcon( + track = (currentEntry as? QueueEntry.StreamingTrack)?.track, + icon = Iconsax.IconsaxDirectboxReceive, + contentDescription = stringResource(Res.string.player_download) + ) + } + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onAlternativeSource + ) { + MillenniumIcon( + Iconsax.SwapHorizontal2, + contentDescription = stringResource(Res.string.player_alternative_source_content_description) + ) + } + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onLyrics + ) { + MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = stringResource(Res.string.player_lyrics)) + } + val goToAlbumLabel = stringResource(Res.string.player_go_to_album) + val sleepTimerLabel = stringResource(Res.string.player_sleep_timer) + val trackDetailsLabel = stringResource(Res.string.player_track_details) + MillenniumDropdownMenu( + expanded = playerOptionsUiState.isMoreOptionsMenuOpen, + onExpandedChange = { expanded -> + if (!expanded) playerOptionsViewModel.dismissMoreOptionsMenu() + }, + side = AnchorSide.Top, + anchor = { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + playerOptionsViewModel.showMoreOptionsMenu() + onMoreOptions() + } + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.player_more_options_content_description) + ) + } + }) { + MillenniumDropdownMenuItem( + label = goToAlbumLabel, + icon = Iconsax.IconsaxCd, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + currentTrack?.album?.id?.let { albumId -> + navigationCommands.navigateTo(Routes.Album(albumId)) + } + }) + MillenniumDropdownMenuItem( + label = sleepTimerLabel, + icon = Iconsax.IconsaxMoon, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showSleepTimerDialog() + }) + MillenniumDropdownMenuItem( + label = trackDetailsLabel, + icon = Iconsax.IconsaxInformation, + onClick = { + playerOptionsViewModel.dismissMoreOptionsMenu() + playerOptionsViewModel.showTrackDetails() + }) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + scope.launch { + if (playerUiState.volume <= 0f) { + audioPlayer.setVolume(lastNonZeroVolume) + } else { + lastNonZeroVolume = playerUiState.volume + audioPlayer.setVolume(0f) + } + } + } + ) { + val volumeIcon = when { + playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross + playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow + else -> Iconsax.IconsaxVolumeHigh + } + MillenniumIcon( + imageVector = volumeIcon, + contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description) + ) + } + MillenniumSlider( + value = playerUiState.volume, + onValueChange = { + lastNonZeroVolume = it + scope.launch { + audioPlayer.setVolume(it) + } + }, + label = stringResource(Res.string.player_volume_label), + modifier = Modifier.fillMaxWidth(0.62f) + ) + } + } + } + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt similarity index 70% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt index 33b03057..fec5626a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt @@ -15,37 +15,47 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -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.LoopState -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.di.rememberLogger +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_fallback_artists +import spotube.composeapp.generated.resources.player_fallback_title +import spotube.composeapp.generated.resources.player_unknown_album + +internal data class PlayerArtist( + val id: String?, + val name: String +) internal data class PlayerUiState( val queue: List = emptyList(), val currentQueueEntry: QueueEntry? = null, val title: String = "No-op track title", val artists: String = "No-op artists", + val artistList: List = emptyList(), val album: String = "", - val coverUrl: String = "", val position: Duration = 0.milliseconds, val actualDuration: Duration = 0.milliseconds, val metadataDuration: Duration = 0.milliseconds, val playerState: PlayerState = PlayerState.IDLE, val loopState: LoopState = LoopState.NONE, val isShuffling: Boolean = false, - val volume: Float = 0f, + val volume: Float = 0f ) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING + + val coverModel: Any? get() = currentQueueEntry?.artworkModel val displayDuration: Duration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration val seekDuration: Duration @@ -63,7 +73,7 @@ internal data class PlayerUiState( queue=${queue.map { it.displayTitle() }}, currentQueueEntry=${currentQueueEntry?.displayTitle()}, title='$title', artists='$artists', - album='$album', coverUrl='$coverUrl', + album='$album', coverModel=${coverModel?.let { it::class.simpleName }}, position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration, playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume ) @@ -74,7 +84,7 @@ internal data class PlayerUiState( @Composable internal fun rememberPlayerUiState( audioPlayer: AudioPlayerInterface, - audioPlayerQueue: AudioPlayerQueue, + audioPlayerQueue: AudioPlayerQueue ): PlayerUiState { val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null) @@ -85,21 +95,24 @@ internal fun rememberPlayerUiState( val isShuffling by audioPlayer.shuffleModeFlow.collectAsState() val volume by audioPlayer.volumeFlow.collectAsState() val metadataDuration = (currentQueueEntry?.durationInMilliseconds() ?: 0L).milliseconds + val fallbackTitle = stringResource(Res.string.player_fallback_title) + val fallbackArtists = stringResource(Res.string.player_fallback_artists) + val unknownAlbum = stringResource(Res.string.player_unknown_album) val state = PlayerUiState( queue = queue, currentQueueEntry = currentQueueEntry, - title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" }, - artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" }, - album = currentQueueEntry?.displayAlbum().orEmpty(), - coverUrl = currentQueueEntry?.coverUrl().orEmpty(), + title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { fallbackTitle }, + artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists }, + artistList = currentQueueEntry?.displayArtistList().orEmpty(), + album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(), position = position, actualDuration = duration, metadataDuration = metadataDuration, playerState = playerState, loopState = loopState, isShuffling = isShuffling, - volume = volume, + volume = volume ) // val logger = rememberLogger() @@ -121,19 +134,18 @@ private fun QueueEntry.displayArtists(): String { } } -private fun QueueEntry.displayAlbum(): String { +private fun QueueEntry.displayArtistList(): List { return when (this) { - is QueueEntry.StreamingTrack -> track.album?.title ?: "Unknown Album" - is QueueEntry.LocalTrack -> album.orEmpty() + is QueueEntry.StreamingTrack -> track.artists.map { PlayerArtist(it.id, it.name) } + // Local tracks have no artist ids, so they are shown but not navigable. + is QueueEntry.LocalTrack -> artists.map { PlayerArtist(id = null, name = it) } } } -private fun QueueEntry.coverUrl(): String { +private fun QueueEntry.displayAlbum(unknownAlbum: String): String { return when (this) { - is QueueEntry.StreamingTrack -> (track.album?.thumbnails - ?: track.thumbnails)?.firstOrNull()?.url.orEmpty() - - is QueueEntry.LocalTrack -> "" + is QueueEntry.StreamingTrack -> track.album?.title ?: unknownAlbum + is QueueEntry.LocalTrack -> album.orEmpty() } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt new file mode 100644 index 00000000..b8f3d388 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt @@ -0,0 +1,434 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Spring +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.composeunstyled.Sheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.UnstyledBottomSheet +import com.composeunstyled.rememberBottomSheetState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.remote.ConnectionRequestDialogHost +import org.spotube.spotube.core.remote.RemotePlaybackController +import org.spotube.spotube.modules.devices.PlayDestinationPickerHost +import org.spotube.spotube.modules.lyrics.LyricsScreen +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContent +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel +import org.spotube.spotube.modules.shell.alternative_track.AlternativeTrackSheet +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContent +import org.spotube.spotube.modules.shell.player_queue.PlayerQueueContentViewModel +import org.spotube.spotube.modules.shell.player_queue.QueueSheet +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.theme.MillenniumTheme + +val LocalAppShellBottomInset = staticCompositionLocalOf { 0.dp } + +@Composable +fun AppShell( + navigator: Navigator, + navigationState: NavigationState, + viewModel: AppShellViewModel = koinViewModel(), + queueViewModel: PlayerQueueContentViewModel = koinViewModel(), + alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel(), + content: @Composable () -> Unit +) { + val navigatorCommands: NavigationCommands = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val snackbarHostState = remember { MillenniumSnackbarHostState() } + LaunchedEffect(remotePlaybackController) { + remotePlaybackController.events.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() + val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() + val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() + + LaunchedEffect(navigatorCommands, navigator) { + launch { + navigatorCommands.navigationCommandFlow.collect { route -> + navigator.navigate(route) + } + } + launch { + navigatorCommands.navigationPopCommandFlow.collect { route -> + navigationState.backStacks[navigationState.topLevelRoute]?.let { backStack -> + backStack.lastOrNull()?.let { currentRoute -> + if (route == null || route == currentRoute) { + navigator.pop() + } + } + } + } + } + } + + // Onboarding is a bootstrap, full-screen destination. It draws its own app bar (and thus + // the desktop window controls), so render it without the sidebar, players or bottom bar. + if (navigationState.topLevelRoute == Routes.Welcome) { + content() + return + } + + ConnectionRequestDialogHost() + PlayDestinationPickerHost() + + Box(modifier = Modifier.fillMaxSize()) { + val useSidebar = viewModel.useSidebar() + val chromeUiState by viewModel.compactChromeUiState.collectAsState() + val density = LocalDensity.current + val navigationBarInset = with(density) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val bottomOverlayInset = + viewModel.bottomOverlayInset(useSidebar, navigationBarInset, chromeUiState) + + CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) { + if (useSidebar) { + val hazeState = rememberHazeState() + val bgHazeState = rememberHazeState() + + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + .hazeSource(state = bgHazeState) // <-- Put it here! + ) + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.weight(1f)) { + AppSidebar( + navigator = navigator, + navigationState = navigationState + ) + MillenniumSeparator( + modifier = Modifier.fillMaxHeight(), + vertical = true + ) + Box( + modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState) + ) { + content() + } + } + AppLargePlayer( + hazeState = hazeState, + modifier = Modifier.fillMaxWidth(), + onQueue = queueViewModel::toggleQueueVisibility, + onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, + onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }) + } + + if (isQueueVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { queueViewModel.setQueueVisibility(false) }) + ) + } + + if (isAlternativeVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { alternativeViewModel.setAlternativeVisibility(false) }) + ) + } + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { queueViewModel.setQueueVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + PlayerQueueContent( + viewModel = queueViewModel, + modifier = Modifier.fillMaxSize() + ) + } + + AlternativeTrackSheet( + isVisible = isAlternativeVisible, + onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + AlternativeTrackContent( + viewModel = alternativeViewModel, + modifier = Modifier.fillMaxSize() + ) + } + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) + ) + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigationState, + viewModel = viewModel, + onQueue = queueViewModel::toggleQueueVisibility, + onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, + onExpandLyrics = viewModel::showLyricsOverlay, + content = content + ) + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { queueViewModel.setQueueVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + PlayerQueueContent( + viewModel = queueViewModel, + modifier = Modifier.fillMaxSize() + ) + } + + AlternativeTrackSheet( + isVisible = isAlternativeVisible, + onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, + modifier = Modifier.fillMaxSize() + ) { + AlternativeTrackContent( + viewModel = alternativeViewModel, + modifier = Modifier.fillMaxSize() + ) + } + + if (isLyricsOverlayVisible) { + Dialog( + onDismissRequest = { viewModel.hideLyricsOverlay() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Box( + modifier = Modifier.fillMaxSize() + .background(MillenniumTheme.colors.background) + ) { + LyricsScreen( + modifier = Modifier.fillMaxSize(), + onClose = { viewModel.hideLyricsOverlay() }) + } + } + } + } + } + } + + // Drawn last so it floats above the players/sheets, just above the + // bottom overlay (large player or compact player + bottombar). + MillenniumSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomOverlayInset + 12.dp) + ) + } +} + +@OptIn( + ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class +) +@Composable +internal fun CompactPlayerOverlay( + navigator: Navigator, + navigationState: NavigationState, + viewModel: AppShellViewModel, + onQueue: () -> Unit, + onAlternativeSource: () -> Unit, + onExpandLyrics: () -> Unit, + floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) }, + expandedPlayer: @Composable (modifier: Modifier, onCollapse: () -> Unit) -> Unit = { modifier, onCollapse -> + AppExpandedPlayer( + modifier = modifier, + onCollapse = onCollapse, + onQueue = onQueue, + onAlternativeSource = onAlternativeSource, + onExpandLyrics = onExpandLyrics + ) + }, + bottomBar: @Composable (Modifier) -> Unit = { + AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it) + }, + content: @Composable () -> Unit +) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val chromeUiState by viewModel.compactChromeUiState.collectAsState() + + val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() } + val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() + val bottomBarHeightPx = with(density) { + chromeUiState.measuredBottomBarHeight?.toPx() + ?: (viewModel.bottomBarHeight.toPx() + navBarInsetPx) + } + val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + val peekHeight = with(density) { peekHeightPx.toDp() } + + val partialDetent = remember(peekHeight) { + SheetDetent("player-peek") { _, _ -> peekHeight } + } + // The library remembers its detent definitions at creation. Recreate the state when + // the measured chrome height changes, rather than keeping a stale peek anchor. + val sheetState = key(peekHeight) { + rememberBottomSheetState( + initialDetent = partialDetent, + detents = listOf(partialDetent, SheetDetent.FullyExpanded), + // A spring carries the fling velocity into the settle, so releasing mid-drag + // glides to the nearest detent instead of stalling and then easing like a tween. + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow) + ) + } + + // Read only inside draw/derived scopes: the sheet offset changes every drag frame, and + // reading it in composition would recompose the whole player on each one. + val progress by remember(sheetState, partialDetent) { + derivedStateOf { + val value = if (!sheetState.offset.isFinite() || + (sheetState.isIdle && sheetState.currentDetent == partialDetent) + ) 0f else runCatching { + sheetState.progress(partialDetent, SheetDetent.FullyExpanded) + }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f + value.coerceIn(0f, 1f) + } + } + // Keyed on the sheet state: it is recreated when the peek height changes, and a derived + // value left reading the previous sheet's progress would never update again. + val expandedPlayerVisible by remember(sheetState) { + derivedStateOf { viewModel.compactSheetUiState(progress).showExpandedPlayer } + } + val floatingPlayerVisible by remember(sheetState) { + derivedStateOf { viewModel.compactSheetUiState(progress).floatingPlayerAlpha > 0.01f } + } + // Once shown, keep the expanded player composed: composing it (view models, lyrics, cover + // art) on the first drag frame of every gesture is the hitch the user feels as lag. + var expandedPlayerComposed by remember { mutableStateOf(false) } + if (expandedPlayerVisible) expandedPlayerComposed = true + + Box(modifier = Modifier.fillMaxSize()) { + content() + + UnstyledBottomSheet( + state = sheetState, + modifier = Modifier.fillMaxSize() + ) { + Sheet(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + if (expandedPlayerComposed) { + expandedPlayer( + Modifier + .fillMaxSize() + .graphicsLayer { + alpha = viewModel.compactSheetUiState(progress).expandedPlayerAlpha + }, + { scope.launch { sheetState.animateTo(partialDetent) } } + ) + } + + // Always composed: building it (view model, cover art, player state) only once + // progress drops below the fade-out point stalls the collapse mid-drag. + Column(modifier = Modifier.fillMaxWidth()) { + floatingPlayer( + Modifier + .zeroSizeUnless(floatingPlayerVisible) + .graphicsLayer { + alpha = viewModel.floatingPlayerAlpha(progress) + }) + } + } + } + } + + bottomBar( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .zeroSizeUnless(floatingPlayerVisible) + .onSizeChanged { size -> + viewModel.onBottomBarMeasured(with(density) { size.height.toDp() }) + } + .graphicsLayer { + alpha = viewModel.floatingPlayerAlpha(progress) + }) + } +} + +/** + * Keeps the content composed but takes it out of layout, drawing and hit testing while it is + * fully faded out, so it neither steals touches from what is above it nor costs a re-composition + * when it comes back. + */ +private fun Modifier.zeroSizeUnless(visible: Boolean): Modifier = + if (visible) this else layout { measurable, constraints -> + measurable.measure(constraints) + layout(0, 0) {} + } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt similarity index 70% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt index ed3bffcc..482acbbf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt @@ -15,11 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell + +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import androidx.lifecycle.ViewModel import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -34,44 +35,50 @@ data class CompactSheetUiState( val showExpandedPlayer: Boolean, val expandedPlayerAlpha: Float, val floatingPlayerAlpha: Float, - val bottomBarAlpha: Float, + val bottomBarAlpha: Float ) data class LyricsOverlayState( - val isVisible: Boolean = false, + val isVisible: Boolean = false ) +data class CompactChromeUiState(val measuredBottomBarHeight: Dp? = null) + class AppShellViewModel : ViewModel() { val sidebarMinWidth = 840.dp - val bottomBarEstimatedHeight = 58.dp val bottomBarHeight = 80.dp - val floatingPlayerHeight = 74.dp + // The background bleed overlaps the bottom bar and is not part of the peek height. + val floatingPlayerHeight = FloatingPlayerContentHeight val largePlayerInset = 100.dp val floatingPlayerDismissProgress = 0.7f val expandedPlayerVisibilityThreshold = 0.001f private val lyricsOverlayVisible = MutableStateFlow(false) + private val compactChrome = MutableStateFlow(CompactChromeUiState()) + val compactChromeUiState = compactChrome.asStateFlow() + + fun onBottomBarMeasured(height: Dp) { + if (height > 0.dp) compactChrome.value = CompactChromeUiState(height) + } val isLyricsOverlayVisible: StateFlow = lyricsOverlayVisible.asStateFlow() - val floatingPlayerBottomOffset: Dp - get() = bottomBarEstimatedHeight + 8.dp - - val compactChromeInset: Dp - get() = floatingPlayerBottomOffset + floatingPlayerHeight + 10.dp - @Composable fun useSidebar(maxWidth: Dp? = null): Boolean { if (maxWidth != null) { return maxWidth >= sidebarMinWidth } - val density = LocalDensity.current - val windowInfo = LocalWindowInfo.current - return with(density) { windowInfo.containerSize.width.toDp() >= sidebarMinWidth } + return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded } - fun bottomOverlayInset(useSidebar: Boolean): Dp = - if (useSidebar) largePlayerInset else compactChromeInset + fun bottomOverlayInset( + useSidebar: Boolean, + navigationBarInset: Dp, + chrome: CompactChromeUiState + ): Dp = + if (useSidebar) largePlayerInset + navigationBarInset + else (chrome.measuredBottomBarHeight ?: (bottomBarHeight + navigationBarInset)) + + floatingPlayerHeight fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float = if (sheetHeightPx > 0f) { @@ -91,7 +98,7 @@ class AppShellViewModel : ViewModel() { showExpandedPlayer = progress > expandedPlayerVisibilityThreshold, expandedPlayerAlpha = progress, floatingPlayerAlpha = floatingPlayerAlpha(progress), - bottomBarAlpha = 1f - progress, + bottomBarAlpha = 1f - progress ) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt new file mode 100644 index 00000000..c28cff4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt @@ -0,0 +1,277 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.LocalTextStyle +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.LocalMillenniumSidebar +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumControlState +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.graphics.RectangleShape +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.NavigationItem +import org.spotube.spotube.core.navigation.NavigationState +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.library.LibraryState +import org.spotube.spotube.modules.library.LibraryTab +import org.spotube.spotube.modules.support.LocalSupportPrompt +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.spotube.spotube.resources.iconsax.IconsaxSetting2 +import org.spotube.spotube.resources.iconsax.IconsaxSidebarLeftBroken +import org.spotube.spotube.resources.iconsax.IconsaxSidebarRightBroken +import org.spotube.spotube.sidebarTabs +import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSidebar +import org.spotube.millennium.composables.MillenniumSidebarContent +import org.spotube.millennium.composables.MillenniumSidebarFooter +import org.spotube.millennium.composables.MillenniumSidebarGroup +import org.spotube.millennium.composables.MillenniumSidebarGroupContent +import org.spotube.millennium.composables.MillenniumSidebarGroupLabel +import org.spotube.millennium.composables.MillenniumSidebarHeader +import org.spotube.millennium.composables.MillenniumSidebarItem +import org.spotube.millennium.composables.MillenniumSidebarMenu +import org.spotube.millennium.composables.MillenniumSidebarTrigger +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.cookie_regular +import spotube.composeapp.generated.resources.settings_screen_title +import spotube.composeapp.generated.resources.support_button_label +import spotube.composeapp.generated.resources.shell_sidebar_toggle_content_description + +@Composable +fun AppSidebar( + navigator: Navigator, + navigationState: NavigationState, + modifier: Modifier = Modifier, + libraryState: LibraryState = koinInject(), + connectStatusViewModel: ConnectStatusViewModel = koinViewModel() +) { + val connectStatus by connectStatusViewModel.uiState.collectAsStateWithLifecycle() + val remoteStatus = connectStatus.remote?.let { remoteStatusLabel(it) } + val jamStatus = connectStatus.jam?.let { jamStatusLabel(it) } + var expanded by rememberSaveable { mutableStateOf(true) } + val groupExpansion = remember { mutableStateMapOf() } + val currentLibraryTab by libraryState.currentTab.collectAsState() + val openSupport = LocalSupportPrompt.current + + MillenniumSidebar( + modifier = modifier, + collapsed = !expanded, + shape = RectangleShape, + onCollapsedChange = { expanded = !it }, + ) { + MillenniumSidebarHeader { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { + Text( + text = "Spotube", + style = AppTheme.typography.headlineLarge.copy( + fontFamily = FontFamily( + listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal)) + ) + ), + color = AppTheme.colors.onSurface + ) + } + MillenniumSidebarTrigger { + MillenniumIconButton( + if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken, + contentDescription = stringResource(Res.string.shell_sidebar_toggle_content_description), + style = MillenniumIconButtonStyle.Quiet, + onClick = { it.toggle() } + ) + } + } + } + MillenniumSidebarContent( + contentSpacing = 0.dp + ) { + sidebarTabs.forEach { entry -> + when (entry) { + is NavigationItem.Group<*> -> { + val isGroupExpanded = groupExpansion[entry.title.key] ?: true + MillenniumSidebarGroup( + expanded = isGroupExpanded, + collapsible = true, + groupSpacing = 0.dp, + onExpandedChange = { groupExpansion[entry.title.key] = it }) { + MillenniumSidebarGroupLabel(stringResource(entry.title)) + MillenniumSidebarGroupContent( + contentSpacing = 0.dp + ) { + MillenniumSidebarMenu(contentSpacing = 0.dp) { + entry.items.forEach { item -> + val selected = + navigationState.topLevelRoute == item.route && + (item.route != Routes.Library || currentLibraryTab == item.data) + SidebarNavItem( + item = item, + selected = selected, + status = when (item.route) { + Routes.Devices -> remoteStatus + Routes.Jam -> jamStatus + else -> null + }, + onClick = { + if (item.route == Routes.Library) { + (item.data as? LibraryTab)?.let { + libraryState.currentTab.value = it + } + } + navigator.navigate(item.route) + }) + } + } + } + } + } + + is NavigationItem.Tab<*> -> { + SidebarNavItem( + item = entry, + selected = navigationState.topLevelRoute == entry.route, + onClick = { navigator.navigate(entry.route) }) + } + } + } + } + // Compact the footer chrome only (padding + gap between items); items keep their size. + MillenniumSidebarFooter( + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp), + contentSpacing = 2.dp + ) { + val supportLabel = stringResource(Res.string.support_button_label) + MillenniumSidebarItem( + label = supportLabel, + onClick = { openSupport() }, + contentDescription = supportLabel, + icon = Iconsax.IconsaxHeart2 + ) + MillenniumSidebarItem( + label = stringResource(Res.string.settings_screen_title), + onClick = { navigator.navigate(Routes.Settings) }, + selected = navigationState.topLevelRoute == Routes.Settings, + contentDescription = stringResource(Res.string.settings_screen_title), + icon = Iconsax.IconsaxSetting2 + ) + } + } +} + +@Composable +private fun SidebarNavItem( + item: NavigationItem.Tab<*>, + selected: Boolean, + onClick: () -> Unit, + /** Live status (e.g. "Playing on Krtx"); when set the row gets a dot and a second line. */ + status: String? = null +) { + val title = stringResource(item.title) + if (status != null) { + LiveSidebarNavItem(item, title, status, selected, onClick) + return + } + MillenniumSidebarItem( + label = title, + onClick = onClick, + selected = selected, + contentDescription = title, + icon = item.icon + ) +} + +@Composable +private fun LiveSidebarNavItem( + item: NavigationItem.Tab<*>, + title: String, + status: String, + selected: Boolean, + onClick: () -> Unit +) { + MillenniumSidebarItem( + onClick = onClick, + selected = selected, + contentDescription = "$title, $status" + ) { + // Match the text overload's icon metrics so live rows line up with their siblings. + val metrics = MillenniumTheme.sidebarTheme.item.resolve(MillenniumControlState.Default) + val contentColor = LocalTextStyle.current.color + Box { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(metrics.iconSize) + ) + LiveDot(Modifier.align(Alignment.TopEnd).offset(x = 3.dp, y = (-3).dp)) + } + if (LocalMillenniumSidebar.current?.collapsed != true) { + Spacer(Modifier.width(metrics.iconLabelSpacing)) + Column(Modifier.weight(1f)) { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = status, + style = AppTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt new file mode 100644 index 00000000..b2c14807 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt @@ -0,0 +1,131 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.spotube.core.ui.theming.AppTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.connect_jam_hosting +import spotube.composeapp.generated.resources.connect_jam_joined +import spotube.composeapp.generated.resources.devices_remote_playing_on +import spotube.composeapp.generated.resources.devices_status_connected + +/** + * Slim "something is live" strip under the Home header: one row while remote-controlling a device, + * one while in a Group Jam. Renders nothing when neither is active. + */ +@Composable +fun ConnectStatusBanner( + onOpenRemote: () -> Unit, + onOpenJam: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectStatusViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val remote = state.remote + val jam = state.jam + if (remote == null && jam == null) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (remote != null) StatusRow(remoteStatusLabel(remote), onOpenRemote) + if (jam != null) StatusRow(jamStatusLabel(jam), onOpenJam) + } +} + +@Composable +private fun StatusRow(label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(AppTheme.shapes.medium) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LiveDot() + Text( + text = label, + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } +} + +@Composable +internal fun remoteStatusLabel(status: RemoteConnectStatus): String = + status.deviceName?.let { stringResource(Res.string.devices_remote_playing_on, it) } + ?: stringResource(Res.string.devices_status_connected) + +@Composable +internal fun jamStatusLabel(status: JamConnectStatus): String = pluralStringResource( + if (status.isHost) Res.plurals.connect_jam_hosting else Res.plurals.connect_jam_joined, + status.listenerCount, + status.listenerCount) + +/** Small "something is live" marker, ringed so it reads on any background. */ +@Composable +internal fun LiveDot(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(10.dp) + .background(MillenniumTheme.colors.success, CircleShape) + .border(2.dp, MillenniumTheme.colors.surface, CircleShape) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt new file mode 100644 index 00000000..c83abac5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt @@ -0,0 +1,57 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient + +/** Set while this app is remote-controlling another device. */ +data class RemoteConnectStatus(val deviceName: String?) + +/** Set while this app is in a Group Jam room. */ +data class JamConnectStatus(val isHost: Boolean, val listenerCount: Int) + +data class ConnectStatusUiState( + val remote: RemoteConnectStatus? = null, + val jam: JamConnectStatus? = null) + +/** Live remote-control / Group Jam status shown by the Home header and the sidebar. */ +class ConnectStatusViewModel( + remoteControlClient: RemoteControlClient, + jamRoomService: JamRoomService +) : ViewModel() { + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + jamRoomService.role, + jamRoomService.participants + ) { connection, role, participants -> + ConnectStatusUiState( + remote = (connection as? ConnectionState.Connected)?.let { + RemoteConnectStatus(it.deviceName.ifBlank { null }) + }, + jam = role?.let { JamConnectStatus(isHost = it == JamRole.Host, listenerCount = participants.size) }) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ConnectStatusUiState()) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt new file mode 100644 index 00000000..56c1f042 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerArtistsText.kt @@ -0,0 +1,82 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme + +/** + * Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id] + * are tappable links that open their artist page; local tracks (no ids) render as plain text. + */ +@Composable +internal fun PlayerArtistsText( + artists: List, + onArtistClick: (String) -> Unit, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Unspecified) { + if (artists.isEmpty()) return + val hoverColor = AppTheme.colors.primary + val annotatedArtists = buildAnnotatedString { + artists.forEachIndexed { index, artist -> + if (index > 0) append(", ") + val artistId = artist.id + if (artistId == null) { + append(artist.name) + } else { + withLink( + LinkAnnotation.Clickable( + tag = artistId, + styles = TextLinkStyles( + style = SpanStyle(textDecoration = TextDecoration.Underline), + hoveredStyle = SpanStyle( + color = hoverColor, + textDecoration = TextDecoration.Underline), + ), + linkInteractionListener = { onArtistClick(artistId) }, + ) + ) { + append(artist.name) + } + } + } + } + Text( + text = annotatedArtists, + modifier = modifier, + style = style, + color = color, + textAlign = textAlign, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt similarity index 51% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt index 1356c83a..732a4d0a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerHeartButton.kt @@ -15,39 +15,41 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import org.spotube.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.modules.saved_tracks.SavedState -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel -import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 -import kotlinx.coroutines.launch +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.saved_tracks.SavedState +import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel +import org.spotube.spotube.modules.saved_tracks.rememberIsSavedTracks +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxHeart +import org.spotube.spotube.resources.iconsax.IconsaxHeart2 +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_like_content_description +import spotube.composeapp.generated.resources.player_unlike_content_description @Composable fun PlayerHeartButton( audioPlayerQueue: AudioPlayerQueue, - savedTracksViewModel: SavedTracksViewModel, -) { - val scope = rememberCoroutineScope() + savedTracksViewModel: SavedTracksViewModel) { val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val isSavedTrackState = rememberIsSavedTracks(trackIds = currentTrackId?.let { listOf(it) } ?: emptyList()) val savedTrackIds by savedTracksViewModel.savedTrackIdsFlow.collectAsStateWithLifecycle() + val pendingSavedTrackIds by savedTracksViewModel.pendingSavedTrackIds.collectAsStateWithLifecycle() val isInSavedIds = if (currentTrackId != null) currentTrackId in savedTrackIds else false val isLiked = if (isSavedTrackState is SavedState.Success) { @@ -56,27 +58,14 @@ fun PlayerHeartButton( isInSavedIds } - fun onLike() { - val entry = currentQueueEntry as? QueueEntry.StreamingTrack ?: return - if (isLiked) { - scope.launch { - savedTracksViewModel.removeSavedTracks(listOf(entry.track.id)) - } - } else { - scope.launch { - savedTracksViewModel.saveTracks(listOf(entry.track.id)) - } - } - } - IconButton( - onClick = ::onLike, + MillenniumIconButton( + onClick = { currentTrackId?.let { savedTracksViewModel.toggleSavedTrack(it, isLiked) } }, enabled = isSavedTrackState is SavedState.Success, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape), - ) { - Icon( + loading = currentTrackId != null && currentTrackId in pendingSavedTrackIds, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, - contentDescription = if (isLiked) "Unlike" else "Like", - tint = if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) + contentDescription = stringResource(if (isLiked) Res.string.player_unlike_content_description else Res.string.player_like_content_description), + tint = if (isLiked) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt new file mode 100644 index 00000000..96cd9ca0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionDialogs.kt @@ -0,0 +1,434 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogFooter +import org.spotube.millennium.composables.MillenniumMarqueeProgress +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive +import org.spotube.spotube.core.server.CacheEntry +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_tab_artists +import spotube.composeapp.generated.resources.media_artist +import spotube.composeapp.generated.resources.player_sleep_timer +import spotube.composeapp.generated.resources.player_sleep_timer_cancel +import spotube.composeapp.generated.resources.player_sleep_timer_description +import spotube.composeapp.generated.resources.player_sleep_timer_minutes_label +import spotube.composeapp.generated.resources.player_sleep_timer_minutes_placeholder +import spotube.composeapp.generated.resources.player_sleep_timer_remaining +import spotube.composeapp.generated.resources.player_sleep_timer_start +import spotube.composeapp.generated.resources.player_track_details +import spotube.composeapp.generated.resources.player_track_details_active_url +import spotube.composeapp.generated.resources.player_track_details_album +import spotube.composeapp.generated.resources.player_track_details_album_genres +import spotube.composeapp.generated.resources.player_track_details_album_id +import spotube.composeapp.generated.resources.player_track_details_album_release_date +import spotube.composeapp.generated.resources.player_track_details_album_track_count +import spotube.composeapp.generated.resources.player_track_details_album_type +import spotube.composeapp.generated.resources.player_track_details_available_streams +import spotube.composeapp.generated.resources.player_track_details_confidence +import spotube.composeapp.generated.resources.player_track_details_disc_number +import spotube.composeapp.generated.resources.player_track_details_duration +import spotube.composeapp.generated.resources.player_track_details_explicit +import spotube.composeapp.generated.resources.player_track_details_external_uri +import spotube.composeapp.generated.resources.player_track_details_id +import spotube.composeapp.generated.resources.player_track_details_isrc +import spotube.composeapp.generated.resources.player_track_details_loading_source +import spotube.composeapp.generated.resources.player_track_details_loading_streams +import spotube.composeapp.generated.resources.player_track_details_matched_source +import spotube.composeapp.generated.resources.player_track_details_matches_preference +import spotube.composeapp.generated.resources.player_track_details_metadata_track +import spotube.composeapp.generated.resources.player_track_details_no_source +import spotube.composeapp.generated.resources.player_track_details_no_streams +import spotube.composeapp.generated.resources.player_track_details_popularity +import spotube.composeapp.generated.resources.player_track_details_selected_stream_content_description +import spotube.composeapp.generated.resources.player_track_details_served_from_cache +import spotube.composeapp.generated.resources.player_track_details_served_from_cache_description +import spotube.composeapp.generated.resources.player_track_details_source_not_needed +import spotube.composeapp.generated.resources.player_track_details_stream_mismatch +import spotube.composeapp.generated.resources.player_track_details_thumbnail_urls +import spotube.composeapp.generated.resources.player_track_details_title +import spotube.composeapp.generated.resources.player_track_details_track_number +import spotube.composeapp.generated.resources.common_close + +@Composable +fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + if (uiState.isSleepTimerDialogOpen) { + SleepTimerDialog( + minutes = uiState.sleepTimerMinutes, + remainingMs = uiState.sleepTimerRemainingMs, + onMinutesChange = viewModel::updateSleepTimerMinutes, + onStart = viewModel::startSleepTimer, + onCancelTimer = viewModel::cancelSleepTimer, + onDismiss = viewModel::dismissSleepTimerDialog) + } + + uiState.trackDetails?.let { details -> + TrackDetailsDialog( + details = details, + onDismiss = viewModel::dismissTrackDetails) + } +} + +@Composable +private fun SleepTimerDialog( + minutes: String, + remainingMs: Long?, + onMinutesChange: (String) -> Unit, + onStart: () -> Unit, + onCancelTimer: () -> Unit, + onDismiss: () -> Unit) { + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.player_sleep_timer)) + MillenniumDialogClose() + } + MillenniumDialogBody { + if (remainingMs != null) { + Text( + text = stringResource(Res.string.player_sleep_timer_remaining, formatRemainingTime(remainingMs)), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + } else { + Text( + text = stringResource(Res.string.player_sleep_timer_description), + style = AppTheme.typography.bodyMedium) + } + val minutesState = rememberTextFieldState(minutes) + LaunchedEffect(minutesState.text) { + onMinutesChange(minutesState.text.toString()) + } + MillenniumTextField( + state = minutesState, + label = stringResource(Res.string.player_sleep_timer_minutes_label), + placeholder = stringResource(Res.string.player_sleep_timer_minutes_placeholder), + modifier = Modifier.fillMaxWidth()) + } + MillenniumDialogFooter { + if (remainingMs != null) { + MillenniumButton( + onClick = onCancelTimer, + style = MillenniumButtonStyle.Quiet) { Text(stringResource(Res.string.player_sleep_timer_cancel)) } + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } + MillenniumButton( + onClick = onStart, + enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) { + Text(stringResource(Res.string.player_sleep_timer_start)) + } + } + } +} + +@Composable +private fun TrackDetailsDialog( + details: PlayerTrackDetails, + onDismiss: () -> Unit) { + MillenniumDialog(visible = true, onDismissRequest = onDismiss) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.player_track_details)) + MillenniumDialogClose() + } + MillenniumDialogBody(contentSpacing = 14.dp) { + Text( + details.track.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + if (details.isLoading) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center) { + MillenniumMarqueeProgress(modifier = Modifier.fillMaxWidth()) + } + } + + if (details.localCacheEntry != null) { + LocalCacheBanner(details.localCacheEntry) + } + if (details.localCacheEntry == null && details.activeStreamMatchesPreference == false) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.tertiaryContainer), + shape = RoundedCornerShape(12.dp))) { + Text( + stringResource(Res.string.player_track_details_stream_mismatch), + modifier = Modifier.padding(12.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onTertiaryContainer) + } + } + + TrackMetadataSection(details.track) + + Text( + stringResource(Res.string.player_track_details_matched_source), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + val source = details.matchedSource + if (details.isLoading) { + Text(stringResource(Res.string.player_track_details_loading_source), style = AppTheme.typography.bodySmall) + } else if (source == null) { + Text( + details.error ?: details.errorRes?.let { stringResource(it) } ?: if (details.localCacheEntry != null) { + stringResource(Res.string.player_track_details_source_not_needed) + } else { + stringResource(Res.string.player_track_details_no_source) + }, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } else { + DetailRow(stringResource(Res.string.player_track_details_title), source.title) + DetailRow(stringResource(Res.string.player_track_details_id), source.id) + DetailRow(stringResource(Res.string.media_artist), source.artist) + DetailRow(stringResource(Res.string.player_track_details_album), source.album) + DetailRow(stringResource(Res.string.player_track_details_confidence), "${(source.confidence * 100).toInt()}%") + DetailRow(stringResource(Res.string.player_track_details_external_uri), source.externalUri) + } + + Text( + stringResource(Res.string.player_track_details_available_streams), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + when { + details.localCacheEntry != null && details.streams.isEmpty() -> Text( + stringResource(Res.string.player_track_details_served_from_cache_description), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + + details.isLoading -> Text(stringResource(Res.string.player_track_details_loading_streams), style = AppTheme.typography.bodySmall) + details.streams.isEmpty() -> Text( + details.error ?: details.errorRes?.let { stringResource(it) } ?: stringResource(Res.string.player_track_details_no_streams), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + + else -> details.streams.forEach { stream -> + val isSelected = stream == details.preferredStream + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor( + if (isSelected) { + AppTheme.colors.primaryContainer + } else { + AppTheme.colors.surfaceContainerHigh + }), + shape = RoundedCornerShape(12.dp))) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = streamFormatAndQuality(stream), + style = AppTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) { + AppTheme.colors.onPrimaryContainer + } else { + AppTheme.colors.onSurface + }, + modifier = Modifier.weight(1f)) + if (isSelected) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCheckCircle, + contentDescription = stringResource(Res.string.player_track_details_selected_stream_content_description), + tint = AppTheme.colors.primary) + } + } + Text( + "${stream.protocol} • ${stream.codec} • ${stream.container}", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + Text( + stream.url, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) + if (isSelected) { + Text( + stringResource(Res.string.player_track_details_matches_preference), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) + } + } + } + } + } + + details.activeStreamUrl?.let { activeUrl -> + DetailRow(stringResource(Res.string.player_track_details_active_url), activeUrl) + } + + } + MillenniumDialogFooter { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) } + } + } +} + +@Composable +private fun LocalCacheBanner(entry: CacheEntry) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.secondaryContainer), + shape = RoundedCornerShape(12.dp))) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = Iconsax.IconsaxDirectboxReceive, + contentDescription = null, + tint = AppTheme.colors.onSecondaryContainer) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(Res.string.player_track_details_served_from_cache), + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSecondaryContainer) + Text( + "${entry.filename} • ${formatFileSize(entry.sizeBytes)}", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer) + } + } + } +} + +@Composable +private fun TrackMetadataSection(track: MetadataTrack) { + Text( + stringResource(Res.string.player_track_details_metadata_track), + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) + DetailRow(stringResource(Res.string.player_track_details_id), track.id) + DetailRow(stringResource(Res.string.player_track_details_title), track.title) + DetailRow(stringResource(Res.string.library_tab_artists), track.artists.joinToString { it.name }) + DetailRow(stringResource(Res.string.player_track_details_album), track.album?.title) + DetailRow(stringResource(Res.string.player_track_details_album_id), track.album?.id) + DetailRow(stringResource(Res.string.player_track_details_album_type), track.album?.albumType?.name) + DetailRow(stringResource(Res.string.player_track_details_album_release_date), track.album?.releaseDate) + DetailRow(stringResource(Res.string.player_track_details_album_track_count), track.album?.trackCount?.toString()) + DetailRow(stringResource(Res.string.player_track_details_album_genres), track.album?.genres?.joinToString()) + DetailRow(stringResource(Res.string.player_track_details_duration), formatRemainingTime(track.durationMs)) + DetailRow(stringResource(Res.string.player_track_details_track_number), track.trackNumber?.toString()) + DetailRow(stringResource(Res.string.player_track_details_disc_number), track.discNumber?.toString()) + DetailRow(stringResource(Res.string.player_track_details_explicit), track.explicit?.toString()) + DetailRow(stringResource(Res.string.player_track_details_popularity), track.popularity?.toString()) + DetailRow(stringResource(Res.string.player_track_details_isrc), track.isrcCode) + DetailRow(stringResource(Res.string.player_track_details_external_uri), track.externalUri) + DetailRow(stringResource(Res.string.player_track_details_thumbnail_urls), track.thumbnails?.joinToString { it.url }) +} + +@Composable +private fun DetailRow(label: String, value: String?) { + if (value.isNullOrBlank()) return + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + label, + modifier = Modifier.weight(0.38f), + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) + Text( + value, + modifier = Modifier.weight(0.62f), + style = AppTheme.typography.bodySmall) + } +} + +private fun streamFormatAndQuality(stream: AudioStream): String = when (stream) { + is AudioStream.Lossy -> "${stream.bitrate / 1_000} kbps" + is AudioStream.Lossless -> { + val rate = if (stream.sampleRate % 1_000 == 0) { + "${stream.sampleRate / 1_000}" + } else { + "${stream.sampleRate / 1_000.0}" + } + "$rate kHz • ${stream.channels} ch" + } +} + +private fun formatRemainingTime(milliseconds: Long): String { + val totalSeconds = (milliseconds / 1_000).coerceAtLeast(0) + val hours = totalSeconds / 3_600 + val minutes = (totalSeconds % 3_600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) { + "$hours:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" + } else { + "$minutes:${seconds.toString().padStart(2, '0')}" + } +} + +private fun formatFileSize(sizeBytes: Long): String = when { + sizeBytes >= 1024L * 1024L -> "${sizeBytes / (1024L * 1024L)} MB" + sizeBytes >= 1024L -> "${sizeBytes / 1024L} KB" + else -> "$sizeBytes B" +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt new file mode 100644 index 00000000..d010d1b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -0,0 +1,244 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.CacheEntry +import org.spotube.spotube.core.server.CacheManager +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.core.server.normalizeManifestUrl +import org.spotube.spotube.core.server.selectPreferredAudioStream +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +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 kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_track_details_load_failed +import spotube.composeapp.generated.resources.player_track_details_no_cached_source +import spotube.composeapp.generated.resources.player_track_details_streams_not_cached + +data class PlayerTrackDetails( + val track: MetadataTrack, + val matchedSource: AudioSource.Basic? = null, + val streams: List = emptyList(), + /** Stream picked by the current streaming format/quality preference. */ + val preferredStream: AudioStream? = null, + /** URL currently used for playback, read from the playback URL cache. */ + val activeStreamUrl: String? = null, + /** Whether the active URL matches what the current preferences select. */ + val activeStreamMatchesPreference: Boolean? = null, + val localCacheEntry: CacheEntry? = null, + val isLoading: Boolean = false, + /** Raw error message (e.g. from an exception); takes precedence over [errorRes]. */ + val error: String? = null, + /** Localized error to show when [error] is null. */ + val errorRes: StringResource? = null) + +data class PlayerOptionsUiState( + val isMoreOptionsMenuOpen: Boolean = false, + val isMoreOptionsSheetOpen: Boolean = false, + val isSleepTimerDialogOpen: Boolean = false, + val sleepTimerMinutes: String = "30", + val sleepTimerRemainingMs: Long? = null, + val trackDetails: PlayerTrackDetails? = null) + +class PlayerOptionsViewModel( + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val matchedTracksRepository: TrackSourceRepository, + private val streamingUrlRepository: StreamingUrlRepository, + private val cacheManager: CacheManager, + private val settingsRepository: SettingsRepository, + private val pluginManager: AudioPluginSource) : ViewModel() { + private val _uiState = MutableStateFlow(PlayerOptionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var sleepTimerJob: Job? = null + private var trackDetailsJob: Job? = null + + fun showMoreOptionsMenu() { + _uiState.update { + it.copy(isMoreOptionsMenuOpen = true, isMoreOptionsSheetOpen = false) + } + } + + fun dismissMoreOptionsMenu() { + _uiState.update { it.copy(isMoreOptionsMenuOpen = false) } + } + + fun showMoreOptionsSheet() { + _uiState.update { + it.copy(isMoreOptionsSheetOpen = true, isMoreOptionsMenuOpen = false) + } + } + + fun dismissMoreOptionsSheet() { + _uiState.update { it.copy(isMoreOptionsSheetOpen = false) } + } + + fun showSleepTimerDialog() { + _uiState.update { it.copy(isSleepTimerDialogOpen = true) } + } + + fun dismissSleepTimerDialog() { + _uiState.update { it.copy(isSleepTimerDialogOpen = false) } + } + + fun updateSleepTimerMinutes(minutes: String) { + if (minutes.all(Char::isDigit)) { + _uiState.update { it.copy(sleepTimerMinutes = minutes) } + } + } + + fun startSleepTimer() { + val minutes = _uiState.value.sleepTimerMinutes.toLongOrNull() + ?.takeIf { it in 1..600 } ?: return + val durationMs = minutes * MILLIS_PER_MINUTE + + sleepTimerJob?.cancel() + sleepTimerJob = viewModelScope.launch { + var remainingMs = durationMs + while (remainingMs > 0L) { + _uiState.update { it.copy(sleepTimerRemainingMs = remainingMs) } + val tickMs = minOf(remainingMs, MILLIS_PER_SECOND) + delay(tickMs.milliseconds) + remainingMs -= tickMs + } + runCatching { audioPlayer.pause() } + _uiState.update { it.copy(sleepTimerRemainingMs = null, isSleepTimerDialogOpen = false) } + } + } + + fun cancelSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + _uiState.update { it.copy(sleepTimerRemainingMs = null) } + } + + fun showTrackDetails() { + val track = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack) + ?.track ?: return + trackDetailsJob?.cancel() + _uiState.update { + it.copy( + trackDetails = PlayerTrackDetails(track = track, isLoading = true)) + } + trackDetailsJob = viewModelScope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + withContext(Dispatchers.IO) { + val pluginId = pluginManager.selectedAudioPlugin.value?.pluginId + val matchedSource = pluginId?.let { matchedTracksRepository.getTrackSource(track, it) } + val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id) + val localCacheEntry = if (settings.enableMusicCaching) { + cacheManager.findCachedEntry(track.id)?.second + } else { + null + } + val cachedStreamSource = cachedStream?.source?.takeIf { + matchedSource == null || it.id == matchedSource.id + } + + val source = matchedSource ?: cachedStreamSource?.toBasic() + val streams = cachedStreamSource?.streams.orEmpty() + val preferredStream = selectPreferredAudioStream( + streams = streams, + preferredFormat = settings.streamingMusicFormat, + preferredQuality = settings.streamingMusicQuality) + val activeStreamUrl = if (cachedStreamSource != null) cachedStream.url else null + val activeStreamMatchesPreference = when { + activeStreamUrl == null || preferredStream == null -> null + else -> activeStreamUrl == normalizeManifestUrl( + url = preferredStream.url, + protocol = preferredStream.protocol) + } + + PlayerTrackDetails( + track = track, + matchedSource = source, + streams = streams, + preferredStream = preferredStream, + activeStreamUrl = activeStreamUrl, + activeStreamMatchesPreference = activeStreamMatchesPreference, + localCacheEntry = localCacheEntry, + errorRes = when { + source == null && localCacheEntry == null -> + Res.string.player_track_details_no_cached_source + streams.isEmpty() && localCacheEntry == null -> + Res.string.player_track_details_streams_not_cached + else -> null + }) + } + }.onSuccess { details -> + _uiState.update { current -> + if (current.trackDetails?.track?.id == track.id) { + current.copy(trackDetails = details) + } else current + } + }.onFailure { error -> + if (error is CancellationException) throw error + _uiState.update { current -> + if (current.trackDetails?.track?.id == track.id) { + current.copy( + trackDetails = PlayerTrackDetails( + track = track, + isLoading = false, + error = error.message, + errorRes = Res.string.player_track_details_load_failed)) + } else current + } + } + } + } + + fun dismissTrackDetails() { + trackDetailsJob?.cancel() + trackDetailsJob = null + _uiState.update { it.copy(trackDetails = null) } + } + + override fun onCleared() { + sleepTimerJob?.cancel() + trackDetailsJob?.cancel() + super.onCleared() + } + + companion object { + private const val MILLIS_PER_SECOND = 1_000L + private const val MILLIS_PER_MINUTE = 60_000L + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerTimeFormat.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerTimeFormat.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt index a07f430c..ea7736ad 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerTimeFormat.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerTimeFormat.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell +package org.spotube.spotube.modules.shell import kotlin.time.Duration diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt similarity index 54% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt index 97e0aa28..68c2ac95 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -15,8 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.alternative_track +package org.spotube.spotube.modules.shell.alternative_track + +import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys +import org.spotube.millennium.composables.MillenniumSpokeSpinner +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,11 +37,10 @@ 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.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import org.spotube.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -48,41 +52,45 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle import org.koin.compose.viewmodel.koinViewModel +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.alternative_track_active_source +import spotube.composeapp.generated.resources.alternative_track_confidence +import spotube.composeapp.generated.resources.alternative_track_empty +import spotube.composeapp.generated.resources.alternative_track_no_track +import spotube.composeapp.generated.resources.alternative_track_searching +import spotube.composeapp.generated.resources.alternative_track_title @Composable fun AlternativeTrackContent( viewModel: AlternativeTrackContentViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val uiState by viewModel.alternativeTrackUiState.collectAsState() LaunchedEffect(uiState.currentTrack?.id) { viewModel.loadAlternatives() } - Surface(modifier = modifier) { + Box(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - text = "Alternative Sources", - style = MaterialTheme.typography.titleLarge, - ) + text = stringResource(Res.string.alternative_track_title), + style = AppTheme.typography.titleLarge) val currentTrack = uiState.currentTrack if (currentTrack == null) { Text( - text = "No track currently playing", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = stringResource(Res.string.alternative_track_no_track), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) return@Column } @@ -90,44 +98,35 @@ fun AlternativeTrackContent( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + verticalAlignment = Alignment.CenterVertically) { + MillenniumSpokeSpinner(size = 24.dp) Spacer(modifier = Modifier.width(12.dp)) Text( - text = "Searching for alternative sources...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = stringResource(Res.string.alternative_track_searching), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } return@Column } if (uiState.alternatives.isEmpty()) { Text( - text = "No alternative sources found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = stringResource(Res.string.alternative_track_empty), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) return@Column } LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items( + verticalArrangement = Arrangement.spacedBy(6.dp)) { + itemsWithUniqueKeys( items = uiState.alternatives, - key = { source -> source.id }, - ) { source -> + id = { source -> source.id }) { source -> AlternativeSourceRow( source = source, isActive = source.id == uiState.activeSourceId, - onClick = { viewModel.selectAlternative(source) }, - ) + onClick = { viewModel.selectAlternative(source) }) } } } @@ -138,67 +137,47 @@ fun AlternativeTrackContent( private fun AlternativeSourceRow( source: AudioSource, isActive: Boolean, - onClick: () -> Unit, -) { + onClick: () -> Unit) { val thumbnailUrl = source.thumbnails.maxByOrNull { it.width * it.height }?.url val rowColor = if (isActive) { - MaterialTheme.colorScheme.secondaryContainer + AppTheme.colors.secondaryContainer } else { - MaterialTheme.colorScheme.surfaceContainerHigh + AppTheme.colors.surfaceContainerHigh } - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = onClick), - tonalElevation = if (isActive) 2.dp else 0.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(64.dp) - .background(rowColor) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + MillenniumListTile( + onClick = onClick, + selected = isActive, + leading = { Box( modifier = Modifier .size(44.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { if (thumbnailUrl != null) { AsyncImage( model = thumbnailUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = source.title.firstOrNull()?.uppercase() ?: "?", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = source.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isActive) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) + }, + title = { + Text( + text = source.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + }, + subtitle = { + Column { val subtitle = buildString { source.artist?.let { append(it) } if (source.album != null) { @@ -209,27 +188,24 @@ private fun AlternativeSourceRow( if (subtitle.isNotEmpty()) { Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } Text( - text = "Confidence: ${(source.confidence * 100).toInt()}%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = stringResource(Res.string.alternative_track_confidence, (source.confidence * 100).toInt()), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } - + }, + trailing = { if (isActive) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckCircle, - contentDescription = "Active source", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) + contentDescription = stringResource(Res.string.alternative_track_active_source), + tint = AppTheme.colors.primary, + modifier = Modifier.size(22.dp)) } - } - } + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt index 0e9bd5b6..891d7baf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.alternative_track +package org.spotube.spotube.modules.shell.alternative_track import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.server.AlternativeTracksRepository +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.server.AlternativeTracksRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -37,13 +37,11 @@ data class AlternativeTrackUiState( val currentTrack: MetadataTrack? = null, val alternatives: List = emptyList(), val activeSourceId: String? = null, - val isLoading: Boolean = false, -) + val isLoading: Boolean = false) class AlternativeTrackContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, - private val alternativeTracksRepository: AlternativeTracksRepository, -) : ViewModel() { + private val alternativeTracksRepository: AlternativeTracksRepository) : ViewModel() { private val alternativeVisibilityFlow = MutableStateFlow(false) private val alternativesFlow = MutableStateFlow>(emptyList()) private val activeSourceIdFlow = MutableStateFlow(null) @@ -55,20 +53,17 @@ class AlternativeTrackContentViewModel( audioPlayerQueue.currentQueueEntryFlow, alternativesFlow, activeSourceIdFlow, - isLoadingFlow, - ) { currentEntry, alternatives, activeSourceId, isLoading -> + isLoadingFlow) { currentEntry, alternatives, activeSourceId, isLoading -> val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track AlternativeTrackUiState( currentTrack = currentTrack, alternatives = alternatives, activeSourceId = activeSourceId, - isLoading = isLoading, - ) + isLoading = isLoading) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = AlternativeTrackUiState(), - ) + initialValue = AlternativeTrackUiState()) fun toggleAlternativeVisibility() { alternativeVisibilityFlow.update { !it } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt new file mode 100644 index 00000000..97db75d2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt @@ -0,0 +1,46 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell.alternative_track + + + +import androidx.compose.foundation.layout.Box +import com.composeunstyled.currentWindowWidthBreakpoint +import org.spotube.millennium.adaptive.WindowBreakpoints +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.spotube.spotube.modules.shell.player_queue.BottomSheetContent +import org.spotube.spotube.modules.shell.player_queue.EndDrawerSheet + +private val SlidingSheetBreakpoint = 840.dp + +@Composable +fun AlternativeTrackSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit) { + Box(modifier = modifier) { + if (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) { + EndDrawerSheet(isVisible, onDismiss, content) + } else { + BottomSheetContent(isVisible, onDismiss, content) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt new file mode 100644 index 00000000..6b3181ac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -0,0 +1,446 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell.player_queue + + +import org.spotube.millennium.composables.MillenniumDropdownMenuItem +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.composeunstyled.Text +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore +import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxDragHandle +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import org.spotube.spotube.resources.iconsax.IconsaxUserRemove +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTooltip +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.queue_clear_queue +import spotube.composeapp.generated.resources.queue_filter_queue +import spotube.composeapp.generated.resources.queue_no_queue_entries +import spotube.composeapp.generated.resources.queue_title +import spotube.composeapp.generated.resources.jam_participants_ban +import spotube.composeapp.generated.resources.jam_participants_host +import spotube.composeapp.generated.resources.player_more_options_content_description +import spotube.composeapp.generated.resources.queue_item_subtitle_added_by +import spotube.composeapp.generated.resources.queue_participant_ban_description +import spotube.composeapp.generated.resources.queue_participant_kick +import spotube.composeapp.generated.resources.queue_participant_kick_description +import spotube.composeapp.generated.resources.queue_participant_remove_suggestions +import spotube.composeapp.generated.resources.queue_participant_remove_suggestions_description +import spotube.composeapp.generated.resources.queue_remove_from_queue +import spotube.composeapp.generated.resources.queue_reorder_content_description + +@Composable +fun PlayerQueueContent( + viewModel: PlayerQueueContentViewModel = koinViewModel(), + modifier: Modifier = Modifier +) { + val state by viewModel.queueContentUiState.collectAsState() + val displayItems = state.displayItems + val filterQuery = state.filterQuery + val isFiltered = state.isFiltered + val isReadOnly = state.isReadOnly + var selectedParticipant by remember { mutableStateOf(null) } + + val lazyListState = rememberLazyListState() + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState, + onMove = { from, to -> + if (isFiltered || isReadOnly) return@rememberReorderableLazyListState + viewModel.onMove(from.index, to.index) + }) + + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(Res.string.queue_title), + style = AppTheme.typography.titleLarge + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val filterState = rememberTextFieldState(filterQuery) + LaunchedEffect(filterState.text) { + viewModel.setQueueFilter(filterState.text.toString()) + } + MillenniumTextField( + state = filterState, + label = "", + placeholder = stringResource(Res.string.queue_filter_queue), + modifier = Modifier.weight(1f) + ) + if (!isReadOnly) { + MillenniumTooltip(stringResource(Res.string.queue_clear_queue)) { + MillenniumIconButton( + onClick = viewModel::clearQueue, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxTrash, + contentDescription = stringResource(Res.string.queue_clear_queue) + ) + } + } + } + } + + if (displayItems.isEmpty()) { + Text( + text = stringResource(Res.string.queue_no_queue_entries), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(bottom = 8.dp) + ) { + items(displayItems, key = { it.id }) { item -> + ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> + QueueItemRow( + item = item, + reorderScope = if (isFiltered || isReadOnly) null else this, + onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, + onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, + onDragStarted = { viewModel.onDragStart() }, + onDragStopped = { viewModel.onDragStop() }, + showOptions = !isReadOnly, + enabled = !isReadOnly, + onParticipantClick = { selectedParticipant = it }) + } + } + } + } + + selectedParticipant?.let { participant -> + ParticipantDialog( + participant = participant, + isJamHost = state.isJamHost, + onDismiss = { selectedParticipant = null }, + onKick = { viewModel.kickParticipant(participant.id) }, + onBan = { viewModel.banParticipant(participant.id) }, + onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }) + } + } + } +} + +@Composable +private fun ParticipantDialog( + participant: JamParticipant, + isJamHost: Boolean, + onDismiss: () -> Unit, + onKick: () -> Unit, + onBan: () -> Unit, + onRemoveSuggestions: () -> Unit +) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { Text(participant.displayName, style = AppTheme.typography.titleLarge) }) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + ParticipantAvatar(participant, size = 40) + Text( + text = participant.displayName, + style = AppTheme.typography.bodyLarge + ) + if (participant.isHost) { + Text( + text = stringResource(Res.string.jam_participants_host), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary + ) + } + } + + if (isJamHost && !participant.isHost) { + MillenniumSeparator( + modifier = Modifier.fillMaxWidth() + ) + MillenniumListTile( + onClick = { + onKick() + onDismiss() + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = null, + tint = AppTheme.colors.error + ) + }, + title = { Text(stringResource(Res.string.queue_participant_kick)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_kick_description)) }) + MillenniumListTile( + onClick = { + onBan() + onDismiss() + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = null, + tint = AppTheme.colors.error + ) + }, + title = { Text(stringResource(Res.string.jam_participants_ban)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_ban_description)) }) + MillenniumListTile( + onClick = { + onRemoveSuggestions() + onDismiss() + }, + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicSquareRemove, + contentDescription = null + ) + }, + title = { Text(stringResource(Res.string.queue_participant_remove_suggestions)) }, + subtitle = { Text(stringResource(Res.string.queue_participant_remove_suggestions_description)) }) + } + } + } +} + +@Composable +private fun ParticipantAvatar(participant: JamParticipant, size: Int) { + Box( + modifier = Modifier + .size(size.dp) + .clip(CircleShape) + .background(AppTheme.colors.primaryContainer), + contentAlignment = Alignment.Center + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onPrimaryContainer + ) + } +} + +@Composable +private fun QueueItemRow( + item: QueueItemUi, + reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit, + onDragStarted: () -> Unit, + onDragStopped: () -> Unit, + showOptions: Boolean = true, + enabled: Boolean = true, + onParticipantClick: (JamParticipant) -> Unit = {} +) { + var showMenu by remember { mutableStateOf(false) } + + MillenniumListTile( + onClick = onPlayClick, + enabled = enabled, + selected = item.isCurrent, + modifier = Modifier, + leading = { + Row( + modifier = Modifier + .height(56.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIcon( + Iconsax.IconsaxDragHandle, + contentDescription = if (reorderScope != null) stringResource(Res.string.queue_reorder_content_description) else null, + modifier = Modifier + .size(24.dp) + .then( + if (reorderScope != null) { + with(reorderScope) { + Modifier.draggableHandle( + onDragStarted = { onDragStarted() }, + onDragStopped = onDragStopped + ) + } + } else { + Modifier + }), + tint = AppTheme.colors.onSurfaceVariant) + + Box( + modifier = Modifier + .size(40.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center + ) { + if (item.artwork != null) { + AsyncImage( + model = item.artwork, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = "${item.originalIndex + 1}", + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + } + }, + title = { + Text( + text = item.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (item.isCurrent) { + AppTheme.colors.onSecondaryContainer + } else { + AppTheme.colors.onSurface + } + ) + }, + subtitle = { + Text( + text = item.addedByParticipant?.let { stringResource(Res.string.queue_item_subtitle_added_by, item.subtitle, it.displayName) } ?: item.subtitle, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + trailing = { + Text( + text = item.durationLabel, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(4.dp)) + + item.addedByParticipant?.let { participant -> + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(AppTheme.colors.surfaceVariant) + .clickable { onParticipantClick(participant) }, + contentAlignment = Alignment.Center + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + if (showOptions) { + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = stringResource(Res.string.player_more_options_content_description) + ) + } + }) { + MillenniumDropdownMenuItem( + label = stringResource(Res.string.queue_remove_from_queue), + icon = Iconsax.IconsaxMusicSquareRemove, + destructive = true, + onClick = { + onRemoveClick() + showMenu = false + }) + } + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt similarity index 71% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 71faa53b..8da73f4c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -15,12 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.shell.player_queue +package org.spotube.spotube.modules.shell.player_queue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.jam.JamParticipant +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -36,19 +39,23 @@ data class QueueItemUi( val subtitle: String, val durationLabel: String, val isCurrent: Boolean, - val imageUrl: String?, + val artwork: Any?, val originalIndex: Int, -) + /** Participant who added this item to the jam queue, if any. */ + val addedByParticipant: JamParticipant? = null) data class QueueContentUiState( val filterQuery: String = "", val displayItems: List = emptyList(), val isFiltered: Boolean = false, -) + /** Guests cannot reorder/remove/clear the shared jam queue. */ + val isReadOnly: Boolean = false, + val isJamHost: Boolean = false, + val participants: List = emptyList()) class PlayerQueueContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { + private val jamRoomService: JamRoomService) : ViewModel() { private val queueVisibilityFlow = MutableStateFlow(false) private val queueFilterFlow = MutableStateFlow("") private val reorderBuffer = MutableStateFlow?>(null) @@ -60,7 +67,7 @@ class PlayerQueueContentViewModel( private val computedItems: StateFlow> = combine( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, - ) { queue, currentEntry -> + jamRoomService.participants) { queue, currentEntry, participants -> val currentIndex = if (currentEntry != null) { queue.indexOfFirst { it.matchesCurrent(currentEntry) } } else { @@ -70,52 +77,51 @@ class PlayerQueueContentViewModel( val title: String val subtitle: String val durationMs: Long - val imageUrl: String? when (entry) { is QueueEntry.StreamingTrack -> { title = entry.track.title subtitle = entry.track.artists.joinToString(", ") { it.name } durationMs = entry.track.durationMs - imageUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url - ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url } is QueueEntry.LocalTrack -> { title = entry.name subtitle = entry.artists.joinToString(", ") durationMs = entry.duration - imageUrl = null } } + val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy } + QueueItemUi( id = "${entry.url}@$index", title = title, subtitle = subtitle, durationLabel = durationMs.toDurationLabel(), isCurrent = index == currentIndex, - imageUrl = imageUrl, + artwork = entry.artworkModel, originalIndex = index, - ) + addedByParticipant = addedByParticipant) } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) + initialValue = emptyList()) val queueContentUiState: StateFlow = combine( computedItems, reorderBuffer, queueFilterFlow, - ) { items, buffer, filterQuery -> + jamRoomService.role, + jamRoomService.participants) { items, buffer, filterQuery, role, participants -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { items.filter { item -> item.title.lowercase().contains(normalizedFilter) || - item.subtitle.lowercase().contains(normalizedFilter) + item.subtitle.lowercase().contains(normalizedFilter) || + item.addedByParticipant?.displayName?.lowercase()?.contains(normalizedFilter) == true } } else { items @@ -124,12 +130,13 @@ class PlayerQueueContentViewModel( filterQuery = filterQuery, displayItems = buffer ?: filtered, isFiltered = isFiltered, - ) + isReadOnly = role == JamRole.Guest, + isJamHost = role == JamRole.Host, + participants = participants) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = QueueContentUiState(), - ) + initialValue = QueueContentUiState()) fun toggleQueueVisibility() { queueVisibilityFlow.update { !it } @@ -144,14 +151,14 @@ class PlayerQueueContentViewModel( } fun playQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.jumpTo(index) } } fun removeQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { val currentQueue = audioPlayerQueue.queueFlow.value if (index < currentQueue.size) { @@ -162,24 +169,49 @@ class PlayerQueueContentViewModel( fun moveQueueItem(fromIndex: Int, toIndex: Int) { if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.move(fromIndex, toIndex) } } fun clearQueue() { + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.clear() } } + // ---------- Jam participant moderation (host only) ---------- + + fun kickParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } + + /** Removes every queue item that the given participant suggested. */ + fun removeParticipantTracks(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { + val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId } + entries.forEach { audioPlayerQueue.removeFromQueue(it) } + } + } + fun onDragStart() { if (reorderBuffer.value != null) return + if (queueContentUiState.value.isReadOnly) return val currentItems = queueContentUiState.value.displayItems reorderBuffer.value = currentItems.toList() } fun onMove(from: Int, to: Int) { + if (queueContentUiState.value.isReadOnly) return val buffer = reorderBuffer.value ?: return if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return val item = buffer[from] diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt new file mode 100644 index 00000000..a15efb6a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/QueueSheet.kt @@ -0,0 +1,111 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.shell.player_queue + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import com.composeunstyled.DrawerPlacement +import com.composeunstyled.SheetDetent +import com.composeunstyled.currentWindowWidthBreakpoint +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumDrawer +import org.spotube.millennium.composables.MillenniumDrawerValue +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.rememberMillenniumDrawerState + +private val SlidingSheetBreakpoint = 840.dp + +@Composable +fun QueueSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val isExpanded = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded + + if (isExpanded) { + EndDrawerSheet(isVisible, onDismiss, content) + } else { + BottomSheetContent(isVisible, onDismiss, content) + } +} + +@Composable +internal fun EndDrawerSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + val drawerState = rememberMillenniumDrawerState() + LaunchedEffect(isVisible) { + drawerState.targetValue = + if (isVisible) MillenniumDrawerValue.Open else MillenniumDrawerValue.Closed + } + LaunchedEffect(drawerState.currentValue) { + if (drawerState.currentValue == MillenniumDrawerValue.Closed && isVisible) { + onDismiss() + } + } + MillenniumDrawer( + state = drawerState, + placement = DrawerPlacement.End, + shape = RectangleShape, + maximumWidth = 400.dp, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + ) { + content() + } +} + +@Composable +internal fun BottomSheetContent( + isVisible: Boolean, + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + if (!isVisible) return + // Show the whole sheet and cap the content instead: a detent that reveals only part of a + // full-height sheet leaves the bottom of the queue list off-screen and unreachable. + val sheetState = rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded) + ) + LaunchedEffect(sheetState.bottomSheetState.currentDetent) { + if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + contentSpacing = 8.dp + ) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.85f)) { + content() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt new file mode 100644 index 00000000..0781aae2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/support/Support.kt @@ -0,0 +1,158 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.support + +import org.spotube.millennium.composables.MillenniumDialogHeader +import org.spotube.millennium.composables.MillenniumDialogTitle +import org.spotube.millennium.composables.MillenniumDialogClose +import org.spotube.millennium.composables.MillenniumDialogBody +import org.spotube.millennium.composables.MillenniumDialogDescription +import org.spotube.millennium.composables.MillenniumDialogFooter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.openUrlInBrowser +import org.spotube.spotube.resources.iconsax.BrandBuyMeACoffee +import org.spotube.spotube.resources.iconsax.BrandOpencollective +import org.spotube.spotube.resources.iconsax.BrandPatreon +import org.spotube.spotube.resources.iconsax.Iconsax +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.support_dialog_dismiss +import spotube.composeapp.generated.resources.support_dialog_message +import spotube.composeapp.generated.resources.support_dialog_reminder_hint +import spotube.composeapp.generated.resources.support_dialog_title +import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle +import spotube.composeapp.generated.resources.welcome_donate_bmac_title +import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle +import spotube.composeapp.generated.resources.welcome_donate_opencollective_title +import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle +import spotube.composeapp.generated.resources.welcome_donate_patreon_title + +/** A single donation destination offered across the app. */ +data class DonationOption( + val id: String, + val title: StringResource, + val subtitle: StringResource, + val url: String, + val icon: ImageVector) + +/** The donation destinations shown in onboarding, settings and the support prompt. */ +val donationOptions: List = listOf( + DonationOption( + id = "patreon", + title = Res.string.welcome_donate_patreon_title, + subtitle = Res.string.welcome_donate_patreon_subtitle, + url = "https://patreon.com/krtirtho", + icon = Iconsax.BrandPatreon), + DonationOption( + id = "buymeacoffee", + title = Res.string.welcome_donate_bmac_title, + subtitle = Res.string.welcome_donate_bmac_subtitle, + url = "https://www.buymeacoffee.com/krtirtho", + icon = Iconsax.BrandBuyMeACoffee), + DonationOption( + id = "opencollective", + title = Res.string.welcome_donate_opencollective_title, + subtitle = Res.string.welcome_donate_opencollective_subtitle, + url = "https://opencollective.com/spotube", + icon = Iconsax.BrandOpencollective)) + +/** Minimum time between two automatic support reminders. */ +const val SupportReminderIntervalMillis: Long = 7L * 24 * 60 * 60 * 1000 + +/** + * Opens the support prompt from anywhere inside the app shell. Hosted at the app root so the + * sidebar, home top bar and the periodic reminder all drive the same dialog. + */ +val LocalSupportPrompt = staticCompositionLocalOf { {} } + +/** Opens a donation destination in the user's browser. */ +fun openDonation(option: DonationOption) { + runCatching { openUrlInBrowser(option.url) } +} + +/** A dialog that lets the user pick a way to support the project. */ +@Composable +fun SupportDialog( + visible: Boolean, + onDismissRequest: () -> Unit, + isReminder: Boolean = false) { + if (!visible) return + MillenniumDialog(visible = true, onDismissRequest = onDismissRequest) { + MillenniumDialogHeader { + MillenniumDialogTitle(stringResource(Res.string.support_dialog_title)) + MillenniumDialogClose() + } + MillenniumDialogBody { + MillenniumDialogDescription(stringResource(Res.string.support_dialog_message)) + if (isReminder) { + Text( + stringResource(Res.string.support_dialog_reminder_hint), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + donationOptions.forEach { option -> + MillenniumButton( + onClick = { + openDonation(option) + onDismissRequest() + }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = option.icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (option.id == "patreon") { + AppTheme.colors.onSurface + } else { + Color.Unspecified + }) + Text(stringResource(option.title)) + } + } + } + } + MillenniumDialogFooter { + MillenniumButton( + onClick = onDismissRequest, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.support_dialog_dismiss)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt new file mode 100644 index 00000000..535929f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateDialog.kt @@ -0,0 +1,67 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.update + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import com.composeunstyled.Text +import com.mikepenz.markdown.m3.Markdown +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumAlertDialog +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.app_update_dialog_no_notes +import spotube.composeapp.generated.resources.app_update_dialog_title +import spotube.composeapp.generated.resources.update_action_ignore +import spotube.composeapp.generated.resources.update_action_update + +/** + * Prompts the user that a newer Spotube release is available on GitHub. [onUpdate] + * should take the user to the downloads page; [onIgnore] persistently silences this + * specific release tag so the check moves on to the next newer one. + */ +@Composable +fun AppUpdateDialog( + update: AvailableAppUpdate, + onDismiss: () -> Unit, + onIgnore: () -> Unit, + onUpdate: () -> Unit) { + MillenniumAlertDialog( + visible = true, + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.app_update_dialog_title, update.tag)) }, + content = { + if (update.releaseNotesMarkdown.isNotBlank()) { + SelectionContainer { + Markdown(content = update.releaseNotesMarkdown) + } + } else { + Text(stringResource(Res.string.app_update_dialog_no_notes)) + } + }) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt new file mode 100644 index 00000000..0bbc8552 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt @@ -0,0 +1,119 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.update + +import org.spotube.spotube.core.network.HttpClientFactory +import co.touchlab.kermit.Logger +import org.spotube.spotube.AppBuildInfo +import org.spotube.spotube.modules.settings.SettingsRepository +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.swiftzer.semver.SemVer +import org.koin.core.component.KoinComponent + +data class AvailableAppUpdate( + val tag: String, + val releaseNotesMarkdown: String) + +/** + * Checks GitHub for a newer app release once on startup and exposes it for the UI to + * surface via [org.spotube.spotube.modules.update.AppUpdateDialog]. This is an + * app-scoped singleton (not a ViewModel) since the check runs once for the whole + * process, independent of any screen's lifecycle. + */ +class AppUpdateService( + private val settingsRepository: SettingsRepository, + httpClientFactory: HttpClientFactory) : KoinComponent { + private val logger = Logger.withTag("AppUpdateService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val httpClient = httpClientFactory.json + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + scope.launch { + checkForUpdate() + } + } + + private suspend fun checkForUpdate() { + try { + // Read directly from DataStore so the check honors persisted preferences, + // rather than the repository StateFlow's default value during startup. + val settings = settingsRepository.getCurrentSettings() + if (!settings.autoCheckForUpdates) return + + val release = httpClient.get(AppBuildInfo.LATEST_RELEASE_API_URL) { + header(HttpHeaders.Accept, "application/vnd.github+json") + header(HttpHeaders.UserAgent, "Spotube/${AppBuildInfo.VERSION}") + }.body() + + val latestVersion = parseVersion(release.tagName.removePrefix("v")) + ?: return logger.w { "Ignoring release with invalid SemVer tag: ${release.tagName}" } + val currentVersion = parseVersion(AppBuildInfo.VERSION) + ?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" } + + if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) { + _availableUpdate.value = AvailableAppUpdate( + tag = release.tagName, + releaseNotesMarkdown = release.body.orEmpty()) + } + } catch (e: Exception) { + logger.w(e) { "Failed to check for app updates" } + } + } + + fun ignoreUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + val settings = settingsRepository.getCurrentSettings() + settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag)) + }.onFailure { e -> + logger.e(e) { "Failed to persist ignored app version" } + } + } + } + + fun dismissUpdate() { + _availableUpdate.value = null + } + + private fun parseVersion(version: String): SemVer? = runCatching { + SemVer.parse(version) + }.getOrNull() +} + +@Serializable +private data class GitHubRelease( + @SerialName("tag_name") val tagName: String, + val body: String? = null) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt new file mode 100644 index 00000000..dbfd236f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateDialog.kt @@ -0,0 +1,88 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.update + +import org.spotube.millennium.composables.MillenniumAlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.text.selection.SelectionContainer +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.m3.Markdown +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_update_dialog_current_version +import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes +import spotube.composeapp.generated.resources.plugin_update_dialog_title +import spotube.composeapp.generated.resources.update_action_ignore +import spotube.composeapp.generated.resources.update_action_update + +/** + * Prompts the user that a newer version of one of their installed plugins is + * available. [onUpdate] should download and install the update; [onIgnore] + * persistently silences this specific plugin version so the check moves on to the + * next newer one (or another plugin). + */ +@Composable +fun PluginUpdateDialog( + update: AvailablePluginUpdate, + onDismiss: () -> Unit, + onIgnore: () -> Unit, + onUpdate: () -> Unit) { + MillenniumAlertDialog( + visible = true, + onDismissRequest = onDismiss, + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing), + title = { + Text( + stringResource( + Res.string.plugin_update_dialog_title, + update.pluginName, + update.info.latestVersion)) + }, + description = { + Text(stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion)) + }, + content = { + if (update.info.releaseNotes.isNotBlank()) { + SelectionContainer { + Markdown(content = update.info.releaseNotes) + } + } else { + Text(stringResource(Res.string.plugin_update_dialog_no_notes)) + } + }) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt new file mode 100644 index 00000000..6c09dbeb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/PluginUpdateService.kt @@ -0,0 +1,146 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.update + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.modules.plugin.PluginEntry +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.plugin.PluginManagerStates +import org.spotube.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import net.swiftzer.semver.SemVer +import org.koin.core.component.KoinComponent + +data class AvailablePluginUpdate( + val pluginId: String, + val pluginName: String, + val currentVersion: String, + val info: PluginUpdateInfo) + +/** + * Checks the currently active plugins for updates through their own [PluginService.use] + * -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and + * exposes at most one pending update at a time for the UI to surface via + * [org.spotube.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped + * singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager] + * independent of any screen's lifecycle. + */ +class PluginUpdateService( + private val pluginManager: PluginManager, + private val settingsRepository: SettingsRepository) : KoinComponent { + private val logger = Logger.withTag("PluginUpdateService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + scope.launch { + pluginManager.ziplineServices.filterNotNull().collect { services -> + checkForUpdates(services.values.filterNotNull()) + } + } + } + + private suspend fun checkForUpdates(services: List) { + // Only surface one plugin update prompt at a time; re-checks happen the + // next time the selected plugin set changes. + if (_availableUpdate.value != null) return + + val settings = settingsRepository.getCurrentSettings() + if (!settings.autoCheckForUpdates) return + + for (service in services.distinctBy { it.pluginId }) { + val pluginEntry = findPluginEntry(service.pluginId) ?: continue + val currentVersion = parseVersion(pluginEntry.version) ?: continue + + try { + val updateInfo = service.use { coreAPI.checkPluginUpdates(currentVersion) } ?: continue + val latestVersion = parseVersion(updateInfo.latestVersion) + if (latestVersion == null) { + logger.w { "Ignoring plugin update with invalid SemVer: ${updateInfo.latestVersion}" } + continue + } + + val ignoredVersion = settings.ignoredPluginUpdateVersions[pluginEntry.id] + if (latestVersion > currentVersion && updateInfo.latestVersion != ignoredVersion) { + _availableUpdate.value = AvailablePluginUpdate( + pluginId = pluginEntry.id, + pluginName = pluginEntry.name, + currentVersion = pluginEntry.version, + info = updateInfo) + return + } + } catch (e: Exception) { + logger.w(e) { "Failed to check updates for plugin ${pluginEntry.name}" } + } + } + } + + private fun findPluginEntry(pluginId: String): PluginEntry? { + val state = pluginManager.state.value as? PluginManagerStates.Data ?: return null + return state.plugins.find { it.id == pluginId } + } + + fun ignoreUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + val settings = settingsRepository.getCurrentSettings() + settingsRepository.updateSettings( + settings.copy( + ignoredPluginUpdateVersions = settings.ignoredPluginUpdateVersions + + (update.pluginId to update.info.latestVersion) + ) + ) + }.onFailure { e -> + logger.e(e) { "Failed to persist ignored plugin update version" } + } + } + } + + fun installUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + scope.launch { + runCatching { + pluginManager.updatePluginFromURL(update.info.directDownloadUrl) + }.onFailure { e -> + logger.e(e) { "Failed to install update for plugin ${update.pluginName}" } + } + } + } + + fun dismissUpdate() { + _availableUpdate.value = null + } + + private fun parseVersion(version: String): SemVer? = runCatching { + SemVer.parse(version) + }.getOrNull() +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/webview/WebViewScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/webview/WebViewScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt index a3a80650..45e3e982 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/webview/WebViewScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/webview/WebViewScreen.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.webview +package org.spotube.spotube.modules.webview import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.core.di.rememberLogger -import dev.krtirtho.spotube.core.webview.PlatformWebViewScreen -import dev.krtirtho.spotube.core.webview.WebViewController +import org.spotube.spotube.core.di.rememberLogger +import org.spotube.spotube.core.webview.PlatformWebViewScreen +import org.spotube.spotube.core.webview.WebViewController internal object WebViewScreen @Composable diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt new file mode 100644 index 00000000..938035f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeContent.kt @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.welcome + +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_get_started +import spotube.composeapp.generated.resources.welcome_lets_go +import spotube.composeapp.generated.resources.welcome_next +import spotube.composeapp.generated.resources.welcome_step_guide +import spotube.composeapp.generated.resources.welcome_step_plugins +import spotube.composeapp.generated.resources.welcome_step_support +import spotube.composeapp.generated.resources.welcome_step_welcome + +/** Order matches the 4 onboarding pages. */ +internal val welcomePrimaryLabels: List = listOf( + Res.string.welcome_lets_go, + Res.string.welcome_next, + Res.string.welcome_next, + Res.string.welcome_get_started) + +/** Short labels for the step switcher. Order matches the 4 onboarding pages. */ +internal val welcomeStepLabels: List = listOf( + Res.string.welcome_step_welcome, + Res.string.welcome_step_support, + Res.string.welcome_step_plugins, + Res.string.welcome_step_guide) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt new file mode 100644 index 00000000..f42c1e66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeModels.kt @@ -0,0 +1,28 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.welcome + +import org.spotube.spotube.modules.support.DonationOption +import org.jetbrains.compose.resources.StringResource + +data class WelcomeUiState( + val currentPageIndex: Int, + val pageCount: Int, + val isLastPage: Boolean, + val primaryLabel: StringResource, + val donations: List) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt new file mode 100644 index 00000000..14014419 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeRepository.kt @@ -0,0 +1,110 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.welcome + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import org.spotube.spotube.core.DebugConfig +import org.spotube.spotube.core.db.Database +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Owns whether the Welcome/Onboarding flow should be the app's start destination. + * + * This lives in its own preference rather than [org.spotube.spotube.modules.settings.UserSettings] + * so the very first launch can be distinguished from "settings not read yet". It is a Koin + * singleton so the app-level start route and the routed [WelcomeScreen] observe the same state. + */ +class WelcomeRepository(private val database: Database) { + companion object { + private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed") + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Set when onboarding is finished for this process only; does not persist (debug forcing). */ + private val sessionDismissed = MutableStateFlow(false) + + /** Synchronous signal, available before the persisted flag loads; true on upgrades. */ + val isExistingInstall: Boolean get() = database.isExistingInstall + + private val onboardingCompleted: StateFlow = database.settingsDataStore.data + .map { prefs -> prefs[ONBOARDING_COMPLETED_KEY] ?: database.isExistingInstall } + .stateIn(scope, SharingStarted.Eagerly, null) + + /** + * Whether the app should start on the Welcome/Onboarding route instead of the main shell. + * + * - `true` on a fresh install, or whenever [DebugConfig.FORCE_SHOW_ONBOARDING] is enabled. + * - `false` on an existing install (upgrade) or once onboarding is completed or skipped, + * including within a forced session. + * + * The initial value is derived from [isExistingInstall] so the start route can be chosen on + * the very first frame without flashing the wrong screen. + */ + val shouldShowOnboarding: StateFlow = combine( + onboardingCompleted, + sessionDismissed) { completed, dismissed -> + when { + dismissed -> false + DebugConfig.FORCE_SHOW_ONBOARDING -> true + else -> !(completed ?: database.isExistingInstall) + } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall) + + init { + // One-time migration. When the flag was never written this is either a brand-new install + // (show onboarding) or an upgrade of an existing install (skip it). Persisting the + // decision also stops the fresh install's own data writes from being mistaken for an + // upgrade on the next launch. + scope.launch { + database.settingsDataStore.edit { prefs -> + if (ONBOARDING_COMPLETED_KEY !in prefs) { + prefs[ONBOARDING_COMPLETED_KEY] = database.isExistingInstall + } + } + } + } + + suspend fun completeOnboarding() { + sessionDismissed.value = true + database.settingsDataStore.edit { prefs -> + prefs[ONBOARDING_COMPLETED_KEY] = true + } + } + + /** Development helper to surface the welcome screen again on the next launch. */ + suspend fun resetOnboarding() { + database.settingsDataStore.edit { prefs -> + prefs[ONBOARDING_COMPLETED_KEY] = false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt new file mode 100644 index 00000000..d87384cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeScreen.kt @@ -0,0 +1,183 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.welcome + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTabGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.welcome.pages.BrandingPage +import org.spotube.spotube.modules.welcome.pages.DonationPage +import org.spotube.spotube.modules.welcome.pages.HowItWorksPage +import org.spotube.spotube.modules.welcome.pages.PluginsPage +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_skip + +/** + * First-run Welcome/Onboarding screen. It is a route + * ([org.spotube.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell. + * + * The steps are exposed through a Millennium tab group rather than ad-hoc dots or a footer + * walk-through, so the screen reads as one deliberate panel on every window size. + * Each step owns its content and the footer only carries the forward action. + */ +@Composable +fun WelcomeScreen(viewModel: WelcomeViewModel) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + WelcomeTheme { + Box(Modifier.fillMaxSize()) { + WelcomeBackground() + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + WelcomeWordmark( + fontSize = AppTheme.typography.titleLarge.fontSize) + }, + backButton = false, + transparent = true, + actions = { + if (!state.isLastPage) { + MillenniumButton( + onClick = viewModel::completeOnboarding, + style = MillenniumButtonStyle.Quiet) { + Text( + text = stringResource(Res.string.welcome_skip), + color = AppTheme.colors.onSurfaceVariant) + } + } + }) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + contentAlignment = Alignment.Center) { + MillenniumCard( + modifier = Modifier + .widthIn(max = 1040.dp) + .fillMaxWidth() + .fillMaxHeight(), + contentPadding = PaddingValues(0.dp)) { + Column(Modifier.fillMaxSize()) { + WelcomeStepSwitcher( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + state = state, + viewModel = viewModel) + MillenniumSeparator() + WelcomeActionBar( + state = state, + viewModel = viewModel) + } + } + } + } + } + } +} + +@Composable +private fun WelcomeStepSwitcher( + state: WelcomeUiState, + viewModel: WelcomeViewModel, + modifier: Modifier = Modifier) { + val labels = welcomeStepLabels.map { stringResource(it) } + MillenniumTabGroup( + selectedTab = state.currentPageIndex, + onSelectedTabChange = viewModel::goToPage, + tabs = labels.indices.toList(), + modifier = modifier, + tabLabel = { labels[it] }) { index -> + when (index) { + 0 -> BrandingPage() + + 1 -> DonationPage( + donations = state.donations, + onDonate = viewModel::openDonation) + + 2 -> PluginsPage() + + else -> HowItWorksPage() + } + } +} + +@Composable +private fun WelcomeActionBar( + state: WelcomeUiState, + viewModel: WelcomeViewModel) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.weight(1f)) + MillenniumButton( + onClick = viewModel::onPrimary, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(state.primaryLabel), + fontWeight = FontWeight.SemiBold) + MillenniumIcon( + imageVector = if (state.isLastPage) { + Iconsax.IconsaxCheckCircle + } else { + Iconsax.IconsaxNext + }, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt new file mode 100644 index 00000000..0320a529 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeViewModel.kt @@ -0,0 +1,78 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.welcome + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.modules.support.donationOptions +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class WelcomeViewModel( + private val repository: WelcomeRepository) : ViewModel() { + private val currentPageIndex = MutableStateFlow(0) + + val uiState: StateFlow = + currentPageIndex.map { index -> + val pageCount = welcomePrimaryLabels.size + WelcomeUiState( + currentPageIndex = index, + pageCount = pageCount, + isLastPage = index == pageCount - 1, + primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], + donations = donationOptions) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = WelcomeUiState( + currentPageIndex = 0, + pageCount = welcomePrimaryLabels.size, + isLastPage = welcomePrimaryLabels.size == 1, + primaryLabel = welcomePrimaryLabels.first(), + donations = donationOptions)) + + fun goToPage(index: Int) { + currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) } + } + + fun onPrimary() { + if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) { + completeOnboarding() + } else { + goToPage(currentPageIndex.value + 1) + } + } + + fun openDonation(url: String) { + // Only destinations owned by our static funding list can be opened here. + if (donationOptions.none { it.url == url }) return + runCatching { org.spotube.spotube.openUrlInBrowser(url) } + } + + fun completeOnboarding() { + viewModelScope.launch { + repository.completeOnboarding() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt new file mode 100644 index 00000000..ae732f68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/WelcomeVisuals.kt @@ -0,0 +1,69 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.welcome + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.TextUnit +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.theming.AppTheme +import org.jetbrains.compose.resources.Font +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.* + +/** + * Reapplies the Millennium theme for the onboarding pages using the app's active configuration, + * so onboarding matches the palette and appearance used by the rest of the app. + */ +@Composable +fun WelcomeTheme(content: @Composable () -> Unit) { + val dark = AppTheme.colors.surface.luminance() < 0.5f + MillenniumTheme( + dark = dark, + config = MillenniumTheme.config, + content = content) +} + +@Composable +fun WelcomeBackground(modifier: Modifier = Modifier) { + val scheme = AppTheme.colors + Box( + modifier.fillMaxSize().background( + Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface))) + ) +} + +@Composable +fun WelcomeWordmark( + modifier: Modifier = Modifier, + fontSize: TextUnit = AppTheme.typography.displaySmall.fontSize +) { + Text( + stringResource(Res.string.welcome_app_name), + modifier = modifier, + fontFamily = FontFamily(Font(Res.font.cookie_regular)), + fontSize = fontSize, + color = AppTheme.colors.onSurface) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt new file mode 100644 index 00000000..9df108f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/BrandingPage.kt @@ -0,0 +1,43 @@ +package org.spotube.spotube.modules.welcome.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.spotube.spotube.modules.welcome.WelcomeWordmark +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@Composable +fun BrandingPage(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp)) + Spacer(Modifier.height(24.dp)) + WelcomeWordmark(fontSize = 48.sp) + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.welcome_brand_freedom), + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + Text( + stringResource(Res.string.welcome_brand_quiet_description), + modifier = Modifier.widthIn(max = 440.dp), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt new file mode 100644 index 00000000..6217e7b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/DonationPage.kt @@ -0,0 +1,72 @@ +package org.spotube.spotube.modules.welcome.pages + + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.spotube.spotube.modules.support.DonationOption +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DonationPage( + donations: List, + onDonate: (String) -> Unit, + modifier: Modifier = Modifier) { + Column( + modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Text( + stringResource(Res.string.welcome_donate_quiet_title), + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) + Spacer(Modifier.height(20.dp)) + Text( + stringResource(Res.string.welcome_donate_letter), + modifier = Modifier.widthIn(max = 540.dp), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + Spacer(Modifier.height(32.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + donations.forEach { option -> + MillenniumButton( + onClick = { onDonate(option.url) }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + option.icon, null, Modifier.size(24.dp), + tint = if (option.id == "patreon") AppTheme.colors.onSurface else Color.Unspecified) + Text(stringResource(option.title)) + } + } + } + } + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.welcome_donate_optional), + modifier = Modifier.widthIn(max = 500.dp), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt new file mode 100644 index 00000000..e8764fa5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -0,0 +1,98 @@ +package org.spotube.spotube.modules.welcome.pages + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumListTile +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll + +import org.spotube.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentDownload +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import org.spotube.spotube.resources.iconsax.IconsaxSearch +import org.spotube.spotube.resources.iconsax.User +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.welcome_guide_lyrics_desc +import spotube.composeapp.generated.resources.welcome_guide_lyrics_title +import spotube.composeapp.generated.resources.welcome_guide_offline_desc +import spotube.composeapp.generated.resources.welcome_guide_offline_title +import spotube.composeapp.generated.resources.welcome_guide_search_desc +import spotube.composeapp.generated.resources.welcome_guide_search_title +import spotube.composeapp.generated.resources.welcome_guide_signin_desc +import spotube.composeapp.generated.resources.welcome_guide_signin_title +import spotube.composeapp.generated.resources.welcome_guide_subtitle +import spotube.composeapp.generated.resources.welcome_guide_title + +/** + * The onboarding guide: a short, concrete answer to "how do I actually use this app?". + * No marketing prose — just the four things a new user needs to do. + */ +@Composable +fun HowItWorksPage(modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(Res.string.welcome_guide_title), + style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif)) + Text( + stringResource(Res.string.welcome_guide_subtitle), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant) + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WelcomeGuideStep( + icon = Iconsax.User, + title = Res.string.welcome_guide_signin_title, + description = Res.string.welcome_guide_signin_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxSearch, + title = Res.string.welcome_guide_search_title, + description = Res.string.welcome_guide_search_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxDocumentDownload, + title = Res.string.welcome_guide_offline_title, + description = Res.string.welcome_guide_offline_desc) + WelcomeGuideStep( + icon = Iconsax.IconsaxMusic, + title = Res.string.welcome_guide_lyrics_title, + description = Res.string.welcome_guide_lyrics_desc) + } + } +} + +@Composable +private fun WelcomeGuideStep( + icon: ImageVector, + title: StringResource, + description: StringResource, +) { + MillenniumListTile( + leading = { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary) + }, + title = { Text(stringResource(title)) }, + subtitle = { Text(stringResource(description)) }) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt new file mode 100644 index 00000000..3bbcb709 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/welcome/pages/PluginsPage.kt @@ -0,0 +1,10 @@ +package org.spotube.spotube.modules.welcome.pages + +import androidx.compose.runtime.Composable +import org.spotube.spotube.modules.plugin.PluginScreen + +/** The same installation, discovery, permissions and sign-in flow as Settings, in-place. */ +@Composable +fun PluginsPage() { + PluginScreen(onboarding = true) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/ArrowLeft3.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/ArrowLeft3.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt index d51ba7ce..573512aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/ArrowLeft3.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/ArrowLeft3.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt new file mode 100644 index 00000000..4b70c390 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandBuyMeACoffee.kt @@ -0,0 +1,264 @@ +package org.spotube.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.BrandBuyMeACoffee: ImageVector + get() { + if (_BrandBuyMeACoffee != null) { + return _BrandBuyMeACoffee!! + } + _BrandBuyMeACoffee = ImageVector.Builder( + name = "BrandBuyMeACoffee", + defaultWidth = 512.dp, + defaultHeight = 512.dp, + viewportWidth = 512f, + viewportHeight = 512f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(512f) + verticalLineToRelative(512f) + horizontalLineTo(0f) + close() + } + ) { + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.17f, 256.45f) + lineToRelative(-0.35f, -0.21f) + lineTo(255f, 256f) + arcToRelative(1.93f, 1.93f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.16f, 0.45f) + close() + moveTo(256.15f, 256.04f) + arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.15f, -0.04f) + arcToRelative(0.54f, 0.54f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 0.1f) + arcToRelative(0.3f, 0.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0.15f, -0.06f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256f, 256.03f) + horizontalLineToRelative(0.05f) + verticalLineTo(256f) + lineToRelative(-0.05f, 0.03f) + close() + moveTo(255f, 256.67f) + lineToRelative(0.59f, -0.34f) + lineToRelative(0.22f, -0.12f) + lineToRelative(0.2f, -0.21f) + arcToRelative(3.39f, 3.39f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.01f, 0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(255.97f, 256.77f) + lineToRelative(-0.58f, -0.55f) + lineTo(255f, 256f) + curveToRelative(0.21f, 0.37f, 0.56f, 0.65f, 0.97f, 0.76f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.18f, 256f) + curveToRelative(-0.46f, 0.2f, -0.87f, 0.51f, -1.18f, 0.91f) + lineToRelative(0.37f, -0.23f) + curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.2f, 255.27f) + curveToRelative(0f, -0.52f, -0.25f, -0.43f, -0.19f, 1.43f) + curveToRelative(0f, -0.15f, 0.06f, -0.3f, 0.09f, -0.44f) + curveToRelative(0.04f, -0.33f, 0.06f, -0.66f, 0.1f, -0.99f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.18f, 256f) + arcToRelative(3.02f, 3.02f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.18f, 0.91f) + lineToRelative(0.37f, -0.23f) + curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256.24f, 256.58f) + arcTo(2.52f, 2.52f, 0f, isMoreThanHalf = false, isPositiveArc = false, 255f, 256f) + curveToRelative(0.37f, 0.18f, 0.74f, 0.36f, 0.99f, 0.5f) + lineToRelative(0.25f, 0.08f) + close() + moveTo(256.49f, 256.54f) + arcTo(3.96f, 3.96f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 255f) + curveToRelative(0.19f, 0.49f, 0.35f, 1f, 0.47f, 1.51f) + lineToRelative(0.01f, 0.03f) + close() + } + path(fill = SolidColor(Color(0xFFFFDD00))) { + moveTo(267.82f, 145.08f) + curveToRelative(-18.4f, 7.87f, -39.28f, 16.8f, -66.33f, 16.8f) + arcTo(125.64f, 125.64f, 0f, isMoreThanHalf = false, isPositiveArc = true, 168f, 157.26f) + lineToRelative(18.71f, 192.07f) + arcToRelative(32.09f, 32.09f, 0f, isMoreThanHalf = false, isPositiveArc = false, 32f, 29.45f) + reflectiveCurveToRelative(26.53f, 1.38f, 35.39f, 1.38f) + curveToRelative(9.53f, 0f, 38.1f, -1.38f, 38.1f, -1.38f) + arcToRelative(32.1f, 32.1f, 0f, isMoreThanHalf = false, isPositiveArc = false, 31.99f, -29.45f) + lineToRelative(20.04f, -212.25f) + curveToRelative(-8.96f, -3.06f, -18f, -5.09f, -28.19f, -5.09f) + curveToRelative(-17.63f, -0.01f, -31.83f, 6.06f, -48.23f, 13.08f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(256f, 256f) + lineToRelative(0.32f, 0.3f) + lineToRelative(0.21f, 0.12f) + arcTo(3.21f, 3.21f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 256f) + close() + } + path(fill = SolidColor(Color(0xFF0D0C22))) { + moveTo(431.23f, 136.85f) + lineToRelative(-2.82f, -14.21f) + curveToRelative(-2.53f, -12.75f, -8.27f, -24.8f, -21.36f, -29.41f) + curveToRelative(-4.2f, -1.47f, -8.96f, -2.11f, -12.18f, -5.16f) + curveToRelative(-3.22f, -3.05f, -4.17f, -7.79f, -4.91f, -12.18f) + curveToRelative(-1.38f, -8.07f, -2.67f, -16.14f, -4.09f, -24.19f) + curveToRelative(-1.22f, -6.92f, -2.18f, -14.7f, -5.36f, -21.05f) + curveToRelative(-4.13f, -8.53f, -12.71f, -13.51f, -21.24f, -16.81f) + arcToRelative(122.38f, 122.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, -13.36f, -4.13f) + curveToRelative(-21.31f, -5.62f, -43.72f, -7.69f, -65.64f, -8.86f) + arcToRelative(551.32f, 551.32f, 0f, isMoreThanHalf = false, isPositiveArc = false, -78.96f, 1.31f) + curveToRelative(-19.54f, 1.78f, -40.12f, 3.93f, -58.69f, 10.68f) + curveToRelative(-6.79f, 2.47f, -13.78f, 5.44f, -18.94f, 10.68f) + curveToRelative(-6.33f, 6.44f, -8.4f, 16.4f, -3.78f, 24.43f) + curveToRelative(3.29f, 5.7f, 8.85f, 9.73f, 14.76f, 12.4f) + arcToRelative(119.72f, 119.72f, 0f, isMoreThanHalf = false, isPositiveArc = false, 23.97f, 7.8f) + curveToRelative(22.94f, 5.07f, 46.71f, 7.06f, 70.15f, 7.91f) + arcToRelative(534.38f, 534.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, 77.86f, -2.54f) + arcToRelative(443.55f, 443.55f, 0f, isMoreThanHalf = false, isPositiveArc = false, 19.15f, -2.53f) + curveToRelative(7.5f, -1.15f, 12.32f, -10.96f, 10.11f, -17.79f) + curveToRelative(-2.65f, -8.17f, -9.76f, -11.34f, -17.8f, -10.1f) + curveToRelative(-1.18f, 0.19f, -2.36f, 0.36f, -3.55f, 0.53f) + lineToRelative(-0.85f, 0.12f) + curveToRelative(-2.72f, 0.34f, -5.45f, 0.67f, -8.17f, 0.96f) + arcToRelative(433.76f, 433.76f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.92f, 1.49f) + curveToRelative(-12.66f, 0.88f, -25.36f, 1.29f, -38.06f, 1.31f) + curveToRelative(-12.47f, 0f, -24.95f, -0.35f, -37.39f, -1.17f) + arcToRelative(475.74f, 475.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.99f, -1.42f) + curveToRelative(-2.57f, -0.27f, -5.13f, -0.55f, -7.7f, -0.87f) + lineToRelative(-2.44f, -0.31f) + lineToRelative(-0.53f, -0.08f) + lineToRelative(-2.53f, -0.37f) + curveToRelative(-5.17f, -0.78f, -10.34f, -1.67f, -15.45f, -2.76f) + arcToRelative(2.32f, 2.32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, -4.53f) + horizontalLineToRelative(0.1f) + arcToRelative(250.82f, 250.82f, 0f, isMoreThanHalf = false, isPositiveArc = true, 13.37f, -2.45f) + curveToRelative(1.49f, -0.23f, 2.99f, -0.46f, 4.49f, -0.69f) + horizontalLineToRelative(0.04f) + curveToRelative(2.81f, -0.19f, 5.62f, -0.69f, 8.41f, -1.02f) + arcToRelative(535.29f, 535.29f, 0f, isMoreThanHalf = false, isPositiveArc = true, 73.1f, -2.58f) + curveToRelative(11.84f, 0.34f, 23.68f, 1.04f, 35.47f, 2.24f) + arcToRelative(391.9f, 391.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7.58f, 0.85f) + curveToRelative(0.96f, 0.12f, 1.94f, 0.25f, 2.91f, 0.37f) + lineToRelative(1.96f, 0.28f) + curveToRelative(5.7f, 0.85f, 11.38f, 1.88f, 17.03f, 3.09f) + curveToRelative(8.36f, 1.82f, 19.11f, 2.41f, 22.83f, 11.57f) + curveToRelative(1.18f, 2.91f, 1.72f, 6.14f, 2.38f, 9.19f) + lineToRelative(0.83f, 3.89f) + curveToRelative(0.02f, 0.07f, 0.04f, 0.14f, 0.05f, 0.21f) + curveToRelative(1.97f, 9.18f, 3.94f, 18.37f, 5.92f, 27.55f) + arcToRelative(5.05f, 5.05f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.24f, 6.05f) + horizontalLineToRelative(-0.05f) + lineToRelative(-1.21f, 0.17f) + lineToRelative(-1.19f, 0.16f) + curveToRelative(-3.78f, 0.49f, -7.56f, 0.95f, -11.34f, 1.38f) + arcToRelative(732.52f, 732.52f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.4f, 2.2f) + arcToRelative(784.15f, 784.15f, 0f, isMoreThanHalf = false, isPositiveArc = true, -44.67f, 2.43f) + arcToRelative(802.63f, 802.63f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.8f, 0.28f) + arcToRelative(791.3f, 791.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, -90.5f, -5.26f) + arcToRelative(944.16f, 944.16f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.76f, -1.22f) + curveToRelative(2.52f, 0.32f, -1.83f, -0.25f, -2.71f, -0.37f) + curveToRelative(-2.07f, -0.29f, -4.13f, -0.59f, -6.2f, -0.9f) + curveToRelative(-6.94f, -1.04f, -13.84f, -2.32f, -20.76f, -3.44f) + curveToRelative(-8.37f, -1.38f, -16.38f, -0.69f, -23.95f, 3.44f) + arcToRelative(34.83f, 34.83f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.42f, 14.95f) + curveToRelative(-3.27f, 6.75f, -4.24f, 14.1f, -5.7f, 21.35f) + curveToRelative(-1.46f, 7.25f, -3.73f, 15.06f, -2.87f, 22.5f) + curveToRelative(1.85f, 16.07f, 13.09f, 29.13f, 29.26f, 32.05f) + curveToRelative(15.21f, 2.76f, 30.5f, 4.99f, 45.83f, 6.89f) + arcToRelative(847.2f, 847.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 196.22f, 1.13f) + arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11.41f, 11.32f) + lineToRelative(-1.53f, 14.86f) + lineToRelative(-9.25f, 90.1f) + arcToRelative(70942.69f, 70942.69f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.71f, 94.6f) + lineToRelative(-2.76f, 26.64f) + curveToRelative(-0.88f, 8.74f, -1.01f, 17.76f, -2.67f, 26.4f) + curveToRelative(-2.62f, 13.58f, -11.82f, 21.93f, -25.24f, 24.98f) + arcToRelative(175.93f, 175.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, -37.47f, 4.38f) + curveToRelative(-13.98f, 0.08f, -27.95f, -0.54f, -41.94f, -0.47f) + curveToRelative(-14.93f, 0.08f, -33.2f, -1.29f, -44.72f, -12.4f) + curveToRelative(-10.12f, -9.75f, -11.52f, -25.02f, -12.9f, -38.23f) + arcToRelative(29492.9f, 29492.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, -5.46f, -52.44f) + lineToRelative(-10.13f, -97.18f) + lineToRelative(-6.55f, -62.88f) + curveToRelative(-0.11f, -1.04f, -0.22f, -2.07f, -0.32f, -3.11f) + curveToRelative(-0.79f, -7.5f, -6.1f, -14.84f, -14.47f, -14.46f) + curveToRelative(-7.17f, 0.32f, -15.31f, 6.41f, -14.47f, 14.46f) + lineToRelative(4.86f, 46.62f) + lineToRelative(10.05f, 96.43f) + curveToRelative(2.86f, 27.39f, 5.72f, 54.79f, 8.56f, 82.19f) + curveToRelative(0.55f, 5.25f, 1.07f, 10.51f, 1.65f, 15.76f) + curveToRelative(3.15f, 28.68f, 25.06f, 44.14f, 52.19f, 48.49f) + curveToRelative(15.85f, 2.55f, 32.08f, 3.08f, 48.16f, 3.34f) + curveToRelative(20.62f, 0.33f, 41.44f, 1.13f, 61.72f, -2.61f) + curveToRelative(30.05f, -5.51f, 52.6f, -25.57f, 55.81f, -56.68f) + lineToRelative(2.76f, -26.95f) + curveToRelative(3.05f, -29.72f, 6.11f, -59.44f, 9.15f, -89.17f) + lineToRelative(9.96f, -97.12f) + lineToRelative(4.57f, -44.51f) + arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.32f, -9.09f) + curveToRelative(8.59f, -1.67f, 16.81f, -4.53f, 22.92f, -11.07f) + curveToRelative(9.73f, -10.41f, 11.66f, -23.98f, 8.23f, -37.66f) + close() + moveTo(108.01f, 146.45f) + curveToRelative(0.13f, -0.06f, -0.11f, 1.06f, -0.21f, 1.58f) + curveToRelative(-0.02f, -0.79f, 0.02f, -1.49f, 0.21f, -1.58f) + close() + moveTo(108.84f, 152.9f) + curveToRelative(0.07f, -0.05f, 0.28f, 0.23f, 0.49f, 0.56f) + curveToRelative(-0.32f, -0.3f, -0.53f, -0.53f, -0.5f, -0.56f) + horizontalLineToRelative(0.01f) + close() + moveTo(109.66f, 153.98f) + curveToRelative(0.45f, 0.82f, 0.3f, 0.5f, 0f, 0f) + close() + moveTo(111.31f, 155.31f) + horizontalLineToRelative(0.04f) + curveToRelative(0f, 0.05f, 0.08f, 0.1f, 0.1f, 0.14f) + arcToRelative(1.04f, 1.04f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.15f, -0.14f) + horizontalLineToRelative(0.01f) + close() + moveTo(399.68f, 153.32f) + curveToRelative(-3.09f, 2.93f, -7.74f, 4.3f, -12.33f, 4.98f) + curveToRelative(-51.54f, 7.64f, -103.83f, 11.52f, -155.94f, 9.81f) + curveToRelative(-37.29f, -1.27f, -74.19f, -5.41f, -111.11f, -10.63f) + curveToRelative(-3.62f, -0.51f, -7.54f, -1.17f, -10.02f, -3.84f) + curveToRelative(-4.69f, -5.03f, -2.38f, -15.15f, -1.16f, -21.23f) + curveToRelative(1.12f, -5.57f, 3.25f, -12.98f, 9.87f, -13.78f) + curveToRelative(10.34f, -1.21f, 22.34f, 3.15f, 32.56f, 4.7f) + arcToRelative(616.95f, 616.95f, 0f, isMoreThanHalf = false, isPositiveArc = false, 37.07f, 4.51f) + curveToRelative(52.93f, 4.82f, 106.75f, 4.07f, 159.45f, -2.98f) + arcToRelative(666.2f, 666.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 28.71f, -4.5f) + curveToRelative(8.5f, -1.52f, 17.92f, -4.38f, 23.05f, 4.41f) + curveToRelative(3.52f, 5.99f, 3.99f, 14.01f, 3.44f, 20.78f) + arcToRelative(11.59f, 11.59f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3.6f, 7.76f) + horizontalLineToRelative(0.01f) + close() + } + } + }.build() + + return _BrandBuyMeACoffee!! + } + +@Suppress("ObjectPropertyName") +private var _BrandBuyMeACoffee: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt new file mode 100644 index 00000000..8f7a9291 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandOpencollective.kt @@ -0,0 +1,58 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +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.BrandOpencollective: ImageVector + get() { + if (_BrandOpencollective != null) { + return _BrandOpencollective!! + } + _BrandOpencollective = ImageVector.Builder( + name = "BrandOpencollective", + defaultWidth = 64.dp, + defaultHeight = 64.dp, + viewportWidth = 64f, + viewportHeight = 64f + ).apply { + path( + fill = SolidColor(Color(0xFFB8D3F4)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(52.402f, 31.916f) + curveToRelative(0f, 4.03f, -1.17f, 7.895f, -3.178f, 11.087f) + lineToRelative(8.196f, 8.23f) + curveToRelative(4.014f, -5.375f, 6.523f, -12.094f, 6.523f, -19.318f) + reflectiveCurveToRelative(-2.51f, -13.942f, -6.523f, -19.318f) + lineToRelative(-8.196f, 8.23f) + curveToRelative(2.007f, 3.192f, 3.178f, 6.887f, 3.178f, 11.087f) + close() + } + path( + fill = SolidColor(Color(0xFF3385FF)), + pathFillType = PathFillType.EvenOdd + ) { + moveTo(32.004f, 52.41f) + curveToRelative(-11.207f, 0f, -20.406f, -9.24f, -20.406f, -20.493f) + reflectiveCurveToRelative(9.2f, -20.493f, 20.406f, -20.493f) + curveToRelative(4.182f, 0f, 7.86f, 1.176f, 11.04f, 3.36f) + lineToRelative(8.196f, -8.23f) + curveTo(45.887f, 2.52f, 39.197f, 0f, 32.004f, 0f) + curveTo(14.44f, 0f, 0.057f, 14.278f, 0.057f, 32.084f) + reflectiveCurveTo(14.44f, 64f, 32.004f, 64f) + curveToRelative(7.36f, 0f, 14.05f, -2.52f, 19.403f, -6.55f) + lineToRelative(-8.196f, -8.23f) + curveToRelative(-3.178f, 2.016f, -7.025f, 3.192f, -11.207f, 3.192f) + close() + } + }.build() + + return _BrandOpencollective!! + } + +@Suppress("ObjectPropertyName") +private var _BrandOpencollective: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt new file mode 100644 index 00000000..85c5b7a9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/BrandPatreon.kt @@ -0,0 +1,54 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.BrandPatreon: ImageVector + get() { + if (_BrandPatreon != null) { + return _BrandPatreon!! + } + _BrandPatreon = ImageVector.Builder( + name = "BrandPatreon", + defaultWidth = 512.dp, + defaultHeight = 512.dp, + viewportWidth = 512f, + viewportHeight = 512f + ).apply { + group( + clipPathData = PathData { + moveTo(0.38f, 0.42f) + lineToRelative(512f, 0f) + lineToRelative(0f, 512f) + lineTo(0.38f, 512.42f) + close() + } + ) { + path( + fill = SolidColor(Color.Black), + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(490.12f, 154.23f) + curveToRelative(-0.09f, -65.37f, -51.01f, -118.95f, -110.74f, -138.29f) + curveToRelative(-74.18f, -24.01f, -172.02f, -20.53f, -242.86f, 12.89f) + curveToRelative(-85.85f, 40.51f, -112.82f, 129.26f, -113.83f, 217.77f) + curveToRelative(-0.82f, 72.77f, 6.44f, 264.43f, 114.54f, 265.8f) + curveToRelative(80.33f, 1.02f, 92.29f, -102.48f, 129.45f, -152.33f) + curveToRelative(26.44f, -35.47f, 60.49f, -45.48f, 102.4f, -55.85f) + curveToRelative(72.03f, -17.83f, 121.13f, -74.68f, 121.03f, -149.99f) + close() + } + } + }.build() + + return _BrandPatreon!! + } + +@Suppress("ObjectPropertyName") +private var _BrandPatreon: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt index c6273c77..969d05df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CarbonGithubLogo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CarbonGithubLogo.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CustomServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CustomServer.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt index 362d0dde..cbcaa164 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/CustomServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/CustomServer.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentDismiss.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentDismiss.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt index 7ee6a290..58738d9f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentDismiss.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentDismiss.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMaximize.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMaximize.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt index ceffbbad..04a5cfd9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMaximize.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMaximize.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMinus.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMinus.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt index 15b907fc..e4fc342d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentMinus.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentMinus.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentSquareMultiple.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentSquareMultiple.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt index 7ca44d3e..9693d2cf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/FluentSquareMultiple.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/FluentSquareMultiple.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt new file mode 100644 index 00000000..9ad18022 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax.kt @@ -0,0 +1,3 @@ +package org.spotube.spotube.resources.iconsax + +object Iconsax diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax3DotsMore.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax3DotsMore.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt index 62160400..cf4ec346 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax3DotsMore.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Iconsax3DotsMore.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt index 520ebcc4..e7477dbe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAdd.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAddSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAddSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt index a433ce28..82fd4fce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAddSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxAddSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowDown4.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowDown4.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt index 2d56673e..f022bea8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowDown4.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowDown4.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowSquareUp.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowSquareUp.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt index 59516b40..5a6df3f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxArrowSquareUp.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxArrowSquareUp.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBox.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBox.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt index 9c71eb43..7b29531e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBox.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBox.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBoxAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBoxAdd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt index 06b11520..ddf2a7c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxBoxAdd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxBoxAdd.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt index 2ca12047..ae61ef25 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCd.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt index 1dd8454a..a7964c0e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt index 08d47f5b..8085b005 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCheckSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCheckSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCloseSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCloseSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt index e27c9e18..9fed5ef1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxCloseSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxCloseSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorSwatch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorSwatch.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt index afd465d0..6884928c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorSwatch.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorSwatch.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorsSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorsSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt index da5cce79..f29df961 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxColorsSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxColorsSquare.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDirectboxReceive.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDirectboxReceive.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt index e4a763f7..e1b7caa6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDirectboxReceive.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDirectboxReceive.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentDownload.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentDownload.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt index 9c1ae5c2..aa30f2f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentDownload.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentDownload.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentText.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentText.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt index f4889441..fae03e12 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDocumentText.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDocumentText.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDragHandle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDragHandle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt index d5d44e41..fccc551e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxDragHandle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxDragHandle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt index 51227cce..9bfd13c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEdit.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxExportArrowBulk.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxExportArrowBulk.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt index 5ab7e7b4..6871aae6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxExportArrowBulk.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxExportArrowBulk.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEye.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEye.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt index f5304d04..49d58458 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEye.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxEye.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFilterSearch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFilterSearch.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt index 427444a3..8176e8e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFilterSearch.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFilterSearch.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolder.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolder.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt index d817c099..6b665f45 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolder.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolder.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderAdd.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderAdd.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt index f51ad4bc..07f8b64c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderAdd.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderAdd.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderOpen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderOpen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt index 0a21adbf..2c01af64 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFolderOpen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFolderOpen.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt index ec73a525..832ba1db 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxForbidden.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxForbidden.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFormatCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFormatCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt index 50211bc4..b9070c2c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxFormatCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFormatCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxGlobe.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxGlobe.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt index b59f1e3b..2e05c51d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxGlobe.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxGlobe.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt index 4d07781b..440b220b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt index 6a5877ff..943657b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHeart2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHeart2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHome.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHome.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt index 27f58c50..a802d9ba 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHome.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHome.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHomeBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHomeBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt index 8081f558..1cf29a51 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxHomeBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxHomeBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt index 198e667e..4c6be580 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt index 7c31a0cf..4255e24e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxImportArrow2Bulk.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxInformation.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxInformation.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt index b8b552b6..cbbc16e0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxInformation.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxInformation.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLanguageSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLanguageSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt index 7204461d..a99cd979 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLanguageSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLanguageSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLink.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLink.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt index a91f5e06..fa46bdc6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLink.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLink.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLocation.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLocation.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt index 545e4726..cf01a743 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxLocation.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxLocation.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMagic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMagic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt index 14595f6f..a0d30b5e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMagic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMagic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMinusSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMinusSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt index 22a9de81..88a8f975 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMinusSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMinusSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt new file mode 100644 index 00000000..b0459e66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirrorScreenRegular.kt @@ -0,0 +1,89 @@ +package org.spotube.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxMirrorScreenRegular: ImageVector + get() { + if (_IconsaxMirrorScreenRegular != null) { + return _IconsaxMirrorScreenRegular!! + } + _IconsaxMirrorScreenRegular = ImageVector.Builder( + name = "IconsaxMirrorScreenRegular", + 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( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2f, 9f) + verticalLineTo(8f) + curveTo(2f, 5f, 4f, 3f, 7f, 3f) + horizontalLineTo(17f) + curveTo(20f, 3f, 22f, 5f, 22f, 8f) + verticalLineTo(16f) + curveTo(22f, 19f, 20f, 21f, 17f, 21f) + horizontalLineTo(16f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(3.69f, 11.711f) + curveTo(8.31f, 12.301f, 11.7f, 15.701f, 12.3f, 20.321f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(2.62f, 15.07f) + curveTo(6.01f, 15.5f, 8.5f, 18f, 8.94f, 21.39f) + } + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(1.98f, 18.859f) + curveTo(3.67f, 19.079f, 4.92f, 20.319f, 5.14f, 22.019f) + } + } + }.build() + + return _IconsaxMirrorScreenRegular!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxMirrorScreenRegular: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirroringScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirroringScreen.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt index d6dc0d2e..4cfe7baf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMirroringScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMirroringScreen.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMoon.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMoon.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt index a9dd219e..5c47d773 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMoon.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMoon.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt index 703de396..b404c2bc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt index 2d157fcd..14bd6927 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicDashboard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicDashboard.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt index 8591e793..c38c9ece 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicDashboard.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicDashboard.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicFilter.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicFilter.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt index 1192dffa..91bb39aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicFilter.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicFilter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibrary.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibrary.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt index 37cb9576..2584e03b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibrary.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibrary.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt index 75726088..843e577c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicLibraryOutline.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlay.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt index 5a28f4b5..423725a6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlay.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlaylist.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlaylist.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt index d8f043e1..59920a99 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicPlaylist.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicPlaylist.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt index d84c6d19..e82979e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemove.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt index 83baefb4..e5629855 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxMusicSquareRemoveFilled.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxNext.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxNext.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt index 7333b73a..c7fb610b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxNext.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxNext.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPause.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPause.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt index 096a3b71..f7735bad 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPause.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPause.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPauseCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPauseCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt index 81f70bae..9eb8430d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPauseCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPauseCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlay.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlay.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt index 66ca07d9..36cdabd9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlay.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlay.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt index b9c81564..ae5334bd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt index f2b95adb..8c6c54ba 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPlayCircle2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPlayCircle2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPrevious.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPrevious.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt index b5197971..34545a14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxPrevious.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxPrevious.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshArrow2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshArrow2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt index 2be1ec7d..8ae9eb93 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshArrow2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshArrow2.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshRight.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshRight.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt index 0a6e186b..ed56e82d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRefreshRight.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRefreshRight.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatArrow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatArrow.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt index d68aba5d..919d5887 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatArrow.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatArrow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatMusic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt index 2f6fd2a1..9ac0a975 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeatMusic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeatMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateMusic.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateMusic.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt index f5d3442f..f7088220 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateMusic.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateOne.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateOne.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt index d4cfaf28..77151a13 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxRepeateOne.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxRepeateOne.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearch.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearch.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt index 2340e13c..93489b51 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearch.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearch.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearchBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearchBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt index 73b0554f..31002b0d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSearchBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSearchBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt index 8fab354c..22554bfe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt index 5f305537..1580a167 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSetting2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSetting2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSettingTwotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSettingTwotone.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt index 46cc447b..84043b40 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSettingTwotone.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSettingTwotone.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt index 769f22f8..e7592b34 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShuffle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShuffle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt index d369fe57..497be379 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxShuffle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxShuffle.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt index 0d9381f0..af403ddf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarLeftBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt index 35f6e6cd..ecc9486d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSidebarRightBroken.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSort.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSort.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt index 46b0afd1..c2c01626 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSort.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSort.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt similarity index 75% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt index 89e3d481..c38f5c07 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSound.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSound.kt @@ -1,21 +1,4 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -56,11 +39,7 @@ val Iconsax.IconsaxSound: ImageVector curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f) close() } - path( - fill = SolidColor(Color.White), - fillAlpha = 0.4f, - strokeAlpha = 0.4f - ) { + path(fill = SolidColor(Color.White)) { moveTo(7.5f, 19f) curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f) verticalLineTo(5.75f) @@ -80,11 +59,7 @@ val Iconsax.IconsaxSound: ImageVector curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f) close() } - path( - fill = SolidColor(Color.White), - fillAlpha = 0.4f, - strokeAlpha = 0.4f - ) { + path(fill = SolidColor(Color.White)) { moveTo(16.5f, 19f) curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f) verticalLineTo(5.75f) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt new file mode 100644 index 00000000..e7961ae7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSoundTwotone.kt @@ -0,0 +1,97 @@ +package org.spotube.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.IconsaxSoundTwotone: ImageVector + get() { + if (_IconsaxSoundTwotone != null) { + return _IconsaxSoundTwotone!! + } + _IconsaxSoundTwotone = ImageVector.Builder( + name = "IconsaxSoundTwotone", + 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(3f, 16.5f) + curveTo(2.59f, 16.5f, 2.25f, 16.16f, 2.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(2.25f, 7.84f, 2.59f, 7.5f, 3f, 7.5f) + curveTo(3.41f, 7.5f, 3.75f, 7.84f, 3.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(7.5f, 19f) + curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(6.75f, 5.34f, 7.09f, 5f, 7.5f, 5f) + curveTo(7.91f, 5f, 8.25f, 5.34f, 8.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(8.25f, 18.66f, 7.91f, 19f, 7.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(12f, 21.5f) + curveTo(11.59f, 21.5f, 11.25f, 21.16f, 11.25f, 20.75f) + verticalLineTo(3.25f) + curveTo(11.25f, 2.84f, 11.59f, 2.5f, 12f, 2.5f) + curveTo(12.41f, 2.5f, 12.75f, 2.84f, 12.75f, 3.25f) + verticalLineTo(20.75f) + curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f) + close() + } + path( + fill = SolidColor(Color.White), + fillAlpha = 0.4f, + strokeAlpha = 0.4f + ) { + moveTo(16.5f, 19f) + curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f) + verticalLineTo(5.75f) + curveTo(15.75f, 5.34f, 16.09f, 5f, 16.5f, 5f) + curveTo(16.91f, 5f, 17.25f, 5.34f, 17.25f, 5.75f) + verticalLineTo(18.25f) + curveTo(17.25f, 18.66f, 16.91f, 19f, 16.5f, 19f) + close() + } + path(fill = SolidColor(Color.White)) { + moveTo(21f, 16.5f) + curveTo(20.59f, 16.5f, 20.25f, 16.16f, 20.25f, 15.75f) + verticalLineTo(8.25f) + curveTo(20.25f, 7.84f, 20.59f, 7.5f, 21f, 7.5f) + curveTo(21.41f, 7.5f, 21.75f, 7.84f, 21.75f, 8.25f) + verticalLineTo(15.75f) + curveTo(21.75f, 16.16f, 21.41f, 16.5f, 21f, 16.5f) + close() + } + } + }.build() + + return _IconsaxSoundTwotone!! + } + +@Suppress("ObjectPropertyName") +private var _IconsaxSoundTwotone: ImageVector? = null diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt index 6d81de56..c60404fb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxSquare.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxStopCircle.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxStopCircle.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt index d095cbbc..4c6bc6f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxStopCircle.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxStopCircle.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTag.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTag.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt index f789adcb..0764a5c2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTag.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTag.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTextalignLeft.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTextalignLeft.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt index cb9d5daa..4608269f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTextalignLeft.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTextalignLeft.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTrash.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTrash.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt index eb81b633..3b6247ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxTrash.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxTrash.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt index b69a5663..0880483b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxUserRemove.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxUserRemove.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVerticalSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVerticalSetting.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt index 60258c82..1cfdece4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVerticalSetting.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVerticalSetting.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeCross.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeCross.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt index 4ea83846..55c050ce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeCross.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeCross.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeHigh.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeHigh.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt index 2f4498f3..1001490e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeHigh.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeHigh.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeLow.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeLow.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt index 484258aa..3889166f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxVolumeLow.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxVolumeLow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxWifiSquare.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxWifiSquare.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt index 49ab9927..83fe5866 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxWifiSquare.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxWifiSquare.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/InconsaxClock.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/InconsaxClock.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt index b7ff81fd..87c8078e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/InconsaxClock.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/InconsaxClock.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/LineDiscord.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/LineDiscord.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt index 37d96b17..6d0efd61 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/LineDiscord.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/LineDiscord.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt index 7e74f60c..4bbf52df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/PhosphorDatabaseDuotone.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Play.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Play.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt index d52dc6be..c2bd063a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Play.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/Play.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/SwapHorizontal2.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/SwapHorizontal2.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt index f73604fd..64f351af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/SwapHorizontal2.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/SwapHorizontal2.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/User.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/User.kt rename to composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt index 6f9368ca..f5540c5c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/User.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/User.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.resources.iconsax +package org.spotube.spotube.resources.iconsax import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index b5ab652c..f8e0e84e 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -4,4 +4,4 @@ mod discord_rpc; pub use metadata::*; pub use discord_rpc::*; -uniffi::setup_scaffolding!(); +uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/composeApp/src/commonMain/templates/AppBuildInfo.kt.template b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template new file mode 100644 index 00000000..b1dee0db --- /dev/null +++ b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +package org.spotube.spotube + +internal object AppBuildInfo { + const val VERSION = "${appVersion}" + const val BUILD_NUMBER = ${appBuildNumber} + const val REPOSITORY_URL = "${repositoryUrl}" + const val LATEST_RELEASE_API_URL = "${latestReleaseApiUrl}" +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt similarity index 98% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt index 08c42b0f..2711d897 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueueTest.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt similarity index 93% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index d0a2aa10..d1075488 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -1,15 +1,17 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module @@ -282,8 +284,7 @@ class DeviceAudioPlayerQueueTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE))) queue.jumpTo(1, autoPlay = true) @@ -296,8 +297,7 @@ class DeviceAudioPlayerQueueTest { fun `jumpTo without autoPlay does not call play`() = runTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( - MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) queue.jumpTo(0, autoPlay = false) @@ -309,8 +309,7 @@ class DeviceAudioPlayerQueueTest { fun `jumpTo clamps out of range index`() = runTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( - MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) queue.jumpTo(999) @@ -426,7 +425,10 @@ class DeviceAudioPlayerQueueTest { ) val queue = createQueue() - withContext(Dispatchers.Default) { } // yield to let init coroutine run + // Restore runs on Dispatchers.IO; wait for it on real time rather than assuming one yield is enough. + withContext(Dispatchers.Default) { + withTimeout(5_000) { while (fakePlayer.loadCallCount == 0) delay(10) } + } assertEquals(1, fakePlayer.loadCallCount) assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry()) @@ -449,7 +451,7 @@ class DeviceAudioPlayerQueueTest { fun `streaming track converts to media item`() = runTest { val queue = createQueue() fakeSettings.setSettings( - dev.krtirtho.spotube.modules.settings.UserSettings(playbackProxyServerPort = 14769) + org.spotube.spotube.modules.settings.UserSettings(playbackProxyServerPort = 14769) ) val entry = streamingEntry("t1") diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt similarity index 87% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt index 406134b9..4f6fa948 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayer.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayer.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -9,10 +9,13 @@ import kotlin.time.Duration class FakeAudioPlayer : AudioPlayerInterface { private val _playlistFlow = MutableStateFlow>(emptyList()) override val playlistFlow: StateFlow> = _playlistFlow.asStateFlow() - override val durationFlow: StateFlow - get() = TODO("Not yet implemented") - override val positionFlow: StateFlow - get() = TODO("Not yet implemented") + + private val _durationFlow = MutableStateFlow(Duration.ZERO) + override val durationFlow: StateFlow = _durationFlow.asStateFlow() + + private val _positionFlow = MutableStateFlow(Duration.ZERO) + override val positionFlow: StateFlow = _positionFlow.asStateFlow() + override val bufferingPositionFlow: StateFlow get() = TODO("Not yet implemented") @@ -152,8 +155,8 @@ class FakeAudioPlayer : AudioPlayerInterface { _currentMediaItemFlow.value = _playlistFlow.value.getOrNull(index) } - override val playerStateFlow: StateFlow - get() = TODO("Not yet implemented") + private val _playerStateFlow = MutableStateFlow(PlayerState.IDLE) + override val playerStateFlow: StateFlow = _playerStateFlow.asStateFlow() fun setPlaylist(items: List) { _playlistFlow.value = items @@ -167,6 +170,18 @@ class FakeAudioPlayer : AudioPlayerInterface { _loopStateFlow.value = state } + fun setPlayerState(state: PlayerState) { + _playerStateFlow.value = state + } + + fun setPosition(position: Duration) { + _positionFlow.value = position + } + + fun setDuration(duration: Duration) { + _durationFlow.value = duration + } + fun resetCallCounts() { loadCallCount = 0 playCallCount = 0 diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt similarity index 91% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt index 77cea29e..b9149c83 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeAudioPlayerQueueRepository.kt @@ -1,4 +1,4 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer class FakeAudioPlayerQueueRepository : QueueStateRepository { private var persistedState: PersistedQueueState? = null diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt similarity index 76% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt index df4eae48..8793f64e 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeBlacklistRepository.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeBlacklistRepository.kt @@ -1,10 +1,10 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.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 org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt similarity index 73% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt index 40891c1e..b3daab34 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt @@ -1,12 +1,12 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.spotube.core.zipline.PluginService -import dev.krtirtho.spotube.core.zipline.PluginServiceScope -import dev.krtirtho.spotube.modules.plugin.PluginProvider +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.PluginProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,8 +33,8 @@ class FakeMetadataTrackAPI( } class FakePluginService( - private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI() -) : PluginService { + private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), + override val pluginId: String = "fake-plugin") : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) override suspend fun start() = Unit diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt similarity index 79% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt index cafdb7b1..c979107d 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakeSettingsProvider.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakeSettingsProvider.kt @@ -1,7 +1,7 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.UserSettings import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt new file mode 100644 index 00000000..ab3b9691 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt @@ -0,0 +1,44 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JamNamesTest { + @Test + fun sameSeedSameName() = runTest { + assertEquals(JamNames.generate("device-a"), JamNames.generate("device-a")) + } + + @Test + fun namesAreTwoFriendlyWordsAndVaried() = runTest { + val names = List(60) { JamNames.generate("device-$it") } + assertTrue(names.all { Regex("^[A-Z][a-z]+ [A-Z][a-z]+$").matches(it) }, names.toString()) + assertTrue(names.all { it.length <= JamNames.MAX_LENGTH }) + assertTrue(names.toSet().size >= 50, "only ${names.toSet().size} distinct names") + } + + @Test + fun typedNamesAreCleaned() { + assertEquals("Kingkor Roy", JamNames.normalize(" Kingkor \u0000 \t Roy \n")) + assertEquals("", JamNames.normalize(" ")) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt new file mode 100644 index 00000000..7902dc3b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt @@ -0,0 +1,93 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class JamSecurityTest { + private fun item(trackId: String = "t1", url: String = "", coverUrl: String = "https://i.example/c.jpg") = + JamMediaItem(url = url, trackId = trackId, title = "Song", artist = "A", album = "B", + durationMs = 1_000, coverUrl = coverUrl, protocol = "PROGRESSIVE") + + @Test + fun peerItemsWithoutATrackIdAreDropped() { + assertNull(item(trackId = "", url = "http://attacker.example/track.mp3").sanitized()) + assertNull(item(trackId = "", url = "file:///etc/passwd").sanitized()) + } + + @Test + fun peerItemsLoseUrlsAndNonHttpsCovers() { + val clean = item(url = "http://attacker.example/x", coverUrl = "http://attacker.example/pixel.png").sanitized()!! + assertEquals("", clean.url) + assertEquals("", clean.coverUrl) + assertEquals("https://i.example/c.jpg", item().sanitized()!!.coverUrl) + } + + @Test + fun peerTextIsBounded() { + val clean = item().copy(title = "x".repeat(10_000), durationMs = -5).sanitized()!! + assertTrue(clean.title.length <= 300) + assertEquals(0, clean.durationMs) + } + + @Test + fun localTrackKeysDontRevealPaths() { + val key = JamMediaItem.localTrackKey("/home/alice/Music/song.mp3") + assertFalse("alice" in key || "Music" in key) + assertEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/song.mp3")) + assertNotEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/other.mp3")) + } + + @Test + fun clientKeysAreLongRandomAndAcceptedByTheServer() { + val keys = List(100) { JamRoomCode.newClientKey() } + assertEquals(keys.size, keys.toSet().size) + // Mirrors CLIENT_KEY in the jam-server repository's src/protocol.ts. + val serverPattern = Regex("^[A-Za-z0-9_-]{22,64}$") + assertTrue(keys.all { serverPattern.matches(it) }) + } + + @Test + fun participantKeysAreStablePerRoomAndUnlinkableAcrossRooms() { + val device = JamRoomCode.newClientKey() + assertEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEF")) + assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEG")) + assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(JamRoomCode.newClientKey(), "ABCDEF")) + assertTrue(Regex("^[A-Za-z0-9_-]{22,64}$").matches(jamParticipantKey(device, "ABCDEF"))) + } + + @Test + fun deviceDigestsRevealNothingAndDifferPerRoom() { + val digest = jamDeviceDigest("ABCDEF")!! + assertEquals(digest, jamDeviceDigest("ABCDEF")) + assertNotEquals(digest, jamDeviceDigest("ABCDEG")) + assertTrue(jamDeviceTraits().none { trait -> trait.length > 3 && trait in digest }) + // Mirrors DEVICE_DIGEST in the jam-server repository's src/protocol.ts. + assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest)) + } + + @Test + fun roomCodesAreValid() { + repeat(200) { assertTrue(JamRoomCode.isValid(JamRoomCode.generate())) } + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSyncTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSyncTest.kt new file mode 100644 index 00000000..914ea50a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSyncTest.kt @@ -0,0 +1,135 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class JamSyncTest { + + // --- JamClock --- + + @Test + fun clockIsUnknownBeforeTheFirstSample() { + val clock = JamClock() + assertNull(clock.offsetMs) + assertEquals(1_000L, clock.serverTime(1_000L)) + } + + @Test + fun clockAssumesSymmetricLatency() { + val clock = JamClock() + // Server is 5 s ahead; 40 ms each way. + clock.addSample(clientSentMs = 1_000, serverMs = 6_040, clientReceivedMs = 1_080) + assertEquals(5_000L, clock.offsetMs) + assertEquals(80L, clock.rttMs) + } + + @Test + fun clockTrustsTheFastestRoundTrip() { + val clock = JamClock() + clock.addSample(clientSentMs = 0, serverMs = 5_030, clientReceivedMs = 60) // offset 5000 + // A slow sample with a lopsided path skews the estimate; it must not win. + clock.addSample(clientSentMs = 1_000, serverMs = 6_400, clientReceivedMs = 1_500) // offset 5150 + assertEquals(5_000L, clock.offsetMs) + } + + @Test + fun clockForgetsSamplesOutsideTheWindow() { + val clock = JamClock(window = 2) + clock.addSample(0, 5_010, 20) // fast, offset 5000 + clock.addSample(100, 5_250, 300) // offset 5050 + clock.addSample(400, 5_660, 600) // offset 5160 — the fast one is now gone + assertEquals(5_050L, clock.offsetMs) + } + + @Test + fun clockIgnoresNegativeRoundTrips() { + val clock = JamClock() + clock.addSample(clientSentMs = 100, serverMs = 0, clientReceivedMs = 50) + assertNull(clock.offsetMs) + } + + // --- JamAnchor --- + + @Test + fun anchorExtrapolatesWhileAdvancing() { + val anchor = JamAnchor("t", 0, positionMs = 10_000, serverTimeMs = 50_000, playing = true, rate = 1.5f) + assertEquals(13_000L, anchor.positionAt(52_000)) + } + + @Test + fun anchorHoldsStillWhenNotAdvancing() { + val paused = JamAnchor("t", 0, positionMs = 10_000, serverTimeMs = 50_000, playing = false) + assertEquals(10_000L, paused.positionAt(60_000)) + val buffering = JamAnchor("t", 0, 10_000, 50_000, playing = true, advancing = false) + assertEquals(10_000L, buffering.positionAt(60_000)) + } + + // --- JamDriftCorrector --- + + @Test + fun smallDriftIsLeftAlone() { + val corrector = JamDriftCorrector() + assertEquals(JamCorrection.None, corrector.correct(10_080, 10_000, 1f, seekLeadMs = 300)) + } + + @Test + fun behindSpeedsUpAndAheadSlowsDown() { + val behind = JamDriftCorrector().correct(10_600, 10_000, 1f, seekLeadMs = 300) + assertIs(behind) + assertTrue(behind.rate > 1f && behind.rate <= 1.05f) + + val ahead = JamDriftCorrector().correct(10_000, 10_600, 1f, seekLeadMs = 300) + assertIs(ahead) + assertTrue(ahead.rate < 1f && ahead.rate >= 0.95f) + } + + @Test + fun nudgeFollowsTheRoomRate() { + val nudge = JamDriftCorrector().correct(10_300, 10_000, 1.25f, seekLeadMs = 0) + assertIs(nudge) + assertEquals(1.25f * 1.05f, nudge.rate, 0.001f) + } + + @Test + fun largeDriftSeeksAheadByTheLead() { + val seek = JamDriftCorrector().correct(20_000, 10_000, 1f, seekLeadMs = 400) + assertEquals(JamCorrection.Seek(20_400), seek) + } + + @Test + fun aSingleNoisyReadingDoesNotSeek() { + val corrector = JamDriftCorrector(seekThresholdMs = 1_500) + repeat(4) { corrector.correct(10_000, 10_000, 1f, 0) } + // One coarse report 2 s off (under the immediate-seek bar) is outvoted by the median. + assertEquals(JamCorrection.None, corrector.correct(12_000, 10_000, 1f, 0)) + } + + @Test + fun persistentDriftSeeksOnceTheMedianAgrees() { + val corrector = JamDriftCorrector(seekThresholdMs = 1_500) + val results = List(3) { corrector.correct(12_000, 10_000, 1f, 0) } + assertIs(results[0]) + assertIs(results[1]) + assertEquals(JamCorrection.Seek(12_000), results[2]) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt new file mode 100644 index 00000000..f4549508 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/scrobble/ScrobbleManagerTest.kt @@ -0,0 +1,394 @@ +package org.spotube.spotube.core.scrobble + +import co.touchlab.kermit.Logger +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.FakeAudioPlayer +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.ScrobblePluginSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ScrobbleManagerTest { + + private lateinit var player: FakeAudioPlayer + private lateinit var queue: FakeQueue + private lateinit var plugin: FakeScrobblePluginService + private lateinit var pluginSource: FakeScrobblePluginSource + + /** Virtual playback position advanced by [playSeconds]; mirrors a real player clock. */ + private var currentPositionMs = 0L + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + player = FakeAudioPlayer() + queue = FakeQueue() + plugin = FakeScrobblePluginService() + pluginSource = FakeScrobblePluginSource(plugin) + currentPositionMs = 0L + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun TestScope.createManager(): ScrobbleManager { + return ScrobbleManager( + audioPlayer = player, + audioPlayerQueue = queue, + scrobblePluginSource = pluginSource, + scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler))) + } + + private fun playSeconds(seconds: Int) { + repeat(seconds) { + currentPositionMs += 1_000 + player.setPosition(currentPositionMs.milliseconds) + } + } + + @Test + fun `scrobbles once half of the track has been played`() = runTest { + createManager() + queue.setCurrentEntry( + streamingEntry( + id = "t1", + durationMs = 200_000, + externalUri = "https://open.spotify.com/track/t1") + ) + player.setPlayerState(PlayerState.PLAYING) + player.setDuration(200.seconds) + + playSeconds(99) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + + val scrobble = plugin.scrobbled.single() + assertEquals("t1", scrobble.trackId) + assertEquals("artist-t1", scrobble.artistId) + assertEquals("album-t1", scrobble.albumId) + assertEquals("Track t1", scrobble.trackName) + assertEquals("Artist t1", scrobble.artistName) + assertEquals("Album t1", scrobble.albumName) + assertEquals("spotify", scrobble.streamingProvider) + assertEquals(200_000L, scrobble.durationMs) + assertTrue(scrobble.timestamp > 0) + } + + @Test + fun `does not scrobble tracks shorter than 30 seconds`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "short", durationMs = 20_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals(0, plugin.scrobbled.size) + } + + @Test + fun `caps the listening threshold at four minutes`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "long", durationMs = 1_800_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(239) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `only scrobbles a track once per play`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + playSeconds(15) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit forward seeks`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(10) + currentPositionMs = 60_000 + player.setPosition(currentPositionMs.milliseconds) + + playSeconds(39) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `does not credit time while paused`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 100_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(30) + + player.setPlayerState(PlayerState.PAUSED) + currentPositionMs = 80_000 + player.setPosition(currentPositionMs.milliseconds) + + player.setPlayerState(PlayerState.PLAYING) + playSeconds(19) + assertEquals(0, plugin.scrobbled.size) + + playSeconds(1) + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `restarting the track allows scrobbling it again`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + // Repeat-one restarts playback from the beginning without a queue entry change. + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + } + + @Test + fun `switching tracks starts a fresh scrobble session`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + assertEquals(1, plugin.scrobbled.size) + + queue.setCurrentEntry(streamingEntry(id = "t2", durationMs = 40_000)) + currentPositionMs = 0 + player.setPosition(0.milliseconds) + playSeconds(20) + + assertEquals(2, plugin.scrobbled.size) + assertEquals("t2", plugin.scrobbled.last().trackId) + } + + @Test + fun `waits for a scrobble plugin to be selected`() = runTest { + pluginSource.setPlugin(null) + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(25) + assertEquals(0, plugin.scrobbled.size) + + pluginSource.setPlugin(plugin) + playSeconds(1) + + assertEquals(1, plugin.scrobbled.size) + } + + @Test + fun `scrobbles local tracks`() = runTest { + createManager() + queue.setCurrentEntry( + localEntry( + name = "Local Song", + url = "file:///music/local.mp3", + duration = 100_000) + ) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(50) + + val scrobble = plugin.scrobbled.single() + assertEquals("file:///music/local.mp3", scrobble.trackId) + assertEquals("Local Song", scrobble.trackName) + assertEquals("Local Artist", scrobble.artistName) + assertEquals("Local Album", scrobble.albumName) + assertEquals("local", scrobble.streamingProvider) + assertEquals(100_000L, scrobble.durationMs) + } + + @Test + fun `uses unknown provider when the track has no external uri`() = runTest { + createManager() + queue.setCurrentEntry(streamingEntry(id = "t1", durationMs = 40_000, externalUri = null)) + player.setPlayerState(PlayerState.PLAYING) + + playSeconds(20) + + assertEquals("unknown", plugin.scrobbled.single().streamingProvider) + } + + // --- fixtures --- + + private fun streamingTrack( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null): MetadataTrack { + val artist = MetadataArtist.Basic( + id = "artist-$id", + name = "Artist $id", + thumbnails = emptyList(), + externalUri = null) + val album = MetadataAlbum.Detailed( + releaseDate = "2024", + genres = emptyList(), + trackCount = 1, + id = "album-$id", + title = "Album $id", + description = null, + thumbnails = emptyList(), + albumType = MetadataAlbumType.Album, + artists = listOf(artist), + externalUri = null) + return MetadataTrack( + id = id, + title = "Track $id", + durationMs = durationMs, + trackNumber = 1, + discNumber = 1, + artists = listOf(artist), + album = album, + thumbnails = null, + explicit = false, + popularity = null, + isrcCode = null, + externalUri = externalUri) + } + + private fun streamingEntry( + id: String, + durationMs: Long = 200_000, + externalUri: String? = null): QueueEntry.StreamingTrack { + return QueueEntry.StreamingTrack( + track = streamingTrack(id, durationMs, externalUri), + url = "http://127.0.0.1:14769/stream/$id") + } + + private fun localEntry( + name: String = "Local Track", + url: String = "file:///music/local.mp3", + duration: Long = 180_000): QueueEntry.LocalTrack { + return QueueEntry.LocalTrack( + name = name, + artists = listOf("Local Artist"), + duration = duration, + album = "Local Album", + coverBytes = null, + url = url) + } + + private class FakeQueue : AudioPlayerQueue { + private val queueState = MutableStateFlow>(emptyList()) + private val currentEntryState = MutableStateFlow(null) + + override val queueFlow: StateFlow> = queueState + override val currentQueueEntryFlow: StateFlow = currentEntryState + override val currentCollectionEntryFlow: StateFlow = + MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = + MutableStateFlow(emptyList()) + + fun setCurrentEntry(entry: QueueEntry?) { + currentEntryState.value = entry + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue( + entries: List, + collectionEntry: QueueCollectionEntry?) = error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = error("not used") + override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used") + override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used") + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used") + override suspend fun reloadCurrent() = error("not used") + override suspend fun clear() = error("not used") + override suspend fun getQueue(): List = queueState.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = currentEntryState.value + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() + } + + private class FakeScrobblePluginSource( + initialPlugin: PluginService? = null) : ScrobblePluginSource { + private val selected = MutableStateFlow(initialPlugin) + override val selectedScrobblePlugin: StateFlow = selected + + fun setPlugin(service: PluginService?) { + selected.value = service + } + } + + private class FakeScrobblePluginService : PluginService { + val scrobbled = mutableListOf() + override val pluginId: String = "fake-scrobble-plugin" + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val api = object : ScrobbleAPI { + override suspend fun scrobble(track: ScrobbleTrack) { + scrobbled.add(track) + } + } + val scope = PluginServiceScope(mapOf(ScrobbleAPI::class to api)) + return scope.block() + } + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt new file mode 100644 index 00000000..441a88ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt @@ -0,0 +1,57 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.server + +import io.ktor.http.HeadersBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class StreamProxyHeadersTest { + + // Lowercase names and values as googlevideo sends them + private val upstream = HeadersBuilder().apply { + append("accept-ranges", "bytes") + append("alt-svc", "h3=\":443\"") + append("cache-control", "private, max-age=21298") + append("connection", "keep-alive") + append("content-length", "3523553") + append("content-range", "bytes 0-3523552/3523553") + append("content-type", "audio/webm") + append("last-modified", "Tue, 18 Feb 2025 22:42:11 GMT") + }.build() + + @Test + fun `keeps the headers a player needs to seek`() { + val forwarded = upstream.passthroughForPlayer() + assertEquals("bytes 0-3523552/3523553", forwarded["Content-Range"]) + assertEquals("bytes", forwarded["Accept-Ranges"]) + assertEquals("Tue, 18 Feb 2025 22:42:11 GMT", forwarded["Last-Modified"]) + } + + @Test + fun `drops headers that are duplicated or hop-by-hop`() { + val forwarded = upstream.passthroughForPlayer() + // Length and type are set on the OutgoingContent; forwarding them too emits them twice + assertNull(forwarded["Content-Length"]) + assertNull(forwarded["Content-Type"]) + assertNull(forwarded["Connection"]) + assertNull(forwarded["Alt-Svc"]) + assertNull(forwarded["Cache-Control"]) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt new file mode 100644 index 00000000..28feb80c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -0,0 +1,638 @@ +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.settings.UserSettings +import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Behavioral / caching tests for [StreamingUrlRepository]: the in-memory stream-url cache, + * the matched-source lookup, and the interaction between the two when the active audio + * plugin changes. + * + * These specifically target the "wrong track plays" class of bug: a stale match or cached + * URL from one plugin (or one track) being silently reused for a different plugin/track. + */ +class StreamingUrlRepositoryBehaviorTest { + + @BeforeTest + fun setup() { + startKoin { + modules(module { + factory { (tag: String?) -> Logger.withTag(tag ?: "test") } + }) + } + } + + @AfterTest + fun teardown() { + stopKoin() + } + + private fun track(id: String, title: String = "Track $id") = MetadataTrack( + id = id, + title = title, + durationMs = 200_000, + trackNumber = 1, + discNumber = 1, + artists = emptyList(), + album = null, + thumbnails = null, + explicit = false, + popularity = null, + isrcCode = null, + externalUri = null) + + private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic( + id = id, + title = "source-$id", + artist = null, + album = null, + thumbnails = emptyList(), + externalUri = null, + confidence = confidence) + + private fun streamedSource( + sourceId: String, + streams: List, + confidence: Float = 1f) = AudioSource.Streamed( + id = sourceId, + title = "source-$sourceId", + artist = null, + album = null, + thumbnails = emptyList(), + externalUri = null, + confidence = confidence, + streams = streams) + + private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy( + url = url, + codec = "opus", + container = "webm", + protocol = StreamProtocol.PROGRESSIVE, + bitrate = bitrate) + + private class Harness(cacheTtlMs: Long = 30_000) { + val queue = FakeStreamingAudioPlayerQueue() + val matchedTracksRepository = FakeTrackSourceRepository() + val settings = FakeUserSettingsSource() + val pluginSource = FakeAudioPluginSource() + val repository = StreamingUrlRepository( + pluginManager = pluginSource, + audioPlayerQueue = queue, + matchedTracksRepository = matchedTracksRepository, + settingsRepository = settings, + streamUrlCacheTtlMs = cacheTtlMs, + // Not the host's platform: Windows and iOS don't prefer opus/webm + platform = PlatformType.Linux) + + fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { + val service = FakeAudioPluginService(pluginId, audioAPI) + pluginSource.setPlugin(service) + return service + } + } + + // ---------- basic resolution ---------- + + @Test + fun `resolves and returns a stream via track lookup when nothing is cached`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertNotNull(info) + assertEquals("https://example.com/a.opus", info.url) + } + + @Test + fun `returns null when no audio plugin is selected`() = runTest { + val h = Harness() + val info = h.repository.resolveStreamInfo(track("t1")) + assertNull(info) + } + + @Test + fun `waits for an audio plugin that is still loading`() = runTest { + val h = Harness() + val loader = launch { + delay(5_000) + h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + } + + val info = h.repository.resolveStreamInfo(track("t1")) + loader.join() + + assertEquals("https://example.com/a.opus", info?.url) + } + + @Test + fun `returns null when the plugin yields no sources`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { emptyList() } + + assertNull(h.repository.resolveStreamInfo(track("t1"))) + } + + @Test + fun `resolveStreamInfo by trackId looks up the matching queue entry`() = runTest { + val h = Harness() + val t = track("t1") + h.queue.setQueue(listOf(QueueEntry.StreamingTrack(track = t, url = ""))) + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(trackId = "t1") + + assertNotNull(info) + assertEquals("https://example.com/a.opus", info.url) + } + + @Test + fun `resolveStreamInfo by trackId returns null when track is not queued`() = runTest { + val h = Harness() + h.selectPlugin("pluginA") + assertNull(h.repository.resolveStreamInfo(trackId = "not-queued")) + } + + // ---------- in-memory URL cache ---------- + + @Test + fun `second resolution for the same track reuses the cache instead of calling the plugin again`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.resolveStreamInfo(t) + + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `forceRefresh bypasses the cache and re-resolves`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + var counter = 0 + plugin.audioAPI.onGetStreamsByTrack = { + counter++ + listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus")))) + } + + val first = h.repository.resolveStreamInfo(t) + val second = h.repository.resolveStreamInfo(t, forceRefresh = true) + + assertEquals("https://example.com/1.opus", first?.url) + assertEquals("https://example.com/2.opus", second?.url) + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `changing the preferred format invalidates the cached url`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf( + streamedSource( + "src1", + listOf( + stream("https://example.com/opus.webm"), + AudioStream.Lossy( + url = "https://example.com/aac.mp4", + codec = "aac", + container = "mp4", + bitrate = 256_000)) + ) + ) + } + + // The default format depends on the host platform (aac/mp4 on Windows and iOS) + h.settings.update { + copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "opus", container = "webm")) + } + + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/opus.webm", first?.url) + + h.settings.update { + copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "aac", container = "mp4")) + } + + val second = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/aac.mp4", second?.url) + // Preference change forced a second real lookup rather than serving the stale URL. + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `changing the preferred quality invalidates the cached url`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf( + streamedSource( + "src1", + listOf(stream("https://example.com/128.opus", 128_000), stream("https://example.com/320.opus", 320_000)) + ) + ) + } + + h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(128_000)) } + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/128.opus", first?.url) + + h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(320_000)) } + val second = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/320.opus", second?.url) + } + + @Test + fun `cached url expires after the configured ttl`() = kotlinx.coroutines.runBlocking { + val h = Harness(cacheTtlMs = 10) + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + var counter = 0 + plugin.audioAPI.onGetStreamsByTrack = { + counter++ + listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus")))) + } + + h.repository.resolveStreamInfo(t) + kotlinx.coroutines.delay(50) + h.repository.resolveStreamInfo(t) + + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `invalidateCachedStreamUrl removes the cached entry unconditionally when no url is given`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.invalidateCachedStreamUrl(t.id) + h.repository.resolveStreamInfo(t) + + assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `invalidateCachedStreamUrl with a mismatching url is a no-op`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + h.repository.invalidateCachedStreamUrl(t.id, url = "https://not-the-cached-url") + h.repository.resolveStreamInfo(t) + + // Cache entry survived because the supplied url didn't match; plugin isn't hit again. + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + // ---------- matched-source fast path ---------- + + @Test + fun `a previously matched source from the same plugin is reused via getStreamsOfAudioSource`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/matched.opus")))) + } + plugin.audioAPI.onGetStreamsByTrack = { + error("should not fall back to track lookup when a matched source resolves") + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/matched.opus", info?.url) + assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) + assertEquals(0, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `THE BUG - a source matched by a previous plugin is never handed to a newly selected plugin`() = runTest { + val h = Harness() + val t = track("t1") + + // The user matched this track while "pluginA" (e.g. a YouTube-backed plugin) was active. + // "yt-video-123" is meaningful *only* to pluginA. + h.matchedTracksRepository.seed(t, basicSource("yt-video-123"), pluginId = "pluginA") + + // The user then switches the active audio plugin to "pluginB" (a different backend). + val pluginB = h.selectPlugin("pluginB") + pluginB.audioAPI.onGetStreamsOfAudioSource = { + error( + "pluginB must never receive a source id matched by a different plugin " + + "(id=${it.id}); this is exactly how playback of the wrong track happens." + ) + } + pluginB.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("pluginB-native-source", listOf(stream("https://example.com/correct.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + // pluginB never saw the stale "yt-video-123" id... + assertEquals(0, pluginB.audioAPI.getStreamsOfAudioSourceCallCount) + // ...and instead correctly fell back to a fresh, pluginB-native track lookup. + assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount) + assertEquals("https://example.com/correct.opus", info?.url) + } + + @Test + fun `a cached stream url from a previous plugin is not served after switching plugins`() = runTest { + val h = Harness() + val t = track("t1") + + val pluginA = h.selectPlugin("pluginA") + pluginA.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("srcA", listOf(stream("https://example.com/pluginA.opus")))) + } + val first = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/pluginA.opus", first?.url) + + val pluginB = h.selectPlugin("pluginB") + pluginB.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("srcB", listOf(stream("https://example.com/pluginB.opus")))) + } + val second = h.repository.resolveStreamInfo(t) + + // Must re-resolve via pluginB rather than replaying pluginA's cached URL. + assertEquals("https://example.com/pluginB.opus", second?.url) + assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `matched source is saved and tagged with the resolving plugin id`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + h.repository.resolveStreamInfo(t) + + val saved = h.matchedTracksRepository.rawEntryFor(t.id) + assertNotNull(saved) + assertEquals("pluginA", saved.pluginId) + assertEquals("src1", saved.source.id) + } + + @Test + fun `matched source is looked up but ignored when the format preference has no matching stream`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + // Matched source resolves to a stream, so preferred-format mismatch is irrelevant here; + // this documents that selection still happens on top of the matched-source stream list. + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/only.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + assertEquals("https://example.com/only.opus", info?.url) + } + + @Test + fun `falls back to track lookup when the matched source resolves no streams`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA") + plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() } + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("fresh", listOf(stream("https://example.com/fresh.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/fresh.opus", info?.url) + assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + @Test + fun `track lookup persists a Basic source by resolving its streams before caching`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { listOf(basicSource("basic-1")) } + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/resolved.opus")))) + } + + val info = h.repository.resolveStreamInfo(t) + + assertEquals("https://example.com/resolved.opus", info?.url) + assertEquals("basic-1", h.matchedTracksRepository.rawEntryFor(t.id)?.source?.id) + } + + @Test + fun `distinct tracks never share cache entries even with concurrent resolution`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { t -> + listOf(streamedSource("src-${t.id}", listOf(stream("https://example.com/${t.id}.opus")))) + } + + val t1 = track("t1") + val t2 = track("t2") + + val info1 = h.repository.resolveStreamInfo(t1) + val info2 = h.repository.resolveStreamInfo(t2) + + assertEquals("https://example.com/t1.opus", info1?.url) + assertEquals("https://example.com/t2.opus", info2?.url) + assertTrue(info1?.url != info2?.url) + } + + // ---------- alternatives cache ---------- + + @Test + fun `getCachedAlternatives returns null before anything is cached`() = runTest { + val h = Harness() + assertNull(h.repository.getCachedAlternatives("t1", "pluginA")) + } + + @Test + fun `alternatives resolved via track lookup are cached and retrievable`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + val candidates = listOf(basicSource("alt-1"), basicSource("alt-2")) + plugin.audioAPI.onGetStreamsByTrack = { candidates } + plugin.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/${it.id}.opus")))) + } + + h.repository.resolveStreamInfo(t) + + val cached = h.repository.getCachedAlternatives(t.id, "pluginA") + assertNotNull(cached) + assertEquals(2, cached.size) + } + + @Test + fun `alternatives cached by one plugin are not visible to a different plugin`() = runTest { + val h = Harness() + val t = track("t1") + val pluginA = h.selectPlugin("pluginA") + pluginA.audioAPI.onGetStreamsByTrack = { listOf(basicSource("alt-1")) } + pluginA.audioAPI.onGetStreamsOfAudioSource = { + listOf(streamedSource(it.id, listOf(stream("https://example.com/a.opus")))) + } + h.repository.resolveStreamInfo(t) + + assertNotNull(h.repository.getCachedAlternatives(t.id, "pluginA")) + assertNull(h.repository.getCachedAlternatives(t.id, "pluginB")) + } + + @Test + fun `cacheAlternatives followed by invalidateCachedAlternatives clears the entry`() = runTest { + val h = Harness() + h.repository.cacheAlternatives("t1", listOf(basicSource("alt-1")), "pluginA") + assertNotNull(h.repository.getCachedAlternatives("t1", "pluginA")) + + h.repository.invalidateCachedAlternatives("t1") + + assertNull(h.repository.getCachedAlternatives("t1", "pluginA")) + } + + // ---------- getCachedStreamUrlEntry ---------- + + @Test + fun `getCachedStreamUrlEntry returns null when nothing has been cached`() = runTest { + val h = Harness() + assertNull(h.repository.getCachedStreamUrlEntry("t1")) + } + + @Test + fun `getCachedStreamUrlEntry never fetches - it only reflects the existing cache`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + + assertNull(h.repository.getCachedStreamUrlEntry(t.id)) + h.repository.resolveStreamInfo(t) + val entry = h.repository.getCachedStreamUrlEntry(t.id) + + assertNotNull(entry) + assertEquals("https://example.com/a.opus", entry.url) + assertEquals("pluginA", entry.pluginId) + // Fetching the entry doesn't call the plugin again. + assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + } + + // ---------- caller requests (downloads) ---------- + + private val aacRequest = StreamRequest( + format = org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat( + codec = "aac", container = "mp4", qualities = listOf(AudioQuality.Lossy(128_000))), + quality = AudioQuality.Lossy(128_000), + progressiveOnly = true) + + private fun aacStream(url: String, protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy( + url = url, codec = "aac", container = "mp4", protocol = protocol, bitrate = 128_000) + + @Test + fun `a request's format overrides the streaming settings`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"), aacStream("https://example.com/a.m4a")))) + } + + val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertEquals("https://example.com/a.m4a", info?.url) + } + + @Test + fun `progressiveOnly never picks a manifest even when it matches best`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf( + aacStream("https://example.com/manifest.mpd", StreamProtocol.DASH), + stream("https://example.com/a.opus")))) + } + + val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertEquals("https://example.com/a.opus", info?.url) + assertEquals(StreamProtocol.PROGRESSIVE, info?.protocol) + } + + @Test + fun `progressiveOnly returns null when only manifests exist`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(aacStream("https://example.com/manifest.m3u8", StreamProtocol.HLS)))) + } + + assertNull(h.repository.resolveStreamInfo(track("t1"), request = aacRequest)) + } + + @Test + fun `a request does not write the playback url cache`() = runTest { + val h = Harness() + val plugin = h.selectPlugin("pluginA") + plugin.audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(aacStream("https://example.com/a.m4a")))) + } + + h.repository.resolveStreamInfo(track("t1"), request = aacRequest) + + assertNull(h.repository.getCachedStreamUrlEntry("t1")) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt new file mode 100644 index 00000000..d8f1af5f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -0,0 +1,134 @@ +package org.spotube.spotube.core.server + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.zipline.PluginService +import org.spotube.spotube.core.zipline.PluginServiceScope +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** In-memory [AudioAPI] whose behaviour for each call is fully scripted by the test. */ +class FakeAudioAPI( + override val supportedQualities: List = emptyList(), + var onGetStreamsByTrack: suspend (MetadataTrack) -> List = { emptyList() }, + var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }) : AudioAPI { + var getStreamsByTrackCallCount = 0 + var getStreamsOfAudioSourceCallCount = 0 + val getStreamsOfAudioSourceCalls = mutableListOf() + + override suspend fun getStreamsByTrack(track: MetadataTrack): List { + getStreamsByTrackCallCount++ + return onGetStreamsByTrack(track) + } + + override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { + getStreamsOfAudioSourceCallCount++ + getStreamsOfAudioSourceCalls.add(source) + return onGetStreamsOfAudioSource(source) + } +} + +/** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */ +class FakeAudioPluginService( + override val pluginId: String, + val audioAPI: FakeAudioAPI = FakeAudioAPI()) : PluginService { + override val loggedInFlow: StateFlow = MutableStateFlow(true) + + override suspend fun start() = Unit + override suspend fun stop() = Unit + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + val scope = PluginServiceScope(mapOf(AudioAPI::class to audioAPI)) + return scope.block() + } +} + +class FakeAudioPluginSource( + initialPlugin: PluginService? = null +) : AudioPluginSource { + private val _selectedAudioPlugin = MutableStateFlow(initialPlugin) + override val selectedAudioPlugin: StateFlow = _selectedAudioPlugin + + fun setPlugin(service: PluginService?) { + _selectedAudioPlugin.value = service + } +} + +/** In-memory [TrackSourceRepository], mirroring the plugin-scoped persistence contract of + * the real [MatchedTracksRepository] without touching disk. */ +class FakeTrackSourceRepository : TrackSourceRepository { + private val store = mutableMapOf() + var getCallCount = 0 + var saveCallCount = 0 + + override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { + getCallCount++ + val tagged = store[track.id] ?: return null + if (tagged.pluginId != pluginId) return null + return tagged.source + } + + override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + saveCallCount++ + store[track.id] = PluginTaggedAudioSource(pluginId, source) + } + + fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId] + + fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { + store[track.id] = PluginTaggedAudioSource(pluginId, source) + } +} + +class FakeUserSettingsSource(initial: UserSettings = UserSettings()) : UserSettingsSource { + private val _userSettings = MutableStateFlow(initial) + override val userSettings: StateFlow = _userSettings + + fun update(transform: UserSettings.() -> UserSettings) { + _userSettings.value = _userSettings.value.transform() + } +} + +/** Bare-bones [AudioPlayerQueue] that only backs [queueFlow]; every other member is unused + * by [StreamingUrlRepository] and throws if accidentally exercised. */ +class FakeStreamingAudioPlayerQueue : AudioPlayerQueue { + private val _queueFlow = MutableStateFlow>(emptyList()) + override val queueFlow: StateFlow> = _queueFlow + override val currentQueueEntryFlow: StateFlow = MutableStateFlow(null) + override val currentCollectionEntryFlow: StateFlow = MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + fun setQueue(entries: List) { + _queueFlow.value = entries + } + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?) = error("not used") + + override suspend fun addToQueue(entry: QueueEntry) = error("not used") + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = + error("not used") + + override suspend fun addAllAfterCurrent(entries: List) = error("not used") + override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used") + override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used") + override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used") + override suspend fun reloadCurrent() = error("not used") + override suspend fun clear() = error("not used") + override suspend fun getQueue(): List = _queueFlow.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = null + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt new file mode 100644 index 00000000..b5f68eb9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -0,0 +1,439 @@ +package org.spotube.spotube.core.server + +import org.spotube.spotube.PlatformType +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * Tests for the stream preference selection used by [StreamingUrlRepository]. + * + * Ordering contract (best first): + * 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH). + * 1. Fewest codec/container mismatches against the preferred format. + * 2. Matching quality family (lossy vs lossless). + * 3. Closest bitrate / sample rate. + * 4. Closest channel count (lossless only). + * 5. First occurrence wins remaining ties. + */ +class StreamingUrlRepositoryTest { + + // ---------- helpers ---------- + + private fun opusWebm() = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + private fun flac() = AudioFormat(codec = "flac", container = "flac", qualities = emptyList()) + + private fun lossy( + url: String, + bitrate: Int, + codec: String = "opus", + container: String = "webm", + protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy( + url = url, + codec = codec, + container = container, + protocol = protocol, + bitrate = bitrate) + + private fun lossless( + url: String, + sampleRate: Int, + channels: Int, + codec: String = "flac", + container: String = "flac") = AudioStream.Lossless( + url = url, + codec = codec, + container = container, + sampleRate = sampleRate, + channels = channels) + + private fun select( + streams: List, + format: AudioFormat = opusWebm(), + quality: AudioQuality = AudioQuality.Lossy(256_000), + platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform) + + // ---------- empty / single ---------- + + @Test + fun `returns null when no streams are available`() { + assertNull(select(emptyList())) + } + + @Test + fun `returns the only stream when exactly one is available`() { + val only = lossy("opus-128", 128_000) + assertSame(only, select(listOf(only))) + } + + @Test + fun `does not mutate the input list`() { + val streams = mutableListOf( + lossy("opus-44", 44_000), + lossy("opus-256", 256_000)) + val snapshot = streams.toList() + + select(streams) + + assertEquals(snapshot, streams) + } + + // ---------- format priority ---------- + + @Test + fun `prefers an exact codec and container match over a closer quality in another format`() { + val otherFormat = lossy("aac-256", 256_000, codec = "aac", container = "mp4") + val preferredFormat = lossy("opus-128", 128_000) + + val result = select( + streams = listOf(otherFormat, preferredFormat), + quality = AudioQuality.Lossy(256_000)) + + assertSame(preferredFormat, result) + } + + @Test + fun `format matching ignores codec and container case`() { + val exact = lossy("opus-128", 128_000, codec = "OpUs", container = "WeBm") + val mismatch = lossy("aac-128", 128_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(mismatch, exact), + format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList())) + + assertSame(exact, result) + } + + @Test + fun `a partial format match beats a full format mismatch regardless of bitrate`() { + val partial = lossy("opus-mp4-320", 320_000, container = "mp4") + val fullMismatch = lossy("aac-mp4-44", 44_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(fullMismatch, partial), + quality = AudioQuality.Lossy(44_000)) + + assertSame(partial, result) + } + + @Test + fun `a one-field match beats a two-field mismatch`() { + val oneField = lossy("opus-ogg", 128_000, container = "ogg") + val twoFields = lossy("aac-mp4", 128_000, codec = "aac", container = "mp4") + + val result = select(streams = listOf(twoFields, oneField)) + + assertSame(oneField, result) + } + + @Test + fun `codec-only and container-only matches tie on format and are resolved by quality`() { + val codecOnly = lossy("opus-mp4-320", 320_000, container = "mp4") + val containerOnly = lossy("aac-webm-44", 44_000, codec = "aac", container = "webm") + + val result = select( + streams = listOf(codecOnly, containerOnly), + quality = AudioQuality.Lossy(44_000)) + + assertSame(containerOnly, result) + } + + @Test + fun `when nothing matches the format the closest quality is chosen`() { + val low = lossy("aac-128", 128_000, codec = "aac", container = "mp4") + val target = lossy("aac-256", 256_000, codec = "aac", container = "mp4") + val high = lossy("aac-320", 320_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(low, target, high), + quality = AudioQuality.Lossy(256_000)) + + assertSame(target, result) + } + + @Test + fun `format match is prioritized over matching the quality family`() { + // Same format but wrong family vs. wrong format but correct family. + val sameFormatWrongFamily = lossless("opus-webm-flac", 48_000, 2, codec = "opus", container = "webm") + val wrongFormatRightFamily = lossy("aac-44", 44_000, codec = "aac", container = "mp4") + + val result = select( + streams = listOf(wrongFormatRightFamily, sameFormatWrongFamily), + quality = AudioQuality.Lossy(44_000)) + + assertSame(sameFormatWrongFamily, result) + } + + // ---------- lossy quality ---------- + + @Test + fun `exact lossy bitrate wins`() { + val low = lossy("opus-44", 44_000) + val exact = lossy("opus-128", 128_000) + val high = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(low, exact, high), + quality = AudioQuality.Lossy(128_000)) + + assertSame(exact, result) + } + + @Test + fun `closest lower lossy bitrate wins`() { + val lower = lossy("opus-128", 128_000) + val higher = lossy("opus-320", 320_000) + + val result = select( + streams = listOf(higher, lower), + quality = AudioQuality.Lossy(200_000)) + + assertSame(lower, result) + } + + @Test + fun `closest higher lossy bitrate wins`() { + val lower = lossy("opus-44", 44_000) + val higher = lossy("opus-128", 128_000) + + val result = select( + streams = listOf(lower, higher), + quality = AudioQuality.Lossy(100_000)) + + assertSame(higher, result) + } + + @Test + fun `equidistant lossy bitrates pick the first occurrence`() { + val below = lossy("opus-96", 96_000) + val above = lossy("opus-104", 104_000) + + assertSame(below, select(listOf(below, above), quality = AudioQuality.Lossy(100_000))) + assertSame(above, select(listOf(above, below), quality = AudioQuality.Lossy(100_000))) + } + + @Test + fun `lossy preference prefers a lossy stream over lossless in the same format`() { + val losslessStream = lossless("opus-webm-lossless", 48_000, 2, codec = "opus", container = "webm") + val lossyStream = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(losslessStream, lossyStream), + quality = AudioQuality.Lossy(128_000)) + + assertSame(lossyStream, result) + } + + @Test + fun `lossy distance does not overflow for extreme bitrates`() { + val min = lossy("opus-min", Int.MIN_VALUE) + val max = lossy("opus-max", Int.MAX_VALUE) + + val result = select( + streams = listOf(min, max), + quality = AudioQuality.Lossy(Int.MAX_VALUE)) + + assertSame(max, result) + } + + // ---------- lossless quality ---------- + + @Test + fun `exact lossless sample rate and channels wins`() { + val otherRate = lossless("flac-44", 44_100, 2) + val mono = lossless("flac-48-mono", 48_000, 1) + val exact = lossless("flac-48-stereo", 48_000, 2) + + val result = select( + streams = listOf(otherRate, mono, exact), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(exact, result) + } + + @Test + fun `closest lossless sample rate wins`() { + val near = lossless("flac-44", 44_100, 2) + val far = lossless("flac-96", 96_000, 2) + + val result = select( + streams = listOf(far, near), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(near, result) + } + + @Test + fun `lossless sample rate tie is broken by channel count`() { + val mono = lossless("flac-44-mono", 44_100, 1) + val stereo = lossless("flac-44-stereo", 44_100, 2) + + val result = select( + streams = listOf(mono, stereo), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(stereo, result) + } + + @Test + fun `lossless preference prefers a lossless stream over lossy in the same format`() { + val lossyStream = lossy("flac-flac-lossy", 128_000, codec = "flac", container = "flac") + val losslessStream = lossless("flac-44", 44_100, 2) + + val result = select( + streams = listOf(lossyStream, losslessStream), + format = flac(), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(losslessStream, result) + } + + // ---------- quality-family fallbacks ---------- + + @Test + fun `lossy preference with only lossless streams still returns the first stream`() { + val first = lossless("flac-44", 44_100, 2) + val second = lossless("flac-48", 48_000, 2) + + val result = select( + streams = listOf(first, second), + format = flac(), + quality = AudioQuality.Lossy(256_000)) + + assertSame(first, result) + } + + @Test + fun `lossless preference with only lossy streams still returns the first stream`() { + val first = lossy("opus-128", 128_000) + val second = lossy("opus-320", 320_000) + + val result = select( + streams = listOf(first, second), + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) + + assertSame(first, result) + } + + // ---------- degenerate / orthogonal inputs ---------- + + @Test + fun `empty preferred codec and container fall back to quality`() { + val low = lossy("opus-44", 44_000) + val target = lossy("opus-256", 256_000) + + val result = select( + streams = listOf(low, target), + format = AudioFormat(codec = "", container = "", qualities = emptyList()), + quality = AudioQuality.Lossy(256_000)) + + assertSame(target, result) + } + + @Test + fun `protocol does not influence selection`() { + val progressive = lossy("a", 128_000, protocol = StreamProtocol.PROGRESSIVE) + val dash = lossy("b", 128_000, protocol = StreamProtocol.DASH) + val hls = lossy("c", 128_000, protocol = StreamProtocol.HLS) + + val result = select(streams = listOf(progressive, dash, hls)) + + assertSame(progressive, result) + } + + @Test + fun `selection is stable across repeated calls`() { + val streams = listOf( + lossy("opus-44", 44_000), + lossy("opus-128", 128_000), + lossy("opus-256", 256_000)) + + val first = select(streams, quality = AudioQuality.Lossy(100_000)) + val second = select(streams, quality = AudioQuality.Lossy(100_000)) + + assertSame(first, second) + } + + // ---------- normalizeManifestUrl ---------- + + @Test + fun `absolute http urls are returned unchanged`() { + val url = "http://example.com/audio.mp3" + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `absolute https urls are returned unchanged for every protocol`() { + val url = "https://example.com/manifest.m3u8" + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.HLS)) + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.DASH)) + assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `relative hls manifest paths are resolved against the youtube origin`() { + assertEquals( + "https://www.youtube.com/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS)) + } + + @Test + fun `relative dash manifest paths are resolved against the youtube origin`() { + assertEquals( + "https://www.youtube.com/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH)) + } + + @Test + fun `relative progressive urls are returned unchanged`() { + assertEquals( + "/watch?v=abc", + normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE)) + } + + @Test + fun `empty url is handled without throwing`() { + assertEquals("", normalizeManifestUrl("", StreamProtocol.PROGRESSIVE)) + assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) + assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) + } + + // ---------- device playability ---------- + + @Test + fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS dash manifests are skipped in favour of progressive streams`() { + val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH) + val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS an unplayable stream is still returned when nothing else exists`() { + val opus = lossy("opus-160", 160_000) + assertSame(opus, select(listOf(opus), platform = PlatformType.IOS)) + } + + @Test + fun `off iOS the preferred opus webm stream still wins`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android)) + } + +} diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DESTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt similarity index 95% rename from composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DESTest.kt rename to composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt index 81472ed7..088f642b 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/additionals/DESTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/zipline/host_apis/additionals/DESTest.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline.host_apis.additionals +package org.spotube.spotube.core.zipline.host_apis.additionals -import dev.krtirtho.plugin_interfaces.host_apis.PaddingTypes -import dev.krtirtho.plugin_interfaces.host_apis.SymmetricModes +import org.spotube.plugin_interfaces.host_apis.PaddingTypes +import org.spotube.plugin_interfaces.host_apis.SymmetricModes import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertContentEquals diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt new file mode 100644 index 00000000..c25d82a6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.spotube.PlatformType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class StreamingFormatDefaultsTest { + + private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + @Test + fun `ios defaults to aac in mp4`() { + val format = defaultStreamingMusicFormat(PlatformType.IOS) + assertEquals("aac" to "mp4", format.codec to format.container) + } + + @Test + fun `other platforms keep opus in webm`() { + listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform -> + val format = defaultStreamingMusicFormat(platform) + assertEquals("opus" to "webm", format.codec to format.container, platform.name) + } + } + + @Test + fun `saved opus webm is moved to aac on ios`() { + val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS) + assertEquals("mp4", migrated.streamingMusicFormat.container) + } + + @Test + fun `saved opus webm is kept off ios`() { + val settings = UserSettings(streamingMusicFormat = opusWebm) + assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux)) + } +} diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt similarity index 91% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt index a17253b2..e3b722f6 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,16 +26,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.window.ComposeUIViewController import com.yushosei.newpipe.extractor.NewPipe import com.yushosei.newpipe.util.DefaultDownloaderImpl -import dev.krtirtho.spotube.core.di.initKoin +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.webview.SafariUserAgent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.component.KoinComponent import org.koin.core.component.inject - fun MainViewController() = ComposeUIViewController(configure = { initKoin() + SafariUserAgent.prime() }) { InitNewPipe { App() } } @Composable diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt similarity index 97% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt index aa38bb5f..7f8ca2c3 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/Platform.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import platform.Foundation.NSURL import platform.UIKit.UIApplication diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt similarity index 99% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 04efce38..3c99747b 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -17,7 +17,7 @@ @file:OptIn(ExperimentalForeignApi::class) -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import kotlinx.cinterop.CValue import kotlinx.cinterop.ExperimentalForeignApi @@ -343,7 +343,6 @@ private fun CMTimeMake(value: Long, timescale: Int): CValue { return platform.CoreMedia.CMTimeMake(value, timescale) } - private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue { return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale) } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt similarity index 66% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt index c1b3efff..412db38c 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/di/Modules.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/di/Modules.ios.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.share.IosShareService -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.modules.library.local_tracks.media.IosLocalMediaDiscoveryService -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.IosShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.webview.WebViewController +import org.spotube.spotube.modules.library.local_tracks.media.IosLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService import org.koin.core.module.dsl.singleOf import org.koin.dsl.module diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt new file mode 100644 index 00000000..2f73a824 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +// The OS's own service-discovery stack advertises on the right network. +internal actual fun preferredAdvertiseAddress(): String? = null diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt new file mode 100644 index 00000000..17a4a805 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.ios.kt @@ -0,0 +1,24 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on iOS. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt new file mode 100644 index 00000000..4ac27df9 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.ios.kt @@ -0,0 +1,41 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.staticCompositionLocalOf +import platform.Foundation.NSUserDefaults + +// Compose (and Compose resources) read NSLocale.preferredLanguages, which honours the +// app-level AppleLanguages default. +private const val APPLE_LANGUAGES_KEY = "AppleLanguages" +private const val SYSTEM_LANGUAGE = "system" + +private val LocalAppLocale = staticCompositionLocalOf { SYSTEM_LANGUAGE } + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + val defaults = NSUserDefaults.standardUserDefaults + if (languageTag == null) { + defaults.removeObjectForKey(APPLE_LANGUAGES_KEY) + } else { + defaults.setObject(listOf(languageTag), APPLE_LANGUAGES_KEY) + } + return LocalAppLocale provides (languageTag ?: SYSTEM_LANGUAGE) +} diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt similarity index 57% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt index 7c0a21e4..ff45cbf5 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt @@ -15,19 +15,22 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.jam -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.newSingleThreadContext +import platform.Foundation.NSLocale +import platform.Foundation.NSTimeZone +import platform.Foundation.currentLocale +import platform.Foundation.localTimeZone +import platform.Foundation.localeIdentifier +import platform.UIKit.UIDevice -@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) -actual fun createZiplineDispatcher(): ZiplineDispatcher { - // On iOS/native, thread stack sizes are typically 8 MiB by default, - // so newSingleThreadContext is sufficient. - val ctx = newSingleThreadContext("Zipline") - return ZiplineDispatcher(ctx) { - ctx.close() - } +internal actual fun jamDeviceTraits(): List { + val device = UIDevice.currentDevice + return listOf( + "ios", + device.model, + device.systemName(), + device.systemVersion, + NSLocale.currentLocale.localeIdentifier, + NSTimeZone.localTimeZone.name) } - diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt similarity index 75% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index 9c07b0ed..95eb42c1 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.newpipe +package org.spotube.spotube.core.newpipe import com.yushosei.newpipe.extractor.ServiceList.YouTube import com.yushosei.newpipe.extractor.stream.AudioStream @@ -26,12 +26,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 + actual class NewPipeService { - actual suspend fun searchVideos(query: String): List { + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( query, - mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), "" ) extractor.fetchPage() @@ -43,7 +50,7 @@ actual class NewPipeService { title = item.name, url = item.url, uploader = item.uploaderName ?: "Unknown", - durationMs = item.duration, + durationMs = secondsToMs(item.duration), thumbnailUrl = item.thumbnails.first().url, id = Url(item.url).parameters["v"] ?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}") @@ -63,17 +70,16 @@ actual class NewPipeService { title = extractor.name ?: "Unknown", url = extractor.url ?: "https://www.youtube.com/watch?v=$id", uploader = extractor.uploaderName, - durationMs = extractor.length, + durationMs = secondsToMs(extractor.length), thumbnailUrl = extractor.thumbnails.first().url, audioStreams = extractor.audioStreams() .filter { stream -> stream.isUrl() && stream.format != null } .map { stream -> - dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( + org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( url = stream.content, - codec = stream.codec ?: "unknown", + codec = normalizeCodec(stream.codec ?: "unknown"), bitrate = stream.bitrate, - container = stream.format?.suffix ?: "unknown", - ) + container = normalizeContainer(stream.format?.suffix ?: "unknown")) }, videoStreams = listOf(), // Not yet supported id = id diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt similarity index 94% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt index 0cde016d..662d3467 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/paths/Paths.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSCachesDirectory @@ -35,8 +35,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return requireNotNull(cacheDirectory?.path) } @@ -48,8 +47,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return requireNotNull(documentDirectory?.path) } @@ -60,8 +58,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return (downloadsDirectory?.path ?: getApplicationDataDirPath()) + "/Spotube" } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/share/IosShareService.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt similarity index 93% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/share/IosShareService.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt index 8a22a3c2..6499c550 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/share/IosShareService.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/share/IosShareService.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share -import dev.krtirtho.spotube.core.share.ShareService +import org.spotube.spotube.core.share.ShareService import platform.Foundation.NSURL import platform.UIKit.UIActivityViewController import platform.UIKit.UIApplication diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt similarity index 87% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt index 807346fe..c465732c 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.ios.kt @@ -1,41 +1,43 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.core.webview - -import dev.nucleusframework.webview.web.WebViewState -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSDate -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) +/* + * 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 . + */ + +package org.spotube.spotube.core.webview + +import dev.nucleusframework.webview.web.WebViewState +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +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?) { + // Applied to the WKWebView itself, so it covers redirects and XHR too, not just the first request. + SafariUserAgent.value?.let { webView.webSettings.customUserAgentString = it } +} + +@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) } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt new file mode 100644 index 00000000..785657bf --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt @@ -0,0 +1,68 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.webview + +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIDevice +import platform.UIKit.UIUserInterfaceIdiomPad +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration + +/** + * Mobile Safari's exact User-Agent on this device. + * + * WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`, + * which sites like accounts.greedygreencorp.com treat as an embedded browser ("Oops, something went + * wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26 + * reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application + * name and reports `navigator.userAgent`, which is cached for every web view. + */ +internal object SafariUserAgent { + var value: String? = null + private set + + // Kept alive until its async evaluation calls back. + private var probe: WKWebView? = null + + /** Must run on the main thread (WebKit); call once at startup. */ + @OptIn(ExperimentalForeignApi::class) + fun prime() { + if (value != null || probe != null) return + val config = WKWebViewConfiguration().apply { applicationNameForUserAgent = safariApplicationName() } + probe = WKWebView(frame = CGRectZero.readValue(), configuration = config).also { webView -> + webView.evaluateJavaScript("navigator.userAgent") { result, _ -> + value = result as? String + probe = null + Logger.withTag("SafariUserAgent").i { "WebView User-Agent: $value" } + } + } + } + + // What Safari appends after WebKit's base UA; iPad Safari sends the desktop-class form. + private fun safariApplicationName(): String { + val version = UIDevice.currentDevice.systemVersion.split('.').take(2).joinToString(".") + return if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) { + "Version/$version Safari/605.1.15" + } else { + "Version/$version Mobile/15E148 Safari/604.1" + } + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt new file mode 100644 index 00000000..c4a1559a --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.ios.kt @@ -0,0 +1,48 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.zipline + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import platform.Foundation.NSThread +import kotlin.coroutines.ContinuationInterceptor + +// Same as JVM/Android. Zipline lets QuickJS recurse up to ~6 MiB of native stack, but +// iOS gives secondary threads (incl. newSingleThreadContext's) only 512 KiB, so loading +// a plugin overflowed into the guard page and crashed the app with SIGBUS. +private const val ZIPLINE_THREAD_STACK_SIZE: ULong = 8_388_608u // 8 MiB + +actual fun createZiplineDispatcher(): ZiplineDispatcher { + val dispatcher = CompletableDeferred() + val stopped = CompletableDeferred() + // NSThread is the only way to choose a thread's stack size here; it hosts an event + // loop (runBlocking) whose dispatcher accepts work from any thread. + val thread = NSThread { + runBlocking { + dispatcher.complete(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher) + stopped.await() + } + } + thread.name = "Zipline" + thread.stackSize = ZIPLINE_THREAD_STACK_SIZE + thread.start() + return ZiplineDispatcher(runBlocking { dispatcher.await() }) { + stopped.complete(Unit) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt similarity index 87% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt index f47d02dd..76178dde 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media class IosLocalMediaDiscoveryService : LocalMediaDiscoveryService { override suspend fun discoverFolders(roots: List): List { @@ -24,8 +24,7 @@ class IosLocalMediaDiscoveryService : LocalMediaDiscoveryService { override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { return null } } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt similarity index 90% rename from composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt rename to composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt index 24a2efe0..db540813 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.compose.runtime.Composable @@ -23,6 +23,5 @@ import androidx.compose.runtime.Composable actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { return LocalMediaPermissionState( isGranted = true, - requestPermission = {}, - ) + requestPermission = {}) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt deleted file mode 100644 index bb3e4d29..00000000 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt +++ /dev/null @@ -1,90 +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 . - */ - -package dev.krtirtho.spotube.core.systemtray - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.nucleusframework.application.NucleusApplicationScope -import dev.nucleusframework.composenativetray.tray.api.Tray -import org.koin.compose.koinInject - -/** - * Renders the system tray icon + menu via Compose Native Tray (no AWT/Swing). - * - * Must be composed inside `nucleusApplication { }` and kept in composition - * while the tray should be visible (the menu is fully reactive via - * [SystemTrayService.state]). - */ -@Composable -fun NucleusApplicationScope.SystemTray( - isWindowVisible: Boolean, - onToggleWindowVisibility: () -> Unit, - onExit: () -> Unit, -) { - val trayService = koinInject() - val state by trayService.state.collectAsState() - - Tray( - icon = Iconsax.IconsaxMusic, - tooltip = "Spotube", - primaryAction = onToggleWindowVisibility, - ) { - Item(label = if (isWindowVisible) "Hide Window" else "Show Window") { - onToggleWindowVisibility() - } - Divider() - - Item(label = if (state.isPlaying) "Pause" else "Play") { - trayService.togglePlayPause() - } - Item(label = "Next Track") { trayService.skipToNext() } - Item(label = "Previous Track") { trayService.skipToPrevious() } - Divider() - - CheckableItem( - label = "Shuffle", - checked = state.shuffleEnabled, - onCheckedChange = trayService::setShuffle, - ) - SubMenu(label = "Loop") { - Item(label = "Loop: OFF") { trayService.setLoop(LoopState.NONE) } - Item(label = "Loop: ONE") { trayService.setLoop(LoopState.ONE) } - Item(label = "Loop: ALL") { trayService.setLoop(LoopState.ALL) } - } - Divider() - - Item(label = "Volume: ${(state.volume * 100).toInt()}%") - Item(label = "Volume +") { trayService.increaseVolume() } - Item(label = "Volume -") { trayService.decreaseVolume() } - Item(label = if (state.volume <= 0f) "Unmute" else "Mute") { trayService.toggleMute() } - Divider() - - if (state.currentTrackId != null) { - Item(label = if (state.isCurrentTrackLiked) "Unlike Track" else "Like Track") { - trayService.toggleLike() - } - } - Divider() - - Item(label = "Exit") { onExit() } - } -} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt deleted file mode 100644 index 3e597a48..00000000 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ /dev/null @@ -1,146 +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 . - */ - -package dev.krtirtho.spotube - -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.rememberWindowState -import com.sun.jna.Library -import com.sun.jna.Native -import dev.krtirtho.spotube.core.di.initKoin -import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.systemtray.SystemTray -import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope -import dev.krtirtho.spotube.core.ui.component.LocalWindowScope -import dev.krtirtho.spotube.core.ui.component.LocalWindowState -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend -import dev.nucleusframework.application.nucleusApplication -import io.github.vinceglb.filekit.FileKit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import org.koin.core.component.KoinComponent -import org.koin.core.component.get - -object KoinPathsProvider : KoinComponent { - val paths: Paths get() = get() -} - -private object KoinServicesProvider : KoinComponent { - val systemTrayService: SystemTrayService get() = get() - val settingsProvider: SettingsProvider get() = get() -} - -private interface LibC : Library { - fun setenv(name: String, value: String, overwrite: Int): Int - - companion object { - val INSTANCE: LibC = Native.load("c", LibC::class.java) - } -} - -/** - * WebKitGTK is only used on Linux (Windows = WebView2, macOS = WKWebView). - * The webview is created *after* the window is already mapped and the Tao - * render/swap loop is running. WebKitGTK's accelerated-compositing path then - * initialises its own GL context in-process, racing Tao's swap thread on the - * same Mesa display, which deterministically segfaults `libgallium` on the - * next Compose flush. Disabling WebKit's hardware-accelerated compositing - * (and its DMABUF renderer) removes that GL context entirely — login pages - * render fine in software. Must run before libwebkit2gtk is loaded. - */ -private fun disableWebKitGpuCompositing() { - if (!System.getProperty("os.name").lowercase().contains("linux")) return - LibC.INSTANCE.setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 1) - LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1) -} - -@OptIn(ExperimentalComposeUiApi::class) -fun main() { - disableWebKitGpuCompositing() - FileKit.init(appId = "dev.krtirtho.spotube") - initKoin() - NewPipeDownloader.init(KoinPathsProvider.paths) - val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - nucleusApplication(backend = NucleusBackend.Tao) { - val windowState = rememberWindowState( - width = 1080.dp, - height = 720.dp - ) - - val settingsProvider = KoinServicesProvider.settingsProvider - val settings by settingsProvider.settingsState.collectAsState(initial = null) - val minimizeToTray = settings?.minimizeToTray ?: false - - val trayService = KoinServicesProvider.systemTrayService - - var isWindowVisible by remember { mutableStateOf(true) } - - val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible } - val onExit = { - trayService.close() - appScope.cancel() - exitApplication() - } - - trayService.setCallbacks(onToggleWindowVisibility) - - if (minimizeToTray) { - SystemTray( - isWindowVisible = isWindowVisible, - onToggleWindowVisibility = onToggleWindowVisibility, - onExit = onExit, - ) - } - - DecoratedWindow( - state = windowState, - onCloseRequest = { - if (minimizeToTray) { - isWindowVisible = false - } else { - trayService.close() - appScope.cancel() - exitApplication() - } - }, - title = "Spotube", - visible = isWindowVisible, - ) { - CompositionLocalProvider( - LocalApplicationScope provides this@nucleusApplication, - LocalWindowScope provides this@DecoratedWindow, - LocalWindowState provides windowState - ) { - App() - } - } - } -} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt similarity index 62% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt index dba09a14..ad2c1b57 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/Platform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/Platform.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube +package org.spotube.spotube import java.awt.Desktop import java.net.URI @@ -39,5 +39,24 @@ class JVMPlatform : Platform { actual fun getPlatform(): Platform = JVMPlatform() actual fun openUrlInBrowser(url: String) { - Desktop.getDesktop().browse(URI(url)) + // The app runs on a native (Tao) window, not an AWT frame, so java.awt.Desktop is not a + // reliable way to hand a URL to the OS. Prefer the platform's own opener process and only + // fall back to AWT when no opener is available on PATH. + if (openWithSystemOpener(url)) return + runCatching { Desktop.getDesktop().browse(URI(url)) } +} + +private fun openWithSystemOpener(url: String): Boolean { + val osName = System.getProperty("os.name").lowercase() + val command = when { + osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", url) + osName.contains("mac") -> listOf("open", url) + osName.contains("nux") || osName.contains("nix") || osName.contains("aix") -> + listOf("xdg-open", url) + else -> return false + } + return runCatching { + ProcessBuilder(command).start() + true + }.getOrDefault(false) } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt new file mode 100644 index 00000000..b2f77f13 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt @@ -0,0 +1,68 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.appimage + +import com.sun.jna.Function +import com.sun.jna.NativeLibrary +import java.io.File + +/** + * Makes the GTK 3 and WebKitGTK bundled into the AppImage usable. The AppImage's AppRun already puts + * `$APPDIR/usr/lib` on `LD_LIBRARY_PATH`; the rest has to be done in-process, before any GTK/WebKit code + * loads, because the AppRun is generated and can't be customised: + * + * - WebKitGTK has its helper-process path compiled in. The build patches it to a path relative to the + * working directory (see `DownloadLinuxGtkBundleTask` in buildSrc), so the working directory is moved to + * `$APPDIR/usr`. + * - TLS in WebKit/libsoup needs glib-networking's GIO module, which lives in the bundle. + * - The bundled WebKitGTK 2.44 uses its dmabuf renderer by default. Against a newer host Mesa/compositor + * that path never paints the first frame (the webview stays see-through or black until the window is + * resized), and the bundled GTK/epoxy can even abort while creating the GL context. Disabling it makes + * the webview paint on native Wayland. Left alone if the user already set it. + * + * Does nothing outside the AppImage (deb/rpm/dev runs have no bundled `usr/lib/libgtk-3.so.0`). + */ +object AppImageEnvironment { + fun apply() { + if (!System.getProperty("os.name").lowercase().contains("linux")) return + val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return + val usrDir = File(resourcesDir, "usr") + if (!File(usrDir, "lib/libgtk-3.so.0").isFile) return + + try { + val libc = NativeLibrary.getInstance("c") + val setenv = libc.getFunction("setenv") + val triplet = if (System.getProperty("os.arch").lowercase().let { "aarch64" in it || "arm64" in it }) { + "aarch64-linux-gnu" + } else { + "x86_64-linux-gnu" + } + setenv.set("GIO_EXTRA_MODULES", File(usrDir, "lib/$triplet/gio/modules").absolutePath) + if (System.getenv("WEBKIT_DISABLE_DMABUF_RENDERER").isNullOrBlank()) { + setenv.set("WEBKIT_DISABLE_DMABUF_RENDERER", "1") + } + libc.getFunction("chdir").invokeInt(arrayOf(usrDir.absolutePath)) + } catch (_: Throwable) { + // Falls back to the host's libraries, which only works where they are installed + } + } + + private fun Function.set(name: String, value: String) { + invokeInt(arrayOf(name, value, 1)) + } +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt similarity index 97% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt index 56a44ffb..a5f771bc 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.generated.VLCBundleLoaderGenerated +import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.generated.VLCBundleLoaderGenerated import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.DebugConfig import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery import uk.co.caprica.vlcj.medialist.MediaList @@ -87,11 +88,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val _error = MutableSharedFlow(extraBufferCapacity = 1) actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() - actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = + _currentMediaItem.asStateFlow() actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() actual override val durationFlow: StateFlow = _duration.asStateFlow() actual override val positionFlow: StateFlow = _position.asStateFlow() - actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = + _bufferingPosition.asStateFlow() actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() @@ -101,6 +104,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface init { VLCBundleLoaderGenerated.getVerifiedPath() + val vlcLogArgs = if (DebugConfig.FORCE_VLC_LOGS) arrayOf("--verbose=2") else emptyArray() mediaPlayerFactory = MediaPlayerFactory( null as NativeDiscovery?, "--no-video", @@ -108,8 +112,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface "--aout=any", "--no-osd", "--no-snapshot-preview", -// "--verbose=2" -// "--quiet" + *vlcLogArgs ) audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) { override fun opening(mediaPlayer: MediaPlayer) { @@ -122,7 +125,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface ) { val durationMs = mediaPlayer.status().length() _bufferingPosition.tryEmit( - if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO, + if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO ) _playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY) } @@ -185,7 +188,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface override fun nextItem( mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer, - item: uk.co.caprica.vlcj.media.MediaRef, + item: uk.co.caprica.vlcj.media.MediaRef ) { lock.withLock { val targetIndex = pendingNextIndex ?: run { @@ -338,7 +341,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } } - actual override suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { + actual override suspend fun load( + playlist: List, + autoPlay: Boolean, + startPosition: Int + ) { lock.withLock { if (disposed) return diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt similarity index 62% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt index 8c8c1936..bc7fc177 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/di/Modules.jvm.kt @@ -15,24 +15,27 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.di +package org.spotube.spotube.core.di -import dev.krtirtho.spotube.core.audioplayer.AudioPlayer -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.mediacontrol.SystemMediaControlService -import dev.krtirtho.spotube.core.paths.Paths -import dev.krtirtho.spotube.core.share.JvmShareService -import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService -import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService +import org.spotube.spotube.core.audioplayer.AudioPlayer +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.mediacontrol.SystemMediaControlService +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.share.JvmShareService +import org.spotube.spotube.core.share.ShareService +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.modules.library.local_tracks.media.JvmLocalMediaDiscoveryService +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.withOptions import org.koin.dsl.module +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.window.WindowStateRepository actual val platformModules = module { singleOf(::Paths) + single { WindowStateRepository(get()) } single { AudioPlayer(Unit) } single { JvmLocalMediaDiscoveryService() } single { JvmShareService() } diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt new file mode 100644 index 00000000..94376d20 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt @@ -0,0 +1,53 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +import java.net.DatagramSocket +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface + +// Interface name prefixes for container/VM/VPN links other LAN devices can't reach. +private val VirtualInterfacePrefixes = listOf( + "docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman", + "lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham") + +internal actual fun preferredAdvertiseAddress(): String? = + (routedLanAddress() ?: firstPhysicalLanAddress())?.hostAddress + +/** + * The source address the OS would use to reach the outside world, i.e. the interface that + * owns the default route. A connected UDP socket sends nothing; it only resolves the route. + */ +private fun routedLanAddress(): InetAddress? = runCatching { + DatagramSocket().use { socket -> + socket.connect(InetAddress.getByName("192.0.2.1"), 9) // TEST-NET-1, never routed anywhere + socket.localAddress + } +}.getOrNull()?.takeIf { it is Inet4Address && it.isSiteLocalAddress } + +/** Fallback for networks without a default route (e.g. an offline LAN). */ +private fun firstPhysicalLanAddress(): InetAddress? = runCatching { + NetworkInterface.getNetworkInterfaces().toList() + .filter { iface -> + iface.isUp && !iface.isLoopback && !iface.isVirtual && !iface.isPointToPoint && + VirtualInterfacePrefixes.none { iface.name.startsWith(it) } + } + .flatMap { it.inetAddresses.toList() } + .firstOrNull { it is Inet4Address && it.isSiteLocalAddress } +}.getOrNull() diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt new file mode 100644 index 00000000..62b8f80c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/LocalNetworkPermission.jvm.kt @@ -0,0 +1,24 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.discovery + +import androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on the JVM. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt new file mode 100644 index 00000000..9740ce31 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/i18n/AppLocale.jvm.kt @@ -0,0 +1,36 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.staticCompositionLocalOf +import java.util.Locale + +// Captured before the first override so "System default" can restore it. +private val systemLocale: Locale = Locale.getDefault() + +private val LocalAppLocale = staticCompositionLocalOf { systemLocale.toLanguageTag() } + +@Composable +internal actual fun provideAppLocale(languageTag: String?): ProvidedValue<*> { + val locale = languageTag?.let(Locale::forLanguageTag) ?: systemLocale + // Compose resources read Locale.getDefault() both in composition and in getString(). + Locale.setDefault(locale) + return LocalAppLocale provides locale.toLanguageTag() +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt new file mode 100644 index 00000000..c37e8a47 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt @@ -0,0 +1,30 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import java.util.Locale +import java.util.TimeZone + +internal actual fun jamDeviceTraits(): List = listOf( + "desktop", + System.getProperty("os.name").orEmpty(), + System.getProperty("os.version").orEmpty(), + System.getProperty("os.arch").orEmpty(), + Runtime.getRuntime().availableProcessors().toString(), + Locale.getDefault().toLanguageTag(), + TimeZone.getDefault().id) diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt similarity index 89% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt index 05281c43..d5e2a2dc 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/mediacontrol/SystemMediaControlService.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.mediacontrol +package org.spotube.spotube.core.mediacontrol -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.MediaItem -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.di.injectLogger +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.di.injectLogger import dev.nucleusframework.media.control.MediaControlEvent import dev.nucleusframework.media.control.MediaControlService import dev.nucleusframework.media.control.MediaMetadata @@ -44,8 +44,7 @@ import kotlin.time.Duration.Companion.milliseconds * dispatched onto [scope] so they reach the player's suspend API. */ class SystemMediaControlService( - private val audioPlayer: AudioPlayerInterface, -) : KoinComponent, AutoCloseable { + private val audioPlayer: AudioPlayerInterface) : KoinComponent, AutoCloseable { private val logger by injectLogger() @@ -105,12 +104,10 @@ class SystemMediaControlService( scope.launch { combine( audioPlayer.playerStateFlow, - audioPlayer.positionFlow, - ) { state, position -> + audioPlayer.positionFlow) { state, position -> MediaPlaybackState( status = state.toMediaPlaybackStatus(), - positionMs = position.inWholeMilliseconds, - ) + positionMs = position.inWholeMilliseconds) }.collect { MediaControlService.setPlaybackState(it) } } @@ -127,8 +124,7 @@ class SystemMediaControlService( artist = artist, album = album, coverUrl = coverURL.ifBlank { null }, - duration = duration.inWholeMilliseconds, - ) + duration = duration.inWholeMilliseconds) } private fun PlayerState.toMediaPlaybackStatus(): MediaPlaybackStatus { diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt similarity index 97% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt index 652081af..54d70521 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/paths/Paths.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.paths +package org.spotube.spotube.core.paths import net.harawata.appdirs.AppDirsFactory diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/share/JvmShareService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt similarity index 91% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/share/JvmShareService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt index a56407e7..096e23d0 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/share/JvmShareService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/share/JvmShareService.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.share +package org.spotube.spotube.core.share -import dev.krtirtho.spotube.core.share.ShareService +import org.spotube.spotube.core.share.ShareService import java.awt.Toolkit import java.awt.datatransfer.StringSelection diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt new file mode 100644 index 00000000..50f55bf5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTray.kt @@ -0,0 +1,129 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.systemtray + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxMusic +import dev.nucleusframework.application.NucleusApplicationScope +import dev.nucleusframework.composenativetray.tray.api.Tray +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.tray_exit +import spotube.composeapp.generated.resources.tray_hide_window +import spotube.composeapp.generated.resources.tray_like_track +import spotube.composeapp.generated.resources.tray_loop +import spotube.composeapp.generated.resources.tray_loop_all +import spotube.composeapp.generated.resources.tray_loop_off +import spotube.composeapp.generated.resources.tray_loop_one +import spotube.composeapp.generated.resources.tray_mute +import spotube.composeapp.generated.resources.tray_next_track +import spotube.composeapp.generated.resources.tray_previous_track +import spotube.composeapp.generated.resources.tray_show_window +import spotube.composeapp.generated.resources.tray_shuffle +import spotube.composeapp.generated.resources.tray_unlike_track +import spotube.composeapp.generated.resources.tray_unmute +import spotube.composeapp.generated.resources.tray_volume +import spotube.composeapp.generated.resources.tray_volume_down +import spotube.composeapp.generated.resources.tray_volume_up + +/** + * Renders the system tray icon + menu via Compose Native Tray (no AWT/Swing). + * + * Must be composed inside `nucleusApplication { }` and kept in composition + * while the tray should be visible (the menu is fully reactive via + * [SystemTrayService.state]). + */ +@Composable +fun NucleusApplicationScope.SystemTray( + isWindowVisible: Boolean, + onToggleWindowVisibility: () -> Unit, + onExit: () -> Unit) { + val trayService = koinInject() + val state by trayService.state.collectAsState() + + // Tray menu builders aren't composable, so resolve every label up front. + val hideWindowLabel = stringResource(Res.string.tray_hide_window) + val showWindowLabel = stringResource(Res.string.tray_show_window) + val playLabel = stringResource(Res.string.player_play_content_description) + val pauseLabel = stringResource(Res.string.player_pause_content_description) + val nextTrackLabel = stringResource(Res.string.tray_next_track) + val previousTrackLabel = stringResource(Res.string.tray_previous_track) + val shuffleLabel = stringResource(Res.string.tray_shuffle) + val loopLabel = stringResource(Res.string.tray_loop) + val loopOffLabel = stringResource(Res.string.tray_loop_off) + val loopOneLabel = stringResource(Res.string.tray_loop_one) + val loopAllLabel = stringResource(Res.string.tray_loop_all) + val volumeLabel = stringResource(Res.string.tray_volume, (state.volume * 100).toInt()) + val volumeUpLabel = stringResource(Res.string.tray_volume_up) + val volumeDownLabel = stringResource(Res.string.tray_volume_down) + val muteLabel = stringResource(Res.string.tray_mute) + val unmuteLabel = stringResource(Res.string.tray_unmute) + val likeTrackLabel = stringResource(Res.string.tray_like_track) + val unlikeTrackLabel = stringResource(Res.string.tray_unlike_track) + val exitLabel = stringResource(Res.string.tray_exit) + + Tray( + icon = Iconsax.IconsaxMusic, + tooltip = "Spotube", + primaryAction = onToggleWindowVisibility) { + Item(label = if (isWindowVisible) hideWindowLabel else showWindowLabel) { + onToggleWindowVisibility() + } + Divider() + + Item(label = if (state.isPlaying) pauseLabel else playLabel) { + trayService.togglePlayPause() + } + Item(label = nextTrackLabel) { trayService.skipToNext() } + Item(label = previousTrackLabel) { trayService.skipToPrevious() } + Divider() + + CheckableItem( + label = shuffleLabel, + checked = state.shuffleEnabled, + onCheckedChange = trayService::setShuffle) + SubMenu(label = loopLabel) { + Item(label = loopOffLabel) { trayService.setLoop(LoopState.NONE) } + Item(label = loopOneLabel) { trayService.setLoop(LoopState.ONE) } + Item(label = loopAllLabel) { trayService.setLoop(LoopState.ALL) } + } + Divider() + + Item(label = volumeLabel) + Item(label = volumeUpLabel) { trayService.increaseVolume() } + Item(label = volumeDownLabel) { trayService.decreaseVolume() } + Item(label = if (state.volume <= 0f) unmuteLabel else muteLabel) { trayService.toggleMute() } + Divider() + + if (state.currentTrackId != null) { + Item(label = if (state.isCurrentTrackLiked) unlikeTrackLabel else likeTrackLabel) { + trayService.toggleLike() + } + } + Divider() + + Item(label = exitLabel) { onExit() } + } +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt similarity index 86% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt index b9dfe02c..9c5615ea 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/systemtray/SystemTrayService.kt @@ -15,15 +15,15 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.systemtray +package org.spotube.spotube.core.systemtray -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.MediaItem -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -44,8 +44,7 @@ import kotlinx.coroutines.launch class SystemTrayService( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val savedTracksRepository: SavedTracksRepository, -) : AutoCloseable { + private val savedTracksRepository: SavedTracksRepository) : AutoCloseable { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -61,8 +60,7 @@ class SystemTrayService( audioPlayer.shuffleModeFlow, audioPlayer.volumeFlow, audioPlayerQueue.currentQueueEntryFlow, - savedTracksRepository.savedTracksIdsFlow, - ) { values -> + savedTracksRepository.savedTracksIdsFlow) { values -> TrayState( playerState = values[0] as PlayerState, mediaItem = values[1] as MediaItem?, @@ -70,8 +68,7 @@ class SystemTrayService( shuffleEnabled = values[3] as Boolean, volume = values[4] as Float, currentEntry = values[5] as QueueEntry?, - savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set), - ) + savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set)) }.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial) fun setCallbacks(onToggleWindowVisibility: () -> Unit) { @@ -137,8 +134,7 @@ class SystemTrayService( val shuffleEnabled: Boolean, val volume: Float, val currentEntry: QueueEntry?, - val savedTrackIds: Set, - ) { + val savedTrackIds: Set) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val currentTrackId: String? get() = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -155,8 +151,7 @@ class SystemTrayService( shuffleEnabled = false, volume = 1f, currentEntry = null, - savedTrackIds = emptySet(), - ) + savedTrackIds = emptySet()) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt similarity index 50% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt index cd346728..7e007e12 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -15,22 +15,27 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.graphics.SolidColor import androidx.compose.foundation.window.WindowDraggableArea -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.TopAppBar + +import org.spotube.millennium.composables.MillenniumTopAppBar +import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -38,36 +43,45 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement -import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import dev.krtirtho.spotube.resources.iconsax.FluentDismiss -import dev.krtirtho.spotube.resources.iconsax.FluentMaximize -import dev.krtirtho.spotube.resources.iconsax.FluentMinus -import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple -import dev.krtirtho.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.resources.iconsax.FluentDismiss +import org.spotube.spotube.resources.iconsax.FluentMaximize +import org.spotube.spotube.resources.iconsax.FluentMinus +import org.spotube.spotube.resources.iconsax.FluentSquareMultiple +import org.spotube.spotube.resources.iconsax.Iconsax import dev.nucleusframework.window.WindowControls import dev.nucleusframework.window.WindowControlsRenderer import dev.nucleusframework.window.WindowDoubleClickAction import dev.nucleusframework.window.windowDragArea +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.app_window_close +import spotube.composeapp.generated.resources.app_window_maximize +import spotube.composeapp.generated.resources.app_window_minimize +import spotube.composeapp.generated.resources.app_window_restore +private val WindowControlsReservedWidth = 132.dp -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), backButton: Boolean, -) { + transparent: Boolean) { Box( modifier = Modifier.fillMaxWidth() .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize) ) { - TopAppBar( + MillenniumTopAppBar( title = title, actions = { actions() + // Reserve room for the platform window controls instead of shrinking the + // whole bar, so it spans the full width with no seam under the controls. + Spacer(Modifier.width(WindowControlsReservedWidth)) }, navigationIcon = { if (backButton) { @@ -75,7 +89,9 @@ actual fun ApplicationMainBar( } }, subtitle = subtitle, - modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons + background = if (transparent) SolidColor(Color.Transparent) else null, + separatorColor = if (transparent) Color.Transparent else null, + modifier = Modifier.fillMaxWidth() ) LocalWindowScope.current.WindowControls( modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp), @@ -96,27 +112,33 @@ private fun WindowButtons(modifier: Modifier = Modifier) { modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton(onClick = { windowState.isMinimized = true }) { - Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)) + MillenniumIconButton( + onClick = { windowState.isMinimized = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentMinus, stringResource(Res.string.app_window_minimize), modifier = Modifier.size(14.dp)) } - IconButton(onClick = { - windowState.placement = if (windowState.placement == WindowPlacement.Maximized) - WindowPlacement.Floating else WindowPlacement.Maximized - }) { + MillenniumIconButton( + onClick = { + windowState.placement = if (windowState.placement == WindowPlacement.Maximized) + WindowPlacement.Floating else WindowPlacement.Maximized + }, + style = MillenniumIconButtonStyle.Quiet) { if (windowState.placement == WindowPlacement.Floating) { - Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentMaximize, stringResource(Res.string.app_window_maximize), modifier = Modifier.size(14.dp)) } else { - Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentSquareMultiple, stringResource(Res.string.app_window_restore), modifier = Modifier.size(14.dp)) } } - IconButton(onClick = { - if (settings?.minimizeToTray == true) { - trayService.hideWindow() - } else { - applicationScope.exitApplication() - } - }) { - Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)) + MillenniumIconButton( + onClick = { + if (settings?.minimizeToTray == true) { + trayService.hideWindow() + } else { + applicationScope.exitApplication() + } + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentDismiss, stringResource(Res.string.app_window_close), modifier = Modifier.size(14.dp)) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt similarity index 95% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt index ab238909..5f5eb7ed 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalApplication.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.window.ApplicationScope diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt similarity index 96% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt index f95f7cf1..b9912cbc 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/LocalWindow.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.window.WindowState diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt similarity index 56% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt index e38e6397..4adf2c8d 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.jvm.kt @@ -15,28 +15,42 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.ScrollbarStyle import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.spotube.spotube.core.ui.theming.AppTheme @Composable actual fun VerticalScrollbar( listState: LazyListState, modifier: Modifier ) { - VerticalScrollbar( - adapter = rememberScrollbarAdapter(listState), - modifier = modifier.fillMaxHeight() - .padding(end = 2.dp) - .padding(vertical = 16.dp) - .width(12.dp), - ) + val colors = AppTheme.colors + CompositionLocalProvider( + LocalScrollbarStyle provides ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = 300, + unhoverColor = colors.onSurface.copy(alpha = 0.18f), + hoverColor = colors.onSurface.copy(alpha = 0.4f))) { + VerticalScrollbar( + adapter = rememberScrollbarAdapter(listState), + modifier = modifier.fillMaxHeight() + .padding(end = 2.dp) + .padding(vertical = 16.dp) + .width(12.dp)) + } } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt new file mode 100644 index 00000000..eae0a660 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.theming + +import androidx.compose.runtime.Composable +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode + +// The Tao window never feeds Compose's LocalSystemTheme, so ask the OS directly +@Composable +actual fun isSystemDarkTheme(): Boolean = isSystemInDarkMode() diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt similarity index 92% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt index 818f3ab5..08f1f802 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/webview/PlatformWebviewConfig.jvm.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.webview +package org.spotube.spotube.core.webview -import dev.krtirtho.spotube.core.paths.Paths +import org.spotube.spotube.core.paths.Paths import dev.nucleusframework.webview.web.WebViewState import okio.FileSystem import okio.Path.Companion.toPath diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt new file mode 100644 index 00000000..6bddb90a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/window/WindowStateRepository.kt @@ -0,0 +1,89 @@ +/* + * 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 . + */ + + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.db.DatabaseKeys + +/** + * What survives a restart: the last *normal* (floating) size, and whether the window was + * maximized. The size is kept while maximized so un-maximizing after a restart restores it. + */ +@Serializable +data class PersistedWindowState( + val widthDp: Float, + val heightDp: Float, + val maximized: Boolean +) { + val size: DpSize get() = DpSize(widthDp.dp, heightDp.dp) + val placement: WindowPlacement + get() = if (maximized) WindowPlacement.Maximized else WindowPlacement.Floating + + companion object { + fun of(size: DpSize, placement: WindowPlacement) = PersistedWindowState( + widthDp = size.width.value, + heightDp = size.height.value, + maximized = placement == WindowPlacement.Maximized) + } +} + +class WindowStateRepository internal constructor(private val dataStore: DataStore) { + constructor(database: Database) : this(database.windowStateDataStore) + + private val json = Json { ignoreUnknownKeys = true } + + /** Returns null when nothing was saved yet or the stored value is unreadable. */ + suspend fun load(): PersistedWindowState? = runCatching { + val raw = dataStore.data.first()[DatabaseKeys.WINDOW_STATE_KEY] ?: return null + json.decodeFromString(raw) + }.getOrNull() + + suspend fun save(state: PersistedWindowState) { + runCatching { + dataStore.edit { prefs -> + prefs[DatabaseKeys.WINDOW_STATE_KEY] = json.encodeToString(PersistedWindowState.serializer(), state) + } + } + } +} + +/** + * Keeps a restored [size] usable: never smaller than [min], and never bigger than the + * available screen ([maxWidth] x [maxHeight]) so a size saved on a larger monitor can't open + * off-screen. A null max means the screen size is unknown, so only [min] is enforced. + */ +fun DpSize.coerceForScreen( + min: DpSize, + maxWidth: Dp?, + maxHeight: Dp? +): DpSize { + val width = width.coerceAtLeast(min.width).let { if (maxWidth != null) it.coerceAtMost(maxOf(maxWidth, min.width)) else it } + val height = height.coerceAtLeast(min.height).let { if (maxHeight != null) it.coerceAtMost(maxOf(maxHeight, min.height)) else it } + return DpSize(width, height) +} diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt similarity index 96% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt index b05bcc5e..3591c937 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplineDispatcher.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/zipline/ZiplineDispatcher.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.zipline +package org.spotube.spotube.core.zipline import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.Executors diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt new file mode 100644 index 00000000..17f57aec --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -0,0 +1,245 @@ +/* + * 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 . + */ + +package org.spotube.spotube + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.rememberWindowState +import org.jetbrains.compose.resources.painterResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.spotube_logo +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.graalvm.GraalVmInitializer +import dev.nucleusframework.window.tao.TaoScreenGeometry +import io.github.vinceglb.filekit.FileKit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.spotube.spotube.core.appimage.AppImageEnvironment +import org.spotube.spotube.core.di.initKoin +import org.spotube.spotube.core.i18n.AppLocaleProvider +import org.spotube.spotube.core.newpipe.NewPipeDownloader +import org.spotube.spotube.core.paths.Paths +import org.spotube.spotube.core.systemtray.SystemTray +import org.spotube.spotube.core.systemtray.SystemTrayService +import org.spotube.spotube.core.ui.component.LocalApplicationScope +import org.spotube.spotube.core.ui.component.LocalWindowScope +import org.spotube.spotube.core.ui.component.LocalWindowState +import org.spotube.spotube.core.window.PersistedWindowState +import org.spotube.spotube.core.window.WindowStateRepository +import org.spotube.spotube.core.window.coerceForScreen +import org.spotube.spotube.modules.settings.SettingsProvider +import org.spotube.spotube.modules.settings.SupportedLanguages + +object KoinPathsProvider : KoinComponent { + val paths: Paths get() = get() +} + +private object KoinServicesProvider : KoinComponent { + val systemTrayService: SystemTrayService get() = get() + val settingsProvider: SettingsProvider get() = get() + val windowStateRepository: WindowStateRepository get() = get() +} + +private const val WINDOW_SCREEN_FRACTION = 0.85f +private const val WINDOW_ASPECT_RATIO = 16f / 10f +private val MAX_WINDOW_WIDTH = 1440.dp +private val MAX_WINDOW_HEIGHT = 960.dp +private val MIN_WINDOW_SIZE = DpSize(800.dp, 500.dp) +private const val WINDOW_STATE_SAVE_DEBOUNCE_MS = 500L + +/** Largest [WINDOW_ASPECT_RATIO] size that fits inside [maxWidth] x [maxHeight]. */ +private fun fitAspectRatio(maxWidth: Dp, maxHeight: Dp): DpSize { + val width = minOf(maxWidth, maxHeight * WINDOW_ASPECT_RATIO) + return DpSize(width, width / WINDOW_ASPECT_RATIO) +} + +/** + * A 16:10 window that fits in 85% of the primary monitor's work area (screen minus + * taskbar/dock), capped at 1440x960. + * + * Uses Nucleus' Tao screen geometry rather than AWT, which doesn't exist on the Tao runtime. + * The work area is in physical pixels, so it's divided by the monitor scale factor to get dp. + * Falls back to the cap box when the platform bridge can't report a monitor. + */ +private fun initialWindowSize(): DpSize { + val workArea = availableScreenSize() + ?: return fitAspectRatio(MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT) + + return fitAspectRatio( + maxWidth = (workArea.width * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_WIDTH), + maxHeight = (workArea.height * WINDOW_SCREEN_FRACTION).coerceAtMost(MAX_WINDOW_HEIGHT)) +} + +/** The primary monitor's work area in dp, or null when the platform bridge can't report it. */ +private fun availableScreenSize(): DpSize? { + val workArea = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx() }.getOrNull() + if (workArea == null || workArea.size < 4 || workArea[2] <= 0 || workArea[3] <= 0) return null + + val scale = runCatching { TaoScreenGeometry.primaryMonitorScaleFactor() } + .getOrDefault(1f) + .takeIf { it > 0f } ?: 1f + + return DpSize((workArea[2] / scale).dp, (workArea[3] / scale).dp) +} + +/** Saved size if there is one (clamped to this screen), otherwise the default for a first launch. */ +private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowState { + if (saved == null || saved.widthDp <= 0f || saved.heightDp <= 0f) { + return PersistedWindowState.of(initialWindowSize(), WindowPlacement.Floating) + } + val screen = availableScreenSize() + val size = saved.size.coerceForScreen( + min = MIN_WINDOW_SIZE, + maxWidth = screen?.width, + maxHeight = screen?.height) + return PersistedWindowState.of(size, saved.placement) +} + +/** Runs [block] on a fresh thread and waits for it, rethrowing whatever it threw. */ +private fun runOnWorkerThread(name: String, block: () -> Unit) { + var failure: Throwable? = null + val thread = Thread({ runCatching(block).onFailure { failure = it } }, name) + thread.start() + thread.join() + failure?.let { throw it } +} + +@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) +fun main() { + // Sets compose.application.resources.dir in the native image. initKoin() creates the audio + // player, whose VLC loader reads it, so it can't wait for nucleusApplication() (which calls + // this again, harmlessly). Without it the bundled VLC is skipped for the system one. + GraalVmInitializer.initialize() + AppImageEnvironment.apply() + FileKit.init(appId = "org.spotube.spotube") + val initApp = { + initKoin() + NewPipeDownloader.init(KoinPathsProvider.paths) + } + // libvlc and the system media controls initialize COM as multithreaded on the calling thread. In the + // native image the main thread also creates the Tao window, whose OleInitialize needs a single-threaded + // apartment and panics with RPC_E_CHANGED_MODE, so on Windows Koin's eager singletons are created + // elsewhere. COM doesn't exist on macOS/Linux, where this stays on the main thread as before. + if (getPlatform().type == PlatformType.Windows) { + runOnWorkerThread("spotube-init", initApp) + } else { + initApp() + } + val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + nucleusApplication(backend = NucleusBackend.Tao) { + val windowStateRepository = remember { KoinServicesProvider.windowStateRepository } + // Needed before the window is created, so the one-time read at startup is blocking + val restored = remember { restoreWindowState(runBlocking { windowStateRepository.load() }) } + val windowState = rememberWindowState(placement = restored.placement, size = restored.size) + + // The size to restore into when un-maximizing; only floating windows update it + var normalSize by remember { mutableStateOf(restored.size) } + val currentWindowState = { PersistedWindowState.of(normalSize, windowState.placement) } + // Exit paths can't wait on a coroutine that is about to be cancelled + val persistWindowStateBlocking = { runBlocking { windowStateRepository.save(currentWindowState()) } } + + LaunchedEffect(windowState) { + snapshotFlow { windowState.placement to windowState.size } + .onEach { (placement, size) -> + if (placement == WindowPlacement.Floating && size.isSpecified && + size.width > 0.dp && size.height > 0.dp) { + normalSize = size + } + } + .debounce(WINDOW_STATE_SAVE_DEBOUNCE_MS) + .collect { windowStateRepository.save(currentWindowState()) } + } + + val settingsProvider = KoinServicesProvider.settingsProvider + val settings by settingsProvider.settingsState.collectAsState(initial = null) + val minimizeToTray = settings?.minimizeToTray ?: false + + val trayService = KoinServicesProvider.systemTrayService + + var isWindowVisible by remember { mutableStateOf(true) } + + val onToggleWindowVisibility = { isWindowVisible = !isWindowVisible } + val onExit = { + persistWindowStateBlocking() + trayService.close() + appScope.cancel() + exitApplication() + } + + trayService.setCallbacks(onToggleWindowVisibility) + + if (minimizeToTray) { + // The tray lives outside App(), so it needs its own provider to follow the app language. + AppLocaleProvider(settings?.language ?: SupportedLanguages.SYSTEM) { + SystemTray( + isWindowVisible = isWindowVisible, + onToggleWindowVisibility = onToggleWindowVisibility, + onExit = onExit + ) + } + } + + DecoratedWindow( + state = windowState, + onCloseRequest = { + if (minimizeToTray) { + isWindowVisible = false + } else { + persistWindowStateBlocking() + trayService.close() + appScope.cancel() + exitApplication() + } + }, + title = "Spotube", + icon = painterResource(Res.drawable.spotube_logo), + visible = isWindowVisible + ) { + CompositionLocalProvider( + LocalApplicationScope provides this@nucleusApplication, + LocalWindowScope provides this@DecoratedWindow, + LocalWindowState provides windowState + ) { + App() + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt similarity index 79% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt index fa2b1cf5..dab76069 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt @@ -15,10 +15,17 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.FilterMipmap +import org.jetbrains.skia.FilterMode +import org.jetbrains.skia.Image +import org.jetbrains.skia.MipmapMode +import org.jetbrains.skia.Rect +import org.jetbrains.skia.Surface import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path @@ -70,8 +77,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), durationMs = metadata?.durationMs ?: 0L, album = metadata?.album, - coverBytes = metadata?.coverBytes, - ) + coverBytes = metadata?.coverBytes?.let(::coverThumbnail)) folders.getOrPut(parent) { mutableListOf() }.add(track) } } @@ -85,16 +91,14 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { LocalMediaFolder( path = path, name = Path.of(path).fileName?.toString().orEmpty().ifBlank { path }, - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } } override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { val resolvedRoots = resolveRoots(roots) if (resolvedRoots.isEmpty()) return null @@ -109,8 +113,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY, - ) + StandardWatchEventKinds.ENTRY_MODIFY) } } } @@ -169,8 +172,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { } else { listOf( Path.of(home, "Music"), - Path.of(home, "Downloads"), - ) + Path.of(home, "Downloads")) } return (defaults + customRoots) @@ -189,7 +191,33 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { "wav", "ogg", "opus", - "wma", - ) + "wma") } } + +/** + * Embedded covers can be several MB each and the index is serialised to JSON, so keep a small + * JPEG like Android does (#3193). Returns null for unreadable art. + */ +internal fun coverThumbnail(bytes: ByteArray, size: Int = COVER_THUMBNAIL_SIZE): ByteArray? = runCatching { + Image.makeFromEncoded(bytes).use { source -> + val scale = size.toFloat() / minOf(source.width, source.height) + if (scale >= 1f && bytes.size <= MAX_SMALL_COVER_BYTES) return@runCatching bytes + val width = (source.width * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1) + val height = (source.height * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1) + Surface.makeRasterN32Premul(width, height).use { surface -> + surface.canvas.drawImageRect( + source, + Rect.makeWH(source.width.toFloat(), source.height.toFloat()), + Rect.makeWH(width.toFloat(), height.toFloat()), + FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE), + null, + true) + surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.JPEG, 85)?.bytes } + } + } +}.getOrNull() + +private const val COVER_THUMBNAIL_SIZE = 160 +private const val MAX_SMALL_COVER_BYTES = 64 * 1024 + diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt similarity index 90% rename from composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt rename to composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt index 24a2efe0..db540813 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.modules.library.local_tracks.media +package org.spotube.spotube.modules.library.local_tracks.media import androidx.compose.runtime.Composable @@ -23,6 +23,5 @@ import androidx.compose.runtime.Composable actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { return LocalMediaPermissionState( isGranted = true, - requestPermission = {}, - ) + requestPermission = {}) } diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt similarity index 98% rename from composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt rename to composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt index 537fcfbb..f78eb438 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerTest.kt @@ -1,7 +1,7 @@ -package dev.krtirtho.spotube.core.audioplayer +package org.spotube.spotube.core.audioplayer import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -60,8 +60,7 @@ class AudioPlayerTest { val candidates = listOf( File("src/jvmTest/resources/test-audio"), File("../composeApp/src/jvmTest/resources/test-audio"), - File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio"), - ) + File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio")) for (dir in candidates) { if (dir.isDirectory) return dir } @@ -76,8 +75,7 @@ class AudioPlayerTest { duration = 10.seconds, coverURL = "", url = url, - protocol = StreamProtocol.PROGRESSIVE, - ) + protocol = StreamProtocol.PROGRESSIVE) } private fun testItems(count: Int = 3): List { diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt new file mode 100644 index 00000000..feec3a51 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/AppLocaleProviderTest.kt @@ -0,0 +1,91 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.i18n + +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.use +import org.spotube.spotube.modules.settings.SupportedLanguages +import kotlinx.coroutines.runBlocking +import org.spotube.spotube.core.i18n.getString +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.common_cancel +import spotube.composeapp.generated.resources.plugin_installed_plugins_count +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalComposeUiApi::class) +class AppLocaleProviderTest { + @Test + fun switchingLanguageUpdatesStringsWithoutRestart() { + val systemLocale = Locale.getDefault() + var language by mutableStateOf(SupportedLanguages.DE) + var cancel = "" + var plugins = "" + var direction = LayoutDirection.Ltr + + try { + ImageComposeScene(width = 100, height = 100) { + AppLocaleProvider(language) { + val cancelText = stringResource(Res.string.common_cancel) + val pluginsText = pluralStringResource(Res.plurals.plugin_installed_plugins_count, 3, 3) + val layoutDirection = LocalLayoutDirection.current + SideEffect { + cancel = cancelText + plugins = pluginsText + direction = layoutDirection + } + } + }.use { scene -> + scene.render() + assertEquals("Abbrechen", cancel) + assertEquals("3 Plugins installiert", plugins) + // Non-composable lookups (ViewModels, services) follow the override too. + assertEquals("Abbrechen", runBlocking { getString(Res.string.common_cancel) }) + + language = SupportedLanguages.ZH_TW + scene.render() + assertEquals("取消", cancel) + assertEquals("已安裝 3 個外掛程式", plugins) + + language = SupportedLanguages.RU + scene.render() + assertEquals("3 плагина установлено", plugins) + + language = SupportedLanguages.AR + scene.render() + assertEquals(LayoutDirection.Rtl, direction) + + language = SupportedLanguages.SYSTEM + scene.render() + assertEquals(systemLocale, Locale.getDefault()) + } + } finally { + Locale.setDefault(systemLocale) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/NonComposableStringLookupTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/NonComposableStringLookupTest.kt new file mode 100644 index 00000000..669321d3 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/i18n/NonComposableStringLookupTest.kt @@ -0,0 +1,46 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.i18n + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * The library's non-composable getters query the system theme over D-Bus on Linux on every + * call, which crashed the desktop app (#3183). App code must use the ones in Strings.kt. + */ +class NonComposableStringLookupTest { + @Test + fun appCodeUsesTheAppStringLookups() { + val forbidden = Regex("""^import org\.jetbrains\.compose\.resources\.get(String|PluralString|StringArray)\s*$""") + val offenders = File("src").walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex() + .filter { (_, line) -> forbidden.matches(line) } + .map { (index, _) -> "${file.path}:${index + 1}" } + } + .toList() + + assertTrue( + offenders.isEmpty(), + "Import getString/getPluralString/getStringArray from org.spotube.spotube.core.i18n instead:\n" + + offenders.joinToString("\n")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt new file mode 100644 index 00000000..32224f97 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt @@ -0,0 +1,88 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.spotube.spotube.core.network.HttpClientFactory +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Runs the real client against a room server. Skipped unless `JAM_TEST_URL` is set: + * run `npm run dev` in a checkout of https://github.com/team-spotube/jam-server, then `JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest`. + */ +class JamRoomClientIntegrationTest { + private val serverUrl: String? = System.getenv("JAM_TEST_URL") + + @Test + fun hostAndGuestShareARoom() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val host = JamRoomClient(http) + val guest = JamRoomClient(http) + val code = JamRoomCode.generate() + try { + host.testConnection(url).getOrThrow() + + val hostWelcome = host.connect(url, code, JamRoomCode.newClientKey(), "Host", create = true).getOrThrow() + val hostId = hostWelcome.you + assertEquals(hostId, hostWelcome.host) + // Same machine: the clock offset is just measurement noise. + assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}") + + val queue = JamQueue(listOf(JamMediaItem(url = "", trackId = "t1", title = "Song", artist = "A", + album = "", durationMs = 1_000, coverUrl = "", protocol = "PROGRESSIVE"))) + host.sendQueue(queue) + val anchor = JamAnchor("t1", 0, positionMs = 1_234, serverTimeMs = host.clock.serverTime(0), playing = true) + host.sendAnchor(anchor) + Thread.sleep(300) + + // Late joiners get the retained queue and anchor in the welcome. + val guestKey = JamRoomCode.newClientKey() + val guestWelcome = guest.connect(url, code, guestKey, "Guest", create = false).getOrThrow() + val guestId = guestWelcome.you + assertEquals(queue, guestWelcome.queue) + assertEquals(anchor, guestWelcome.anchor) + assertEquals(listOf(hostId, guestId), guestWelcome.participants.map { it.id }) + + val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance().first() } + guest.sendCommand(JamCommand.SeekTo(42_000)) + val command = withTimeout(5_000) { received.await() } + assertEquals(guestId, command.from) + assertEquals(JamCommand.SeekTo(42_000), command.command) + + val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance().first() } + host.kick(guestId, ban = false) + assertEquals("kicked", withTimeout(5_000) { closed.await() }.code) + + val wrongRoom = guest.connect(url, JamRoomCode.generate(), guestKey, "Guest", create = false) + assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code) + } finally { + host.disconnect() + guest.disconnect() + http.close() + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt new file mode 100644 index 00000000..1bba4a76 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt @@ -0,0 +1,705 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue +import org.spotube.spotube.core.audioplayer.FakeAudioPlayerQueueRepository +import org.spotube.spotube.core.audioplayer.FakeBlacklistRepository +import org.spotube.spotube.core.audioplayer.FakePluginProvider +import org.spotube.spotube.core.audioplayer.FakeSettingsProvider +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.spotube.core.server.FakeAudioPluginService +import org.spotube.spotube.core.server.FakeAudioPluginSource +import org.spotube.spotube.core.server.FakeStreamingAudioPlayerQueue +import org.spotube.spotube.core.server.FakeTrackSourceRepository +import org.spotube.spotube.core.server.FakeUserSettingsSource +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.UserSettingsStore +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.math.abs +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * A host and a guest [JamRoomService], each driving a simulated player, in one room on a + * real room server. The guest's player runs 1% fast, the host's 0.1% slow (so it has to + * re-anchor), and both report positions coarsely, like real players. Skipped unless `JAM_TEST_URL` is set (see [JamRoomClientIntegrationTest]). + */ +class JamSessionSimulationTest { + private val serverUrl: String? = System.getenv("JAM_TEST_URL") + + @BeforeTest + fun setup() { + startKoin { modules(module { factory { (tag: String?) -> Logger.withTag(tag ?: "test") } }) } + } + + @AfterTest + fun teardown() { + stopKoin() + } + + @Test + fun guestFollowsTheHost() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("track-$it") } + val hostSource = source("host-upload") + val host = Participant(http, url, speedError = 0.999) + val guest = Participant(http, url, speedError = 1.01) + host.sources.seed(tracks[0], hostSource, PLUGIN) + guest.sources.seed(tracks[0], source("other-upload"), PLUGIN) + + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(2_000) + + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + assertEquals(JamRole.Host, host.service.role.value) + assertEquals(JamRole.Guest, guest.service.role.value) + + delay(7_000) + assertInSync(host, guest, "after joining") + assertEquals(hostSource, guest.sources.rawEntryFor(tracks[0].id)?.source, "guest uses the host's source") + + host.player.seekTo(60_000.milliseconds) + delay(5_000) + assertInSync(host, guest, "after the host seeked") + + // A guest's seek moves the whole room. + guest.player.seekTo(90_000.milliseconds) + // Two hops (guest -> host -> guest) and two seeks: allow longer to settle. + delay(8_000) + assertTrue(host.player.truePosition() in 96_000..100_000, "host followed the guest's seek: ${host.player.truePosition()}") + assertInSync(host, guest, "after the guest seeked") + + guest.player.pause() + delay(1_500) + assertEquals(PlayerState.PAUSED, host.player.playerStateFlow.value, "guest paused the room") + + host.player.play() + host.service.skipNext() + delay(6_000) + assertEquals(1, host.player.index.value) + assertEquals(1, guest.player.index.value, "guest followed the skip") + assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value) + assertInSync(host, guest, "after the skip") + + // Mashing skip stays within the server's flood limits (the host isn't cut off). + repeat(20) { if (it % 2 == 0) host.service.skipNext() else host.service.skipPrevious() } + delay(6_000) + assertEquals(true, host.service.isConnected.value, "host still connected after mashing skip") + assertEquals(host.player.index.value, guest.player.index.value, "guest followed the skips") + assertInSync(host, guest, "after mashing skip") + + // Steady playback costs (almost) nothing: count the anchors a listener receives. + val listener = JamRoomClient(http) + try { + listener.connect(url, code, JamRoomCode.newClientKey(), "Listener", create = false).getOrThrow() + delay(1_000) // The host answers the join with an anchor. + var anchors = 0 + val counting = launch { + listener.events.collect { if (it is JamRoomEvent.Anchor) anchors++ } + } + delay(STEADY_WINDOW_MS) + counting.cancel() + println("Jam simulation: $anchors anchor(s) in ${STEADY_WINDOW_MS / 1000}s of steady playback") + assertTrue(anchors <= 2, "steady playback sent $anchors anchors") + assertInSync(host, guest, "after steady playback") + } finally { + listener.disconnect() + } + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * Songs that end on their own. The guest has its own uploads of every song (a shorter + * intro) and its player loads the next song halfway through the current one, so it + * must have the host's recording before then, or it plays a different one. + */ + @Test + fun usesTheHostsRecordingForEverySong() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("song-$it", durationMs = SONG_MS) } + val host = Participant(http, url, speedError = 1.0, realQueue = true) + val guest = Participant(http, url, speedError = 1.01, realQueue = true) + tracks.forEach { + host.sources.seed(it, source("host-${it.id}"), PLUGIN) + guest.sources.seed(it, source("own-${it.id}"), PLUGIN) + } + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(6_000) + assertEquals("host-song-0", guest.player.currentSource, "guest plays the host's recording of song 0") + assertInSync(host, guest, "during song 0") + + for (song in 1..2) { + while (host.player.index.value < song) delay(100) + delay(5_000) + assertEquals(song, guest.player.index.value, "guest on song $song") + assertEquals("host-song-$song", guest.player.currentSource, "guest plays the host's recording of song $song") + assertInSync(host, guest, "5 s into song $song after the previous one ended") + } + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * A guest that can't use the host's recordings (another audio plugin) finishes each + * song early. It must wait for the host, not jump back to the song that just ended. + */ + @Test + fun aGuestThatFinishesASongFirstWaitsForTheHost() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("song-$it", durationMs = SONG_MS) } + val host = Participant(http, url, speedError = 1.0) + val guest = Participant(http, url, speedError = 1.0, pluginId = "other-plugin") + tracks.forEach { guest.sources.seed(it, source("own-${it.id}"), "other-plugin") } + val jumpedBack = CopyOnWriteArrayList() + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(4_000) + val watcher = launch { + var last = guest.player.index.value + guest.player.index.collect { now -> + if (now < last) jumpedBack += "guest went back from song $last to $now" + last = now + } + } + for (song in 1..2) { + while (host.player.index.value < song) delay(100) + delay(5_000) + assertEquals(song, guest.player.index.value, "guest on song $song") + } + watcher.cancel() + assertTrue(jumpedBack.isEmpty(), jumpedBack.toString()) + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * A jam must not keep the CPU busy (a spinning loop allocates nonstop and freezes the + * app in GC). Measures the process's CPU time while the room sits in each state. + */ + @Test + fun idleStatesDoNotBurnCpu() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("cpu-$it") } + val host = Participant(http, url, speedError = 1.0, realQueue = true) + val guest = Participant(http, url, speedError = 1.01, realQueue = true) + val os = java.lang.management.ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean + suspend fun cpuDuring(label: String, block: suspend () -> Unit) { + val before = os.processCpuTime + val start = System.nanoTime() + block() + val share = (os.processCpuTime - before).toDouble() / (System.nanoTime() - start) + println("Jam simulation: CPU ${"%.0f".format(share * 100)}% of a core while $label") + assertTrue(share < 0.5, "CPU ${"%.0f".format(share * 100)}% of a core while $label") + } + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(5_000) + cpuDuring("playing in sync") { delay(8_000) } + host.player.pause() + delay(2_000) + cpuDuring("the room is paused") { delay(8_000) } + host.player.play() + delay(3_000) + host.player.stall() + delay(1_000) + cpuDuring("the host is buffering") { delay(8_000) } + host.player.unstall() + delay(3_000) + guest.player.stall() + cpuDuring("the guest is buffering") { delay(8_000) } + guest.player.unstall() + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * Several minutes of jam (songs ending, recordings switching, seeks) must not grow the + * heap. Opt-in, since it takes minutes: set `JAM_SOAK=1`. + */ + @Test + fun longSessionDoesNotLeak() = runBlocking { + val url = serverUrl ?: return@runBlocking + if (System.getenv("JAM_SOAK") == null) return@runBlocking + val http = HttpClientFactory() + val tracks = List(8) { track("soak-$it", durationMs = SONG_MS) } + val host = Participant(http, url, speedError = 1.0, realQueue = true) + val guest = Participant(http, url, speedError = 1.01, realQueue = true) + tracks.forEach { + host.sources.seed(it, source("host-${it.id}"), PLUGIN) + guest.sources.seed(it, source("own-${it.id}"), PLUGIN) + } + val runtime = Runtime.getRuntime() + fun usedMb(): Long { + repeat(3) { System.gc(); Thread.sleep(200) } + return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) + } + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(20_000) + val baseline = usedMb() + val samples = mutableListOf(baseline) + repeat(6) { round -> + if (round % 2 == 0) host.player.seekTo(10_000.milliseconds) else guest.player.seekTo(20_000.milliseconds) + delay(30_000) + samples += usedMb() + } + println("Jam simulation: heap after GC over ${samples.size} samples (MB): $samples") + assertTrue(samples.last() - baseline < 30, "heap grew from $baseline MB to ${samples.last()} MB") + assertInSync(host, guest, "after a long session") + } finally { + guest.close() + host.close() + http.close() + } + } + + /** Losing the network (guest, then host) must not crash, and the room picks up again. */ + @Test + fun roomSurvivesNetworkLoss() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val hostNet = TcpProxy(url) + val guestNet = TcpProxy(url) + val tracks = List(3) { track("track-$it") } + val host = Participant(http, hostNet.url, speedError = 1.0) + val guest = Participant(http, guestNet.url, speedError = 1.01) + val crashes = CopyOnWriteArrayList() + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, e -> crashes += e } + + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(1_000) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(5_000) + assertInSync(host, guest, "before the guest lost its network") + + guestNet.goOffline() + delay(1_000) + assertEquals(false, guest.service.isConnected.value, "guest noticed the drop") + assertEquals(code, guest.service.roomCode.value, "guest stays in the room while offline") + // Meanwhile the room moves on without the guest. + host.player.seekTo(40_000.milliseconds) + delay(4_000) + assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing offline") + + guestNet.goOnline() + delay(10_000) // Up to one backoff step, then resync. + assertEquals(true, guest.service.isConnected.value, "guest reconnected") + assertEquals(JamRole.Guest, guest.service.role.value) + assertInSync(host, guest, "after the guest reconnected") + + // A host that drops for less than the grace period keeps the room. + hostNet.goOffline() + delay(5_000) + assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing while the host is away") + hostNet.goOnline() + delay(10_000) + assertEquals(true, host.service.isConnected.value, "host reconnected") + assertEquals(JamRole.Host, host.service.role.value, "host kept the room") + host.player.seekTo(100_000.milliseconds) + delay(5_000) + assertInSync(host, guest, "after the host reconnected and seeked") + + assertTrue(crashes.isEmpty(), "uncaught exceptions: $crashes") + } finally { + Thread.setDefaultUncaughtExceptionHandler(previousHandler) + guest.close() + host.close() + hostNet.close() + guestNet.close() + http.close() + } + } + + private fun assertInSync(host: Participant, guest: Participant, phase: String) { + assertEquals(host.player.index.value, guest.player.index.value, "same track $phase") + val gap = guest.player.songPosition() - host.player.songPosition() + println("Jam simulation: guest is ${gap}ms off $phase") + assertTrue(abs(gap) < MAX_GAP_MS, "guest is ${gap}ms off $phase") + } + + private class Participant( + http: HttpClientFactory, + serverUrl: String, + speedError: Double, + pluginId: String = PLUGIN, + /** The app's own queue (stateIn flows and all) instead of [SimQueue]. */ + realQueue: Boolean = false) { + val sources = FakeTrackSourceRepository() + val player = SimPlayer(speedError) + val queue: AudioPlayerQueue = if (realQueue) { + player.resolveSource = { i -> + val id = player.playlistFlow.value.getOrNull(i)?.url?.substringAfterLast('/') + id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id" + } + DeviceAudioPlayerQueue( + player, FakeSettingsProvider(), FakeAudioPlayerQueueRepository(), + FakePluginProvider(), FakeBlacklistRepository()) + } else { + SimQueue(player, sources) + } + private val plugins = FakeAudioPluginSource(FakeAudioPluginService(pluginId)) + val service = JamRoomService( + jamClient = JamRoomClient(http), + audioPlayer = player, + audioPlayerQueue = queue, + settingsRepository = FakeSettingsStore(UserSettings(jamServerUrl = serverUrl)), + audioPluginSource = plugins, + trackSources = sources, + streamingUrlRepository = StreamingUrlRepository( + plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()), + evictCachedAudio = {}) + + suspend fun close() { + service.leaveRoom() + player.dispose() + } + } + + private class FakeSettingsStore(initial: UserSettings) : UserSettingsStore { + override val userSettings = MutableStateFlow(initial) + override suspend fun updateSettings(newSettings: UserSettings) { + userSettings.value = newSettings + } + } + + /** + * Plays a list of tracks in real time. Its clock runs [speedError] times true speed (as + * a drifting device would), positions are reported every 250 ms rounded to 100 ms, and + * every seek or track change buffers briefly. + * + * Like ExoPlayer, it picks a song's recording (source) when it loads the song: the next + * song is loaded halfway through the current one, and jumping to the song already + * playing keeps its recording. Recordings differ in intro length ([introMs]). + */ + private class SimPlayer(private val speedError: Double) : AudioPlayerInterface { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var durations: List = emptyList() + /** The source the queue would resolve for a song now. */ + var resolveSource: (Int) -> String = { "default" } + private val loadedSources = mutableMapOf() + val currentSource: String? get() = loadedSources[index.value] + private var basePosition = 0L + private var baseAt = now() + private var speed = 1f + private var playWhenReady = false + + val index = MutableStateFlow(-1) + private val _state = MutableStateFlow(PlayerState.IDLE) + private val _position = MutableStateFlow(Duration.ZERO) + private val _duration = MutableStateFlow(Duration.ZERO) + private val _speed = MutableStateFlow(1f) + private val _playlist = MutableStateFlow>(emptyList()) + private val _currentItem = MutableStateFlow(null) + private val _completion = MutableSharedFlow(extraBufferCapacity = 4) + + init { + scope.launch { + while (isActive) { + delay(250) + val position = truePosition() + val duration = currentDuration() ?: continue + val next = index.value + 1 + if (position >= duration / 2 && next <= durations.lastIndex && next !in loadedSources) { + loadedSources[next] = resolveSource(next) + } + if (position >= duration) { + _completion.tryEmit(Unit) + if (index.value < durations.lastIndex) advanceGapless() + continue + } + _position.value = (position / 100 * 100).milliseconds + } + } + } + + /** Where in the song (not the recording) playback is. */ + fun songPosition(): Long = truePosition() - introMs(currentSource) + + private fun currentDuration(): Long? = + durations.getOrNull(index.value)?.let { it + introMs(currentSource) } + + @Synchronized + fun truePosition(): Long = + if (_state.value == PlayerState.PLAYING) { + basePosition + ((now() - baseAt) * speed * speedError).toLong() + } else { + basePosition + } + + @Synchronized + private fun rebase(position: Long = truePosition()) { + basePosition = position + baseAt = now() + } + + fun loadTracks(trackDurations: List, start: Int, autoPlay: Boolean) { + durations = trackDurations + playWhenReady = autoPlay + loadedSources.clear() + startTrack(start) + } + + /** + * A song ending on its own, like ExoPlayer's gapless transition: no buffering, and + * the position keeps the old song's value until the next report. + */ + private fun advanceGapless() { + val next = index.value + 1 + loadedSources.getOrPut(next) { resolveSource(next) } + index.value = next + _currentItem.value = _playlist.value.getOrNull(next) + _duration.value = currentDuration()!!.milliseconds + rebase(0) + } + + fun startTrack(i: Int) { + // Jumping to the song already playing seeks within it (ExoPlayer's + // seekToDefaultPosition): the loaded recording stays. + if (i != index.value || i !in loadedSources) loadedSources[i] = loadedSources[i] ?: resolveSource(i) + index.value = i + _currentItem.value = _playlist.value.getOrNull(i) + _duration.value = currentDuration()!!.milliseconds + rebase(0) + _position.value = Duration.ZERO + buffer() + } + + private fun buffer() { + rebase() + _state.value = PlayerState.BUFFERING + scope.launch { + delay(BUFFER_MS) + rebase() + _state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + } + } + + /** Network stall: buffering until [unstall]. */ + fun stall() { + rebase() + _state.value = PlayerState.BUFFERING + } + + fun unstall() { + rebase() + _state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + } + + override suspend fun play() { + playWhenReady = true + rebase() + if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PLAYING + } + + override suspend fun pause() { + playWhenReady = false + rebase() + if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PAUSED + } + + override suspend fun seekTo(position: Duration) { + rebase(position.inWholeMilliseconds) + _position.value = position + buffer() + } + + override suspend fun setPlaybackSpeed(speed: Float) { + rebase() + this.speed = speed + _speed.value = speed + } + + override suspend fun skipToNext() = startTrack((index.value + 1).coerceAtMost(durations.lastIndex)) + override suspend fun skipToPrevious() = startTrack((index.value - 1).coerceAtLeast(0)) + override suspend fun jumpTo(index: Int) = startTrack(index) + + override val playerStateFlow: StateFlow = _state + override val positionFlow: StateFlow = _position + override val durationFlow: StateFlow = _duration + override val playbackSpeedFlow: StateFlow = _speed + override val completionFlow: Flow = _completion + override val shuffleModeFlow: StateFlow = MutableStateFlow(false) + override val currentMediaItemFlow: StateFlow = _currentItem + override val playlistFlow: StateFlow> = _playlist + override val bufferingPositionFlow: StateFlow = MutableStateFlow(Duration.ZERO) + override val loopStateFlow: StateFlow = MutableStateFlow(LoopState.NONE) + override val volumeFlow: StateFlow = MutableStateFlow(1f) + override val errorFlow: Flow = MutableSharedFlow() + + override suspend fun stop() = pause() + override suspend fun loop(state: LoopState) = Unit + override suspend fun shuffle(enabled: Boolean) = Unit + override suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { + _playlist.value = playlist + loadTracks(playlist.map { it.duration.inWholeMilliseconds }, startPosition, autoPlay) + } + + override suspend fun addMediaItem(mediaItem: MediaItem) { + _playlist.value += mediaItem + durations = durations + mediaItem.duration.inWholeMilliseconds + } + override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) = Unit + override suspend fun removeMediaItem(mediaItem: MediaItem) = Unit + override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) = Unit + override suspend fun setVolume(volume: Float) = Unit + override fun isDisposed(): Boolean = !scope.isActive + override fun dispose() = scope.cancel() + } + + private class SimQueue(private val player: SimPlayer, sources: FakeTrackSourceRepository) : AudioPlayerQueue { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val entries = MutableStateFlow>(emptyList()) + + init { + player.resolveSource = { i -> + val id = (entries.value.getOrNull(i) as? QueueEntry.StreamingTrack)?.track?.id + id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id" + } + } + + override val queueFlow: StateFlow> = entries + override val currentQueueEntryFlow: StateFlow = + combine(entries, player.index) { list, i -> list.getOrNull(i) } + .stateIn(scope, SharingStarted.Eagerly, null) + override val currentCollectionEntryFlow: StateFlow = MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + override suspend fun load( + entries: List, + autoPlay: Boolean, + startPosition: Int, + collectionEntry: QueueCollectionEntry?) { + this.entries.value = entries + player.loadTracks(entries.map { (it as QueueEntry.StreamingTrack).track.durationMs }, startPosition, autoPlay) + } + + override suspend fun jumpTo(index: Int, autoPlay: Boolean) { + if (autoPlay) player.play() else player.pause() + player.startTrack(index) + } + + override suspend fun reloadCurrent() = jumpTo(player.index.value, autoPlay = true) + override suspend fun addToQueue(entry: QueueEntry) = addAllToQueue(listOf(entry), null) + override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) { + this.entries.value += entries + } + + override suspend fun addAllAfterCurrent(entries: List) = addAllToQueue(entries, null) + override suspend fun removeFromQueue(entry: QueueEntry) = Unit + override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = Unit + override suspend fun move(fromIndex: Int, toIndex: Int) = Unit + override suspend fun clear() = Unit + override suspend fun getQueue(): List = entries.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = currentQueueEntryFlow.value + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() + } + + companion object { + private const val PLUGIN = "test-audio-plugin" + private const val BUFFER_MS = 200L + private const val MAX_GAP_MS = 200L + private const val STEADY_WINDOW_MS = 40_000L + private const val SONG_MS = 30_000L + + /** How much longer a recording's intro is than the original's ("own" uploads cut 2.5 s). */ + private fun introMs(source: String?): Long = if (source?.startsWith("own-") == true) -2_500 else 0 + + private fun now() = Clock.System.now().toEpochMilliseconds() + + private fun track(id: String, durationMs: Long = 180_000) = MetadataTrack( + id = id, title = id, durationMs = durationMs, trackNumber = null, discNumber = null, + artists = emptyList(), album = null, thumbnails = null, explicit = null, + popularity = null, isrcCode = null, externalUri = null) + + private fun source(id: String) = AudioSource.Basic( + id = id, title = id, artist = null, album = null, thumbnails = emptyList(), + externalUri = null, confidence = 1f) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/TcpProxy.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/TcpProxy.kt new file mode 100644 index 00000000..9eb8e2db --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/TcpProxy.kt @@ -0,0 +1,96 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.jam + +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URI +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.concurrent.thread + +/** + * A TCP proxy in front of the room server that a test can cut, like a phone losing its + * network: [goOffline] resets every open connection and refuses new ones until [goOnline]. + */ +class TcpProxy(target: String) : AutoCloseable { + private val targetHost: String + private val targetPort: Int + private val server = ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress("127.0.0.1", 0)) + } + private val sockets = CopyOnWriteArrayList() + + @Volatile + private var online = true + + init { + val uri = URI(target) + targetHost = uri.host + targetPort = if (uri.port > 0) uri.port else 80 + thread(isDaemon = true, name = "tcp-proxy") { + while (!server.isClosed) { + val client = runCatching { server.accept() }.getOrNull() ?: break + if (!online) { + reset(client) + continue + } + val upstream = runCatching { Socket(targetHost, targetPort) }.getOrElse { + reset(client) + continue + } + sockets += client + sockets += upstream + pipe(client, upstream) + pipe(upstream, client) + } + } + } + + val url: String get() = "http://127.0.0.1:${server.localPort}" + + fun goOffline() { + online = false + sockets.forEach(::reset) + sockets.clear() + } + + fun goOnline() { + online = true + } + + override fun close() { + goOffline() + server.close() + } + + private fun pipe(from: Socket, to: Socket) = thread(isDaemon = true, name = "tcp-proxy-pipe") { + runCatching { from.getInputStream().copyTo(to.getOutputStream()) } + reset(from) + reset(to) + } + + /** Closes with a TCP reset, as an abrupt network loss looks to the other side. */ + private fun reset(socket: Socket) { + runCatching { + socket.setSoLinger(true, 0) + socket.close() + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt new file mode 100644 index 00000000..79e3a4c2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/ServerBinderTest.kt @@ -0,0 +1,72 @@ +/* + * 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 . + */ + + +package org.spotube.spotube.core.server + +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import java.net.ServerSocket +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ServerBinderTest { + private val host = "127.0.0.1" + + private fun health(port: Int) = + URI("http://$host:$port/health").toURL().readText() + + @Test + fun usesPreferredPortWhenFree() = runBlocking { + val free = ServerSocket(0, 1, InetAddress.getByName(host)).use { it.localPort } + val bound = startServerWithPortFallback(host, free) { + routing { get("/health") { call.respondText("ok") } } + } + try { + assertEquals(free, bound.port) + assertTrue(!bound.usedFallback) + assertEquals("ok", health(bound.port)) + } finally { + bound.engine.stop(0, 500) + } + } + + @Test + fun fallsBackToAnotherPortWhenPreferredIsTaken() = runBlocking { + ServerSocket(0, 1, InetAddress.getByName(host)).use { blocker -> + val taken = blocker.localPort + val failures = mutableListOf() + val bound = startServerWithPortFallback(host, taken, { port, _ -> failures += port }) { + routing { get("/health") { call.respondText("ok") } } + } + try { + assertNotEquals(taken, bound.port) + assertTrue(bound.usedFallback) + assertEquals(listOf(taken), failures) + assertEquals("ok", health(bound.port)) + } finally { + bound.engine.stop(0, 500) + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/StreamFileRangeTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/StreamFileRangeTest.kt new file mode 100644 index 00000000..2ed56a00 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/StreamFileRangeTest.kt @@ -0,0 +1,59 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.server + +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.readRemaining +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.io.readByteArray +import okio.FileSystem +import okio.Path.Companion.toOkioPath +import java.nio.file.Files +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class StreamFileRangeTest { + // Several chunks plus a partial one. + private val data = Random(42).nextBytes(64 * 1024 * 3 + 1234) + + private fun copy(start: Long, length: Long): ByteArray = runBlocking { + val file = Files.createTempFile("spotube-cache", ".bin").also { it.toFile().deleteOnExit() } + Files.write(file, data) + val channel = ByteChannel() + launch { + streamFileRange(FileSystem.SYSTEM, file.toOkioPath(), start, length, channel) + channel.flushAndClose() + } + channel.readRemaining().readByteArray() + } + + @Test + fun wholeFile() = assertContentEquals(data, copy(0, data.size.toLong())) + + @Test + fun rangeAcrossChunks() { + val start = 70_000L + val length = 120_000L + assertContentEquals(data.copyOfRange(start.toInt(), (start + length).toInt()), copy(start, length)) + } + + @Test + fun lengthPastEndStopsAtEof() = assertContentEquals(data.copyOfRange(100, data.size), copy(100, data.size.toLong())) +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/component/TrackListVirtualizationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/component/TrackListVirtualizationTest.kt new file mode 100644 index 00000000..5e37f66f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/component/TrackListVirtualizationTest.kt @@ -0,0 +1,101 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.use +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** #3200: big track lists must only compose the rows on screen. */ +@OptIn(ExperimentalComposeUiApi::class) +class TrackListVirtualizationTest { + private fun track(index: Int) = MetadataTrack( + // Every 100th id repeats: playlists can hold the same track twice. + id = if (index % 100 == 0) "dup" else "t$index", + title = "Song $index", + durationMs = 200_000L, + trackNumber = 1, + discNumber = 1, + artists = listOf(MetadataArtist.Basic(id = "a", name = "Artist", thumbnails = emptyList(), externalUri = null)), + album = null, + thumbnails = emptyList(), + explicit = false, + popularity = 0, + isrcCode = null, + externalUri = null) + + private fun allNodes(scene: ImageComposeScene): List { + fun flatten(node: SemanticsNode): List = listOf(node) + node.children.flatMap(::flatten) + return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + } + + private fun songTitles(scene: ImageComposeScene): Set = allNodes(scene) + .flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() } + .map { it.text } + .filter { it.startsWith("Song ") } + .toSet() + + @Test + fun onlyVisibleRowsAreComposedAndTheEndLoadsMore() { + val tracks = (0 until 2000).map(::track) + val listState = LazyListState() + var scope: CoroutineScope? = null + var nextPageRequests = 0 + + ImageComposeScene(1000, 800) { + scope = rememberCoroutineScope() + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + TrackList( + tracks = tracks, + hasMore = true, + state = listState, + onLoadNextPage = { nextPageRequests++ }) + } + }.use { scene -> + repeat(3) { scene.render().close() } + + val visible = songTitles(scene) + assertTrue("Song 1" in visible, "first rows render: $visible") + assertTrue(visible.size in 3..60, "expected only on-screen rows, composed ${visible.size}") + assertEquals(0, nextPageRequests) + + scope!!.launch { listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) } + repeat(3) { scene.render().close() } + + val atEnd = songTitles(scene) + assertTrue("Song 1999" in atEnd, "last rows render: ${atEnd.sorted().takeLast(5)}") + assertTrue("Song 1" !in atEnd) + assertEquals(1, nextPageRequests) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeysTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeysTest.kt new file mode 100644 index 00000000..be156dde --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeysTest.kt @@ -0,0 +1,40 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.misc + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UniqueLazyKeysTest { + @Test + fun blankAndRepeatedIdsGetDistinctKeys() { + val ids = listOf("a", "", "b", "a", "", "a") + val keys = uniqueLazyKeys(ids) { it } + + assertEquals(ids.size, keys.toSet().size) + // First occurrences keep their id so scroll position stays stable. + assertEquals("a", keys[0]) + assertEquals("b", keys[2]) + } + + @Test + fun suffixesCannotCollideWithRealIds() { + val ids = listOf("a", "a", "a#1", "a1", "a_1") + assertEquals(ids.size, uniqueLazyKeys(ids) { it }.toSet().size) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt new file mode 100644 index 00000000..6a8ded1d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/window/WindowStateRepositoryTest.kt @@ -0,0 +1,85 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.window + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WindowStateRepositoryTest { + private fun repository(): WindowStateRepository { + val dir = Files.createTempDirectory("window-state-test").toFile() + val file = File(dir, "window.preferences_pb") + return WindowStateRepository( + PreferenceDataStoreFactory.createWithPath(produceFile = { file.absolutePath.toPath() })) + } + + @Test + fun missingValueLoadsNull() = runBlocking { + assertNull(repository().load()) + } + + @Test + fun roundTripsNormalState() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1200.dp, 750.dp), WindowPlacement.Floating)) + + val loaded = store.load()!! + assertEquals(DpSize(1200.dp, 750.dp), loaded.size) + assertEquals(WindowPlacement.Floating, loaded.placement) + } + + @Test + fun roundTripsMaximizedStateKeepingNormalSize() = runBlocking { + val store = repository() + store.save(PersistedWindowState.of(DpSize(1100.dp, 700.dp), WindowPlacement.Maximized)) + + val loaded = store.load()!! + assertEquals(WindowPlacement.Maximized, loaded.placement) + assertEquals(DpSize(1100.dp, 700.dp), loaded.size) + } + + @Test + fun oversizedSavedSizeIsClampedToScreen() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(1800.dp, 1000.dp), clamped) + } + + @Test + fun tinySavedSizeIsRaisedToMinimum() { + val clamped = DpSize(100.dp, 100.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = 1800.dp, maxHeight = 1000.dp) + assertEquals(DpSize(800.dp, 500.dp), clamped) + } + + @Test + fun unknownScreenOnlyEnforcesMinimum() { + val clamped = DpSize(3000.dp, 2000.dp) + .coerceForScreen(DpSize(800.dp, 500.dp), maxWidth = null, maxHeight = null) + assertEquals(DpSize(3000.dp, 2000.dp), clamped) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt new file mode 100644 index 00000000..823248e9 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt @@ -0,0 +1,192 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.jam + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.shell.AppShellViewModel +import org.spotube.spotube.modules.shell.FloatingPlayerContent +import org.spotube.spotube.modules.shell.FloatingPlayerBottomBleed +import org.spotube.spotube.modules.shell.PlayerUiState +import org.spotube.spotube.modules.shell.AppBottombar +import org.spotube.spotube.modules.shell.CompactPlayerOverlay +import org.spotube.millennium.composables.MillenniumTopAppBar +import org.jetbrains.compose.resources.stringResource +import com.composeunstyled.Text +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_screen_title +import org.spotube.millennium.theme.MillenniumTheme +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class JamLayoutTest { + @Test + fun measuredDockHeightIncludesSystemInsetExactlyOnce() { + val viewModel = AppShellViewModel() + viewModel.onBottomBarMeasured(76.dp) + assertEquals( + 140.dp, + viewModel.bottomOverlayInset(false, 0.dp, viewModel.compactChromeUiState.value) + ) + viewModel.onBottomBarMeasured(100.dp) + assertEquals( + 164.dp, + viewModel.bottomOverlayInset(false, 24.dp, viewModel.compactChromeUiState.value) + ) + viewModel.onBottomBarMeasured(0.dp) + assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight) + } + + /** Actual Compose controls rendered without native-window capture or a room server connection. */ + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderRoomControlsAtNarrowAndWideSizes() { + val output = File("build/reports/jam-ui").apply { mkdirs() } + val states = mapOf( + "create" to JamUiState( + serverConfigured = true, + serverHost = "jam.example.com", + canCreate = true + ), + "join" to JamUiState( + serverConfigured = true, + serverHost = "jam.example.com", + setupMode = JamSetupMode.Join + ), + "host" to JamUiState( + isActive = true, + isConnected = true, + isHost = true, + roomCode = "RC5FJ8", + serverConfigured = true, + serverHost = "jam.example.com", + participants = listOf( + JamParticipantUi("host", "Host-8316", "H", true, false), + JamParticipantUi("guest", "Guest-6684", "G", false, true) + ) + ), + "guest" to JamUiState( + isActive = true, + isConnected = true, + roomCode = "RC5FJ8", + serverConfigured = true, + serverHost = "jam.example.com", + participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false)) + ) + ) + for (width in listOf(360, 920)) { + for ((name, state) in states) { + var playerBottom = Float.NaN + var navigationTop = Float.NaN + var navigationBottom = Float.NaN + ImageComposeScene(width, 760) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Column( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + MillenniumTopAppBar(title = { Text(stringResource(Res.string.jam_screen_title)) }) + val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES) + val navigator = remember(navigation) { Navigator(navigation) } + Box(Modifier.weight(1f).fillMaxWidth()) { + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigation, + viewModel = remember { AppShellViewModel() }, + onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {}, + floatingPlayer = { modifier -> + FloatingPlayerContent( + PlayerUiState(), + modifier = modifier.onGloballyPositioned { + playerBottom = it.boundsInRoot().bottom + }, + onPlayPause = {}, + heartButton = {}) + }, + bottomBar = { modifier -> + AppBottombar( + navigator, + navigation, + modifier = modifier.onGloballyPositioned { + navigationTop = it.boundsInRoot().top + navigationBottom = it.boundsInRoot().bottom + }, + libraryBadge = {}) + } + ) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + Modifier.widthIn(max = 520.dp).fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (state.isActive) SessionPanel( + state, + {}, + {}, + {}, + {}, + {}, + {}) + else SetupPanel(state, {}, {}, {}) + ServerStatus(state, {}) + } + } + } + } + } + } + }.use { scene -> + repeat(8) { scene.render().close() } + assertTrue(playerBottom.isFinite()) + assertEquals( + playerBottom - FloatingPlayerBottomBleed.value, + navigationTop, + "Only the player's bottom surface bleed should overlap navigation" + ) + assertEquals( + 760f, + navigationBottom, + "Navigation must reach the window's bottom edge" + ) + scene.render().use { image -> + val data = assertNotNull(image.encodeToData()) + data.use { File(output, "$name-$width.png").writeBytes(it.bytes) } + } + } + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt new file mode 100644 index 00000000..4f397642 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/LibraryToolbarTest.kt @@ -0,0 +1,230 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.spotube.millennium.composables.MillenniumTopAppBar +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryToolbarTest { + private lateinit var library: LibraryState + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + library = LibraryState() + } + + @AfterTest + fun cleanup() { + library.viewModelScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun closingSearchClearsTheActiveFilter() = runTest { + library.openSearch() + library.uiState.value.searchState.setTextAndPlaceCursorAtEnd("Muse") + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(library.uiState.value.searchMode) + assertEquals("Muse", library.queryFlow(LibraryTab.Playlists).first()) + + library.closeSearch() + Snapshot.sendApplyNotifications() + runCurrent() + assertFalse(library.uiState.value.searchMode) + assertEquals("", library.uiState.value.searchState.text.toString()) + } + + @Test + fun eachTabRetainsItsOwnFilterAndActions() = runTest { + library.setQuery(LibraryTab.Playlists, "Rock") + library.onTabSelected(LibraryTab.Albums) + library.setQuery(LibraryTab.Albums, "Jazz") + Snapshot.sendApplyNotifications() + runCurrent() + assertEquals("Jazz", library.uiState.value.searchState.text.toString()) + assertFalse(library.uiState.value.canCreatePlaylist) + + library.onTabSelected(LibraryTab.Playlists) + runCurrent() + assertEquals("Rock", library.uiState.value.searchState.text.toString()) + assertTrue(library.uiState.value.canCreatePlaylist) + library.showCreatePlaylist() + runCurrent() + assertTrue(library.uiState.value.showCreatePlaylist) + + library.onTabSelected(LibraryTab.LocalTracks) + runCurrent() + assertTrue(library.uiState.value.canRefresh) + assertFalse(library.uiState.value.showCreatePlaylist) + library.onTabSelected(LibraryTab.Downloads) + runCurrent() + assertFalse(library.uiState.value.canFilter) + assertFalse(library.uiState.value.searchMode) + } + + @Test + fun anInlineDesktopQueryRemainsVisibleWhenTheLayoutBecomesCompact() = runTest { + library.setQuery(LibraryTab.Artists, "Radiohead") + library.onTabSelected(LibraryTab.Artists) + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(library.uiState.value.searchMode) + assertEquals("Radiohead", library.uiState.value.searchState.text.toString()) + } + + @Test + fun viewModeDefaultsAndOverridesArePerTab() = runTest { + assertEquals(LibraryViewMode.List, defaultLibraryViewMode(true)) + assertEquals(LibraryViewMode.Grid, defaultLibraryViewMode(false)) + library.toggleViewMode(isCompact = true) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Albums) + runCurrent() + assertNull(library.uiState.value.viewModeOverride) + library.toggleViewMode(isCompact = false) + runCurrent() + assertEquals(LibraryViewMode.List, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Playlists) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.LocalTracks) + library.toggleViewMode(isCompact = true) + runCurrent() + assertFalse(library.uiState.value.canChangeView) + assertNull(library.uiState.value.viewModeOverride) + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderExpandedCompactAndSearchToolbars() { + val output = File("build/reports/library-toolbar").apply { mkdirs() } + for (tab in listOf(LibraryTab.Playlists, LibraryTab.Albums, LibraryTab.LocalTracks)) { + for ((mode, width) in listOf( + "desktop" to 575, + "mobile" to 360, + "search" to 360, + "compact-desktop" to 392, + "compact-desktop-search" to 392 + )) { + val expandedDesktop = mode == "desktop" + val searching = mode.contains("search") + val state = LibraryToolbarUiState( + currentTab = tab, + searchMode = searching, + searchState = TextFieldState(if (searching) "Muse" else ""), + canCreatePlaylist = tab == LibraryTab.Playlists, + canRefresh = tab == LibraryTab.LocalTracks, + canChangeView = tab != LibraryTab.LocalTracks + ) + ImageComposeScene(width, 88) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + MillenniumTopAppBar( + title = { + LibraryToolbarTitle( + state, + expandedDesktop, + mode.startsWith("compact-desktop") + ) + }, + actions = { + LibraryToolbarActions( + state, + expandedDesktop, + {}, + {}, + {}, + {}, + compactDesktop = mode.startsWith("compact-desktop") + ) + if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer( + Modifier.width(132.dp) + ) + } + ) + } + }.use { scene -> + repeat(8) { scene.render().close() } + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + + val nodes = + scene.semanticsOwners.flatMap { flatten(it.unmergedRootSemanticsNode) } + val descriptions = nodes.flatMap { + it.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty() + } + val textFields = + nodes.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null } + assertEquals(if (expandedDesktop || searching) 1 else 0, textFields.size) + if (searching) { + assertTrue("Close filter" in descriptions) + assertFalse("Create playlist" in descriptions) + assertFalse("Refresh local tracks" in descriptions) + assertEquals( + "Muse", + textFields.single().config[SemanticsProperties.EditableText].text + ) + } else { + assertEquals(tab == LibraryTab.Playlists, "Create playlist" in descriptions) + assertEquals( + tab == LibraryTab.LocalTracks, + "Refresh local tracks" in descriptions + ) + } + textFields.forEach { node -> + assertTrue( + node.boundsInRoot.left >= 0f && node.boundsInRoot.right <= width, + "Filter must fit beside desktop window controls" + ) + } + scene.render().use { image -> + assertNotNull(image.encodeToData()).use { + File( + output, + "${tab.name}-$mode.png" + ).writeBytes(it.bytes) + } + } + } + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt new file mode 100644 index 00000000..05d2aa04 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt @@ -0,0 +1,415 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import org.spotube.spotube.core.ui.component.cards.AvatarCard +import org.spotube.spotube.core.ui.component.cards.PlayableCard +import org.spotube.spotube.core.ui.component.cards.PlayableListTile +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.downloads.DownloadItem +import org.spotube.spotube.modules.downloads.DownloadRowAction +import org.spotube.spotube.modules.downloads.DownloadStatus +import org.spotube.spotube.modules.downloads.DownloadsContent +import org.spotube.spotube.modules.downloads.DownloadsToolbarTitle +import org.spotube.spotube.modules.downloads.DownloadsToolbarSummary +import org.spotube.spotube.modules.downloads.DownloadsToolbarActions +import org.spotube.spotube.modules.downloads.downloadsUiState +import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksState +import org.spotube.spotube.modules.library.local_tracks.LocalTracksContent +import org.spotube.spotube.modules.library.local_tracks.LocalTracksBreadcrumb +import org.spotube.spotube.modules.library.local_tracks.localTracksUiState +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import java.io.File +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.composables.MillenniumTopAppBar +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.media_artist +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalComposeUiApi::class) +class MediaLibraryUiTest { + private val tracks = listOf( + LocalMediaTrack("/music/one.mp3", "Nishongota", listOf("Aurora"), 185_000, "Clancy"), + LocalMediaTrack( + "/music/two.mp3", + "Amay Prashna Kare Neel Dhrubatara", + listOf("Hemanta Mukherjee"), + 195_000 + ), + LocalMediaTrack("/downloads/three.mp3", "Whatever It Takes", emptyList(), 0) + ) + private val folders = listOf( + LocalMediaFolder("/music", "Music", tracks.take(2)), + LocalMediaFolder("/downloads", "Downloads", tracks.takeLast(1)) + ) + private val artists = listOf("Aurora", "Hemanta Mukherjee", "Imagine Dragons") + private val downloadItems = listOf( + DownloadItem("1", tracks[0].name, "Aurora", null, DownloadStatus.Downloading, 0.48f), + DownloadItem("2", tracks[1].name, "Hemanta Mukherjee", null, DownloadStatus.Completed), + DownloadItem( + "3", + tracks[2].name, + "Imagine Dragons", + null, + DownloadStatus.Failed("Connection lost") + ), + DownloadItem("4", "Clancy", "Twenty One Pilots", null, DownloadStatus.Queued) + ) + + @Test + fun downloadRowsHaveReadyProgressAndCorrectActions() { + val state = downloadsUiState(downloadItems) + assertEquals(2, state.activeCount) + assertEquals(1, state.completedCount) + assertTrue(state.canClearCompleted) + assertEquals(48, state.items[0].percent) + assertEquals(DownloadRowAction.Cancel, state.items[0].action) + assertEquals(DownloadRowAction.Remove, state.items[1].action) + assertEquals(DownloadRowAction.Retry, state.items[2].action) + assertEquals("Connection lost", state.items[2].error) + assertEquals( + 0f, + downloadsUiState(listOf(downloadItems[0].copy(progress = Float.NaN))).items.single().progress + ) + } + + @Test + fun localMediaFilteringAndMetadataAreComputedBeforeRendering() { + val base = LibraryLocalTracksState(folders = folders) + val root = localTracksUiState(base, "aurora") + assertEquals(listOf("Music"), root.visibleFolders.map { it.name }) + assertEquals(3, root.totalTrackCount) + val inFolder = localTracksUiState(base.copy(currentFolderPath = "/music"), "clancy") + assertEquals(listOf(tracks[0].name), inFolder.visibleTracks.map { it.title }) + assertEquals("3:05", inFolder.visibleTracks.single().durationLabel) + assertEquals("Aurora • Clancy", inFolder.visibleTracks.single().subtitle) + assertTrue(inFolder.canPlayFolder) + assertFalse( + localTracksUiState( + base.copy( + currentFolderPath = "/music", + canAccessMedia = false + ), "" + ).canPlayFolder + ) + } + + @Test + fun expandedDownloadsHasOneHeaderAndOneClearAction() { + var cleared = 0 + val state = downloadsUiState(downloadItems) + ImageComposeScene(960, 660) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state) { cleared++ } } + ) + DownloadsContent(state, {}, {}, {}, {}, Modifier.weight(1f), showHeader = false) + } + } + }.use { scene -> + repeat(6) { scene.render().close() } + val nodes = allNodes(scene) + val texts = nodes.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() } + .map { it.text } + assertEquals(1, texts.count { it == "Downloads" }) + assertEquals(1, texts.count { it == "Clear completed" }) + val action = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Clear completed" } == true + } + action.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, cleared) + } + } + + @Test + fun breadcrumbIsCompactAndItsParentLinkNavigatesUp() { + var height = 0 + var navigatedUp = 0 + val state = localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ) + ImageComposeScene(360, 48) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + LocalTracksBreadcrumb( + state, + { navigatedUp++ }, + {}, + Modifier.onGloballyPositioned { height = it.size.height }) + } + }.use { scene -> + repeat(6) { scene.render().close() } + assertEquals(40, height) + val parent = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Back to folders") == true + } + parent.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, navigatedUp) + } + } + + @Test + fun renderScreensAndCollectionModesInLightAndDark() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 960)) { + render(output, "downloads-$theme-$width", width, theme) { + val state = downloadsUiState(downloadItems) + if (width >= 840) Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, {}) }) + DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.weight(1f).padding(horizontal = 12.dp), + showHeader = false + ) + } else DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "folders-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState(folders = folders), + "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "tracks-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "collection-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = tracks, + itemKey = { it.path }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { item, list -> + PlayableCard( + item.name, + item.artists.joinToString(), + onClick = {}, + onPlay = {}, + onAddToQueue = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "artists-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = artists, + itemKey = { it }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { artist, list -> + AvatarCard( + artist, + stringResource(Res.string.media_artist), + onClick = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + } + } + } + + @Test + fun bothCardTypesRespondToHoverAndClick() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (avatar in listOf(false, true)) { + var clicks = 0 + ImageComposeScene(192, 260) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + .padding(16.dp) + ) { + if (avatar) AvatarCard( + tracks[0].artists.first(), + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + else PlayableCard( + tracks[0].name, + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + } + } + }.use { scene -> + repeat(4) { scene.render().close() } + val before = bytes(scene, 0) + scene.sendPointerEvent(PointerEventType.Enter, Offset(80f, 80f), timeMillis = 0) + scene.sendPointerEvent(PointerEventType.Move, Offset(80f, 80f), timeMillis = 1) + repeat(12) { scene.render((it + 1) * 16_000_000L).close() } + val hovered = bytes(scene, 250_000_000) + assertFalse( + before.contentEquals(hovered), + "Hover should visibly change the ${if (avatar) "avatar" else "playable"} card" + ) + File(output, "card-${if (avatar) "avatar" else "playable"}-hover.png").writeBytes( + hovered + ) + scene.sendPointerEvent( + PointerEventType.Press, + Offset(80f, 80f), + timeMillis = 260, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.render(300_000_000).close() + scene.sendPointerEvent( + PointerEventType.Release, Offset(80f, 80f), timeMillis = 310, + buttons = PointerButtons(), button = PointerButton.Primary + ) + scene.render(350_000_000).close() + assertEquals(1, clicks) + } + } + } + + @Test + fun listPlaybackActionsDoNotTriggerNavigation() { + var navigations = 0 + var plays = 0 + ImageComposeScene(360, 100) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + PlayableListTile(tracks[0].name, onClick = { navigations++ }, onPlay = { plays++ }) + } + }.use { scene -> + repeat(4) { scene.render().close() } + val play = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Play") == true + } + val center = play.boundsInRoot.center + scene.sendPointerEvent( + PointerEventType.Press, + center, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.sendPointerEvent( + PointerEventType.Release, + center, + buttons = PointerButtons(), + button = PointerButton.Primary + ) + scene.render().close() + assertEquals(1, plays) + assertEquals(0, navigations) + } + } + + private fun render( + output: File, + name: String, + width: Int, + theme: Theme, + content: @Composable () -> Unit + ) { + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { content() } + } + }.use { scene -> + repeat(6) { scene.render().close() } + if (name.startsWith("downloads")) { + val texts = allNodes(scene).flatMap { + it.config.getOrNull(SemanticsProperties.Text).orEmpty() + }.map { it.text } + assertTrue("Downloading 48%" in texts) + } + File(output, "$name.png").writeBytes(bytes(scene, 0)) + } + } + + private fun bytes(scene: ImageComposeScene, time: Long): ByteArray = + scene.render(time).use { image -> + assertNotNull(image.encodeToData()).use { it.bytes } + } + + private fun allNodes(scene: ImageComposeScene): List { + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/local_tracks/media/CoverThumbnailTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/local_tracks/media/CoverThumbnailTest.kt new file mode 100644 index 00000000..da9365f2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/local_tracks/media/CoverThumbnailTest.kt @@ -0,0 +1,56 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library.local_tracks.media + +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Rect +import org.jetbrains.skia.Surface +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CoverThumbnailTest { + private fun png(width: Int, height: Int): ByteArray = Surface.makeRasterN32Premul(width, height).use { surface -> + // Noise-free but non-uniform so the PNG isn't trivially small. + for (x in 0 until width step 7) { + surface.canvas.drawRect(Rect.makeXYWH(x.toFloat(), 0f, 3f, height.toFloat()), Paint().apply { color = (0xFF000000.toInt() or (x * 2654435761L).toInt()) }) + } + surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.PNG)!!.bytes } + } + + @Test + fun largeCoverIsShrunkToASmallJpeg() { + val original = png(1600, 1200) + val thumbnail = assertNotNull(coverThumbnail(original)) + + Image.makeFromEncoded(thumbnail).use { image -> + assertEquals(160, minOf(image.width, image.height)) + assertEquals(213, image.width) + } + assertTrue(thumbnail.size < 32 * 1024, "thumbnail is ${thumbnail.size} bytes") + } + + @Test + fun unreadableCoverIsDropped() { + assertNull(coverThumbnail(byteArrayOf(1, 2, 3, 4))) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt new file mode 100644 index 00000000..1d34b589 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/settings/LocalMediaFoldersEditorTest.kt @@ -0,0 +1,217 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.use +import androidx.lifecycle.viewModelScope +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_local_media_folders_title +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LocalMediaFoldersEditorTest { + private val models = mutableListOf() + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun cleanup() { + models.forEach { it.viewModelScope.cancel() } + models.clear() + Dispatchers.resetMain() + } + + private fun model( + source: MutableStateFlow, + persist: suspend (List) -> Unit = {} + ) = + LocalMediaFoldersViewModel(source, persist).also { models.add(it) } + + @Test + fun addingFoldersDeduplicatesPathsAndCancelDiscardsTheDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/user/Music"))) + var writes = 0 + val editor = model(source) { writes++ } + editor.open() + editor.addFolder(" /home/user/Music/// ") + editor.addFolder(" ") + assertFalse(editor.uiState.value.canSave) + editor.addFolder("/home/user/Documents/Music Production/") + runCurrent() + assertEquals( + listOf("Music", "Music Production"), + editor.uiState.value.folders.map { it.name }) + assertTrue(editor.uiState.value.canSave) + editor.dismiss() + editor.addFolder("/late-picker-result") + editor.open() + runCurrent() + assertEquals(listOf("/home/user/Music"), editor.uiState.value.folders.map { it.path }) + assertEquals(0, writes) + } + + @Test + fun settingsUpdatesDoNotResetAnOpenDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val editor = model(source) + editor.open() + editor.addFolder("/new-music") + source.value = source.value.copy(overloadedDownloadFolder = "/downloads") + runCurrent() + assertEquals(listOf("/music", "/new-music"), editor.uiState.value.folders.map { it.path }) + assertTrue(editor.uiState.value.canSave) + } + + @Test + fun saveLocksTheDraftUntilPersistenceCompletes() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val gate = CompletableDeferred() + var writes = 0 + val editor = model(source) { paths -> + writes++ + gate.await() + source.value = source.value.copy(localMediaFolders = paths) + } + editor.open() + editor.addFolder("/new-music") + editor.save() + editor.save() + editor.addFolder("/not-allowed-during-save") + editor.dismiss() + runCurrent() + assertTrue(editor.uiState.value.isSaving) + assertTrue(editor.uiState.value.isOpen) + assertFalse(editor.uiState.value.canSave) + assertEquals(2, editor.uiState.value.folders.size) + gate.complete(Unit) + runCurrent() + assertFalse(editor.uiState.value.isOpen) + assertEquals(listOf("/music", "/new-music"), source.value.localMediaFolders) + assertEquals(1, writes) + } + + @Test + fun failedSaveKeepsChangesForRetry() = runTest { + val source = MutableStateFlow(UserSettings()) + val editor = model(source) { throw IllegalStateException("Test storage failure") } + editor.open() + editor.addFolder("/music") + editor.save() + runCurrent() + assertTrue(editor.uiState.value.isOpen) + assertTrue(editor.uiState.value.saveFailed) + assertTrue(editor.uiState.value.canSave) + assertEquals(listOf("/music"), editor.uiState.value.folders.map { it.path }) + } + + @Test + fun rootPathsStayValid() { + assertEquals("/", normalizeMediaFolderPath(" / ")) + assertEquals("C:\\", normalizeMediaFolderPath("C:\\")) + assertEquals("C:/", normalizeMediaFolderPath("C:///")) + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderDialogAtCompactAndWideSizes() { + val output = File("build/reports/local-media-folders").apply { mkdirs() } + val source = + MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/krtirtho/Documents/Music Production"))) + val editor = model(source) + editor.open() + editor.addFolder("/home/krtirtho/Music") + val oneFolder = editor.uiState.value.copy(folders = editor.uiState.value.folders.take(1)) + val cases = mapOf( + "empty" to oneFolder.copy(folders = emptyList()), + "one" to oneFolder, + "many" to oneFolder.copy(folders = (1..8).map { + LocalMediaFolderRowUi( + "/home/user/Music/Collection-$it", + "Collection-$it" + ) + }) + ) + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 640)) { + for ((name, state) in cases) { + var added = 0 + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { + MillenniumDialog( + visible = true, + onDismissRequest = {}, + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent(state, { added++ }, {}, {}, {}) + } + } + } + }.use { scene -> + repeat(24) { scene.render(it * 16_000_000L).close() } + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + + val nodes = scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + val add = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Add folder" } == true + } + add.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, added) + assertTrue( + add.boundsInRoot.right <= width, + "Actions must fit inside compact dialogs" + ) + scene.render(400_000_000).use { image -> + image.encodeToData()!!.use { + File( + output, + "$name-$theme-$width.png" + ).writeBytes(it.bytes) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt new file mode 100644 index 00000000..d11f6e89 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt @@ -0,0 +1,113 @@ +package org.spotube.spotube.modules.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalComposeUiApi::class) +class CompactPlayerOverlayDragTest { + @Test + fun draggingTheSheetUpShowsTheExpandedPlayer() { + var expandedComposed = false + var floatingCompositions = 0 + var floatingPlaced = false + val width = 400 + val height = 800 + ImageComposeScene(width, height) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES) + val navigator = remember(navigation) { Navigator(navigation) } + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigation, + viewModel = remember { AppShellViewModel() }, + onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {}, + floatingPlayer = { modifier -> + DisposableEffect(Unit) { + floatingCompositions++ + onDispose {} + } + Box( + modifier.fillMaxWidth().height(64.dp).background(Color.Gray) + .onGloballyPositioned { floatingPlaced = true }) + }, + expandedPlayer = { modifier, _ -> + DisposableEffect(Unit) { + expandedComposed = true + onDispose { expandedComposed = false } + } + Box(modifier.fillMaxSize().background(Color.Red)) + }, + bottomBar = { modifier -> + Box(modifier.fillMaxWidth().height(56.dp).background(Color.Blue)) + } + ) { Box(Modifier.fillMaxSize()) } + } + }.use { scene -> + var t = 0L + fun frame() { scene.render(t); t += 16_000_000L } + repeat(10) { frame() } + assertTrue(floatingPlaced, "floating player should show while collapsed") + assertFalse(expandedComposed, "expanded player should not be composed while collapsed") + + // grab the floating player (just above the bottom bar) and drag to the top + val startY = height - 120f + 20f + val start = Offset(width / 2f, startY) + scene.sendPointerEvent(PointerEventType.Press, start, type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(5) { frame() } + var y = startY + repeat(30) { + y -= (startY - 40f) / 30f + scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L) + frame() + } + scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(120) { frame() } + + assertTrue(expandedComposed, "expanded player should be composed after dragging up") + // out of layout while the expanded player covers it, but never re-composed + floatingPlaced = false + repeat(5) { frame() } + assertFalse(floatingPlaced, "floating player should be out of layout when fully expanded") + + // drag back down: the floating player must return without being composed again + val downStart = Offset(width / 2f, 60f) + scene.sendPointerEvent(PointerEventType.Press, downStart, type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(5) { frame() } + var dy = downStart.y + repeat(30) { + dy += (height - 160f) / 30f + scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L) + frame() + } + scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(120) { frame() } + assertTrue(floatingPlaced, "floating player should be back after collapsing") + assertTrue(floatingCompositions == 1, "floating player was composed $floatingCompositions times, expected 1") + } + } +} diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt similarity index 75% rename from composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt rename to composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt index 47639afc..9a812193 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/ApplicationMainBar.mobile.kt @@ -15,30 +15,31 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.TopAppBar +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBars +import org.spotube.millennium.composables.MillenniumTopAppBar +import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), - backButton: Boolean -) { - return TopAppBar( + backButton: Boolean, + transparent: Boolean) { + return MillenniumTopAppBar( title = title, subtitle = subtitle, actions = actions, + windowInsets = WindowInsets.statusBars, navigationIcon = { if (backButton) { ApplicationBackButton() } } ) -} \ No newline at end of file +} diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt similarity index 95% rename from composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.mobile.kt rename to composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt index 080a02ea..174696b7 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/VerticalScrollbar.mobile.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.spotube.core.ui.component +package org.spotube.spotube.core.ui.component import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable diff --git a/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt new file mode 100644 index 00000000..047248e3 --- /dev/null +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt @@ -0,0 +1,24 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.ui.theming + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable + +@Composable +actual fun isSystemDarkTheme(): Boolean = isSystemInDarkTheme() diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..0c944468 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..cc0dfc4e Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..aeac4559 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..aef4bb59 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..b293b0c7 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..5af74804 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..092f0ad8 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..bc1f2887 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..3c50965a Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..41ff3ec9 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..9214b754 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..b5a3aaf9 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..6230862c Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..e828dd07 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..52bad80d Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..2383e45a Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..6542cf1d Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..47b16fcb Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..9e132a08 Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..d67288fb Binary files /dev/null and b/composeApp/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/nightly/res/values/strings.xml b/composeApp/src/nightly/res/values/strings.xml new file mode 100644 index 00000000..18731555 --- /dev/null +++ b/composeApp/src/nightly/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + + Spotube Nightly + diff --git a/gradle.properties b/gradle.properties index b96a5a61..831e3793 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,12 +19,21 @@ kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M kotlin.mpp.stability.nowarn=true +kotlin.incremental=true +kotlin.native.jvmArgs=-Xmx6g #Gradle -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +# The Kotlin/Native release framework link (iOS) runs in the Gradle daemon and OOMs below ~6g +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g org.gradle.configuration-cache=true org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +# Preserve compatibility with this project's current Kotlin Multiplatform Android setup on AGP 9. +android.builtInKotlin=false +android.newDsl=false +android.sourceset.disallowProvider=false + +org.gradle.vfs.watch=true \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 1d5bfcc0..cef9c030 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -26,5 +26,4 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac6 toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect -toolchainVendor=ADOPTIUM -toolchainVersion=21 +toolchainVersion=21 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9b7268d..03e7dfd1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,77 +14,67 @@ # along with this program. If not, see . [versions] -agp = "8.13.2" android-compileSdk = "37" android-minSdk = "28" -android-targetSdk = "36" +android-targetSdk = "37" +agp = "9.1.0" androidx-activity = "1.13.0" -androidx-appcompat = "1.7.1" -androidx-core = "1.19.0" -androidx-espresso = "3.7.0" -androidx-lifecycle = "2.11.0" -androidx-testExt = "1.3.0" +androidx-lifecycle = "2.10.0" appdirs = "1.5.0" cache4k = "0.14.0" carApp = "1.7.0" composeHotReload = "1.2.0" -composeMultiplatform = "1.11.1" +composeMultiplatform = "1.12.2" coil = "3.5.0" -composePlaceholderMaterial3 = "1.0.12" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" haze = "2.0.0-alpha03" jna = "5.18.1" -junit = "4.13.2" kermit = "2.1.0" -kmpZip = "0.13.0" -kotlin = "2.3.21" +kmpZip = "1.0.0" +kotlin = "2.4.21" kotlinx-coroutines = "1.11.0" -kotlinx-io = "0.9.1" -material3 = "1.10.0-alpha05" kotlinx-serialization-json = "1.11.0" -materialKolor = "4.1.1" +millennium = "0.1.1" +multiplatformMarkdownRenderer = "0.45.0" murmurhash = "0.4.2" -newpipeextractor = "v0.26.2" +newpipeextractor = "v0.26.5" newpipeExtractorKmp = "1.3.0" -nucleusNucleusApplication = "2.4.6" +nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" -ziplineVersion = "1.27.0" +ziplineVersion = "1.28.0" ktor = "3.5.1" -kotlinStdlib = "2.4.10" -runner = "1.7.0" -core = "1.7.0" +dnssdkt = "1.1.0" filekit = "0.14.2" -compose-webview = "1.0.1" -composeNativeTray = "2.0.3" +compose-webview = "0.1.0-SNAPSHOT" +composeNativeTray = "2.1.6" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" -compose-multiplatform-adaptive = "1.3.0-beta02" -compose-multiplatform-lifecycle = "2.11.0" +compose-multiplatform-lifecycle = "2.10.0" feather-icons = "1.1.1" -material3-window-size = "1.9.0" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" -ksp = "2.3.5" -spotube-gradle = "0.1.1" +spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" -vlcjBundler = "0.1.0" +vlcjBundler = "0.2.1" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" -kotlinx-atomicfu = "0.33.0" -mokkery = "3.3.0" +mokkery = "3.5.1" +jmustache = "1.16" [libraries] +# buildSrc: renders the package manager manifests in packaging/ +jmustache = { module = "com.samskivert:jmustache", version.ref = "jmustache" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } -compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" } +dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" } @@ -95,19 +85,12 @@ kmp-zip = { module = "no.synth:kmp-zip", version.ref = "kmpZip" } kmp-zip-kotlinx = { module = "no.synth:kmp-zip-kotlinx", version.ref = "kmpZip" } kmp-zip-okio = { module = "no.synth:kmp-zip-okio", version.ref = "kmpZip" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -junit = { module = "junit:junit", version.ref = "junit" } -androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } -androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } -androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } -androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } -compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } @@ -117,13 +100,19 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } -material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } +ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +millennium = { module = "org.spotube.millennium:millennium", version.ref = "millennium" } +multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } +multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" } nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" } +nucleus-darkmode-detector = { module = "dev.nucleusframework:nucleus.darkmode-detector", version.ref = "nucleusNucleusApplication" } +nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" } nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } @@ -140,9 +129,7 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization- kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } -kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } -androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } -androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" } filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" } filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } @@ -153,17 +140,11 @@ koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } -jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } -jetbrains-material3-window-size = { module = "org.jetbrains.compose.material3:material3-window-size-class", version.ref = "material3-window-size" } -jetbrains-material3-adaptive-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3-window-size" } -jetbrains-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "compose-multiplatform-adaptive" } -jetbrains-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "compose-multiplatform-adaptive" } feather-icons = { module = "br.com.devsrsouza.compose.icons:feather", version.ref = "feather-icons" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } -kotlinx-io-okio = { module = "org.jetbrains.kotlinx:kotlinx-io-okio", version.ref = "kotlinx-io" } cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography" } cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } @@ -182,12 +163,10 @@ kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versio jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" } -mavenPublish = { id = "maven-publish" } -spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" } -vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" } -kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" } +spotubeGradle = { id = "org.spotube.gradle-plugin", version.ref = "spotube-gradle" } +vlcjBundler = { id = "org.spotube.vlcj-bundler", version.ref = "vlcjBundler" } uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } -neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" } \ No newline at end of file +neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" } diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 3cef6a96..5f1e25ca 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,7 +1,7 @@ TEAM_ID= PRODUCT_NAME=Spotube -PRODUCT_BUNDLE_IDENTIFIER=dev.krtirtho.spotube.Spotube$(TEAM_ID) +PRODUCT_BUNDLE_IDENTIFIER=org.spotube.spotube.Spotube$(TEAM_ID) CURRENT_PROJECT_VERSION=1 MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 4e8d485b..5a38d341 100644 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -15,7 +15,8 @@ ], "idiom" : "universal", "platform" : "ios", - "size" : "1024x1024" + "size" : "1024x1024", + "filename" : "app-icon-1024-dark.png" }, { "appearances" : [ @@ -26,7 +27,8 @@ ], "idiom" : "universal", "platform" : "ios", - "size" : "1024x1024" + "size" : "1024x1024", + "filename" : "app-icon-1024-tinted.png" } ], "info" : { diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png new file mode 100644 index 00000000..dbf53a46 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-dark.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png new file mode 100644 index 00000000..eb3721eb Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024-tinted.png differ diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png index 53fc536f..dbf53a46 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1d..5663e50d 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,23 @@ CADisableMinimumFrameDurationOnPhone + CFBundleURLTypes + + + CFBundleURLName + org.spotube.spotube + CFBundleURLSchemes + + spotube + + + + NSLocalNetworkUsageDescription + Required to discover local network devices + NSBonjourServices + + _http._tcp + _spotube-ctrl._tcp + diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 08b58d47..349b4ba1 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -14,12 +14,16 @@ // along with this program. If not, see . import SwiftUI +import ComposeApp @main struct iOSApp: App { var body: some Scene { WindowGroup { ContentView() + .onOpenURL { url in + ExternalUriHandler.shared.onNewUri(uri: url.absoluteString) + } } } } \ No newline at end of file diff --git a/js_plugin_example/build.gradle.kts b/js_plugin_example/build.gradle.kts index ac43b30d..6b88fb8a 100644 --- a/js_plugin_example/build.gradle.kts +++ b/js_plugin_example/build.gradle.kts @@ -47,7 +47,7 @@ kotlin { } zipline { - mainFunction.set("dev.krtirtho.js_plugin_example.main") + mainFunction.set("org.spotube.js_plugin_example.main") } plugins.withType { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/js.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/js.kt deleted file mode 100644 index 0b7235ee..00000000 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/js.kt +++ /dev/null @@ -1,84 +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 . - */ - -package dev.krtirtho.js_plugin_example - -import app.cash.zipline.Zipline -import dev.krtirtho.js_plugin_example.plugin_apis.audio.RealAudioAPI -import dev.krtirtho.js_plugin_example.plugin_apis.core.RealCoreAPI -import dev.krtirtho.js_plugin_example.plugin_apis.lyrics.RealLyricsAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataAlbumAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataArtistAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataBrowseAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataPlaylistAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataSearchAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataTrackAPI -import dev.krtirtho.js_plugin_example.plugin_apis.metadata.RealMetadataUserAPI -import dev.krtirtho.js_plugin_example.plugin_apis.scrobble.RealScrobbleAPI -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.PersistedStorageAPI -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -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_SERVICE_NAME -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.lyrics.LyricsAPI -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_SERVICE_NAME -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.browse.MetadataBrowseAPI -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_SERVICE_NAME -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.track.MetadataTrackAPI -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_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME -import kotlin.js.Date - -private val zipline by lazy { Zipline.get() } - -@OptIn(ExperimentalJsExport::class) -@JsExport -fun main() { - zipline.take(HttpClientAPI_SERVICE_NAME) - zipline.take(WebViewAPI_SERVICE_NAME) - zipline.take(PersistedStorageAPI_SERVICE_NAME) - console.log("Epoch Time: ${Date.now()}") - - zipline.bind(CoreAPI_SERVICE_NAME, RealCoreAPI()) - zipline.bind(AudioAPI_SERVICE_NAME, RealAudioAPI()) - zipline.bind(LyricsAPI_SERVICE_NAME, RealLyricsAPI()) - zipline.bind(ScrobbleAPI_SERVICE_NAME, RealScrobbleAPI()) - - zipline.bind(MetadataAlbumAPI_SERVICE_NAME, RealMetadataAlbumAPI()) - zipline.bind(MetadataArtistAPI_SERVICE_NAME, RealMetadataArtistAPI()) - zipline.bind(MetadataBrowseAPI_SERVICE_NAME, RealMetadataBrowseAPI()) - zipline.bind(MetadataPlaylistAPI_SERVICE_NAME, RealMetadataPlaylistAPI()) - zipline.bind(MetadataSearchAPI_SERVICE_NAME, RealMetadataSearchAPI()) - zipline.bind(MetadataTrackAPI_SERVICE_NAME, RealMetadataTrackAPI()) - zipline.bind(MetadataUserAPI_SERVICE_NAME, RealMetadataUserAPI()) -} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt new file mode 100644 index 00000000..4893cd32 --- /dev/null +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/js.kt @@ -0,0 +1,84 @@ +/* + * 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 . + */ + +package org.spotube.js_plugin_example + +import app.cash.zipline.Zipline +import org.spotube.js_plugin_example.plugin_apis.audio.RealAudioAPI +import org.spotube.js_plugin_example.plugin_apis.core.RealCoreAPI +import org.spotube.js_plugin_example.plugin_apis.lyrics.RealLyricsAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataAlbumAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataArtistAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataBrowseAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataPlaylistAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataSearchAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataTrackAPI +import org.spotube.js_plugin_example.plugin_apis.metadata.RealMetadataUserAPI +import org.spotube.js_plugin_example.plugin_apis.scrobble.RealScrobbleAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI +import org.spotube.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.host_apis.WebViewAPI +import org.spotube.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import kotlin.js.Date + +private val zipline by lazy { Zipline.get() } + +@OptIn(ExperimentalJsExport::class) +@JsExport +fun main() { + zipline.take(HttpClientAPI_SERVICE_NAME) + zipline.take(WebViewAPI_SERVICE_NAME) + zipline.take(PersistedStorageAPI_SERVICE_NAME) + console.log("Epoch Time: ${Date.now()}") + + zipline.bind(CoreAPI_SERVICE_NAME, RealCoreAPI()) + zipline.bind(AudioAPI_SERVICE_NAME, RealAudioAPI()) + zipline.bind(LyricsAPI_SERVICE_NAME, RealLyricsAPI()) + zipline.bind(ScrobbleAPI_SERVICE_NAME, RealScrobbleAPI()) + + zipline.bind(MetadataAlbumAPI_SERVICE_NAME, RealMetadataAlbumAPI()) + zipline.bind(MetadataArtistAPI_SERVICE_NAME, RealMetadataArtistAPI()) + zipline.bind(MetadataBrowseAPI_SERVICE_NAME, RealMetadataBrowseAPI()) + zipline.bind(MetadataPlaylistAPI_SERVICE_NAME, RealMetadataPlaylistAPI()) + zipline.bind(MetadataSearchAPI_SERVICE_NAME, RealMetadataSearchAPI()) + zipline.bind(MetadataTrackAPI_SERVICE_NAME, RealMetadataTrackAPI()) + zipline.bind(MetadataUserAPI_SERVICE_NAME, RealMetadataUserAPI()) +} \ No newline at end of file diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt similarity index 89% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt index 85dd4f1a..6f861550 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/audio/RealAudioAPI.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.audio +package org.spotube.js_plugin_example.plugin_apis.audio -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioAPI +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack val urls = listOf( "https://cdn.pixabay.com/audio/2024/02/28/audio_d1a9995fc3.mp3", diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/core/RealCoreAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt similarity index 88% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/core/RealCoreAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt index 7848733c..9a25de0a 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/core/RealCoreAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/core/RealCoreAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.core +package org.spotube.js_plugin_example.plugin_apis.core -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo +import org.spotube.plugin_interfaces.plugin_apis.core.CoreAPI +import org.spotube.plugin_interfaces.plugin_apis.core.PluginUpdateInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt similarity index 87% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt index 6756a84e..8d776b8f 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/lyrics/RealLyricsAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.lyrics +package org.spotube.js_plugin_example.plugin_apis.lyrics -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricType -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.SyncLyricsLine +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricType +import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import org.spotube.plugin_interfaces.plugin_apis.lyrics.SyncLyricsLine class RealLyricsAPI : LyricsAPI { override val supportedLyricTypes: List = listOf(LyricType.STATIC, LyricType.SYNCED) diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt similarity index 95% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt index 89d2170e..4d858495 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt @@ -15,20 +15,20 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser private data class ArtistEntity( val id: String, diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt similarity index 80% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt index a0796eb6..7a6c9f25 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataAlbumAPI.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack class RealMetadataAlbumAPI : MetadataAlbumAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt similarity index 76% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt index ed05a111..9e40e0e7 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt @@ -15,16 +15,16 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -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.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack class RealMetadataArtistAPI : MetadataArtistAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt similarity index 76% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt index b2872544..b2463688 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem +import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult class RealMetadataBrowseAPI : MetadataBrowseAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt similarity index 87% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt index d76dac0c..c45b307f 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataPlaylistAPI.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack class RealMetadataPlaylistAPI : MetadataPlaylistAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt similarity index 83% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt index e015435b..ca8985ee 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataSearchAPI.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType class RealMetadataSearchAPI : MetadataSearchAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt similarity index 81% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt index ce28cb01..3237601c 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataTrackAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI class RealMetadataTrackAPI : MetadataTrackAPI { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt similarity index 80% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt index 61828f38..dda9a334 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.metadata +package org.spotube.js_plugin_example.plugin_apis.metadata -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI class RealMetadataUserAPI : MetadataUserAPI { override suspend fun getUser(id: String): MetadataUser? { diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt similarity index 80% rename from js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt rename to js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt index 03aeec85..85b8d901 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/org/spotube/js_plugin_example/plugin_apis/scrobble/RealScrobbleAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.js_plugin_example.plugin_apis.scrobble +package org.spotube.js_plugin_example.plugin_apis.scrobble -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import org.spotube.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack class RealScrobbleAPI: ScrobbleAPI { override suspend fun scrobble(track: ScrobbleTrack) { diff --git a/metadata/en-US/images/featureGraphic.png b/metadata/en-US/images/featureGraphic.png new file mode 100644 index 00000000..42a5c41c Binary files /dev/null and b/metadata/en-US/images/featureGraphic.png differ diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png new file mode 100644 index 00000000..adbafe11 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/1.png differ diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png new file mode 100644 index 00000000..e4e91416 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/2.png differ diff --git a/metadata/en-US/images/phoneScreenshots/3.png b/metadata/en-US/images/phoneScreenshots/3.png new file mode 100644 index 00000000..b488d8c9 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/3.png differ diff --git a/metadata/en-US/images/phoneScreenshots/4.png b/metadata/en-US/images/phoneScreenshots/4.png new file mode 100644 index 00000000..32dc9f3a Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/4.png differ diff --git a/metadata/en-US/images/phoneScreenshots/5.png b/metadata/en-US/images/phoneScreenshots/5.png new file mode 100644 index 00000000..3ad3c81a Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/5.png differ diff --git a/metadata/en-US/images/phoneScreenshots/android-1.jpg b/metadata/en-US/images/phoneScreenshots/android-1.jpg deleted file mode 100644 index 16debbd7..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-1.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-2.jpg b/metadata/en-US/images/phoneScreenshots/android-2.jpg deleted file mode 100644 index d594227e..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-2.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-3.jpg b/metadata/en-US/images/phoneScreenshots/android-3.jpg deleted file mode 100644 index ed2d61f2..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-3.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-4.jpg b/metadata/en-US/images/phoneScreenshots/android-4.jpg deleted file mode 100644 index 36a3549f..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-4.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-5.jpg b/metadata/en-US/images/phoneScreenshots/android-5.jpg deleted file mode 100644 index 29be481b..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-5.jpg and /dev/null differ diff --git a/metadata/tr/images/featureGraphic.png b/metadata/tr/images/featureGraphic.png new file mode 100644 index 00000000..42a5c41c Binary files /dev/null and b/metadata/tr/images/featureGraphic.png differ diff --git a/metadata/tr/images/phoneScreenshots/1.png b/metadata/tr/images/phoneScreenshots/1.png new file mode 100644 index 00000000..adbafe11 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/1.png differ diff --git a/metadata/tr/images/phoneScreenshots/2.png b/metadata/tr/images/phoneScreenshots/2.png new file mode 100644 index 00000000..e4e91416 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/2.png differ diff --git a/metadata/tr/images/phoneScreenshots/3.png b/metadata/tr/images/phoneScreenshots/3.png new file mode 100644 index 00000000..b488d8c9 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/3.png differ diff --git a/metadata/tr/images/phoneScreenshots/4.png b/metadata/tr/images/phoneScreenshots/4.png new file mode 100644 index 00000000..32dc9f3a Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/4.png differ diff --git a/metadata/tr/images/phoneScreenshots/5.png b/metadata/tr/images/phoneScreenshots/5.png new file mode 100644 index 00000000..3ad3c81a Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/5.png differ diff --git a/metadata/tr/images/phoneScreenshots/android-1.jpg b/metadata/tr/images/phoneScreenshots/android-1.jpg deleted file mode 100644 index 16debbd7..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-1.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-2.jpg b/metadata/tr/images/phoneScreenshots/android-2.jpg deleted file mode 100644 index d594227e..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-2.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-3.jpg b/metadata/tr/images/phoneScreenshots/android-3.jpg deleted file mode 100644 index ed2d61f2..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-3.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-4.jpg b/metadata/tr/images/phoneScreenshots/android-4.jpg deleted file mode 100644 index 36a3549f..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-4.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-5.jpg b/metadata/tr/images/phoneScreenshots/android-5.jpg deleted file mode 100644 index 29be481b..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-5.jpg and /dev/null differ diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO deleted file mode 100644 index 74209eac..00000000 --- a/packaging/aur/.SRCINFO +++ /dev/null @@ -1,19 +0,0 @@ -pkgbase = spotube-bin -pkgdesc = Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! -pkgver = 4.0.0 -pkgrel = 1 -url = https://spotube.cc -arch = x86_64 -license = AGPL-3.0-or-later -depends = mpv -depends = libappindicator-gtk3 -depends = libsecret -depends = jsoncpp -depends = libnotify -depends = xdg-user-dirs -depends = webkit2gtk-4.1 -optdepends = yt-dlp-git -source = https://github.com/KRTirtho/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz -md5sums = 475b1ae9b08f27743a4d4749391ae3db - -pkgname = spotube-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD deleted file mode 100644 index 3fc7d677..00000000 --- a/packaging/aur/PKGBUILD +++ /dev/null @@ -1,40 +0,0 @@ -# Maintainer: Kingkor Roy Tirtho -pkgname=spotube-bin -pkgver=%{{SPOTUBE_VERSION}}% -pkgrel=%{{PKGREL}}% -epoch= -pkgdesc="Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" -arch=(x86_64) -url="https://spotube.cc" -license=('AGPL-3.0-or-later') -groups=() -depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') -makedepends=() -checkdepends=() -optdepends=('yt-dlp-git') -provides=() -conflicts=() -replaces=() -backup=() -options=() -install= -changelog= -source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") -noextract=() -md5sums=(%{{LINUX_MD5}}%) -validpgpkeys=() - -package() { - install -dm755 "${pkgdir}/usr/share/icons/spotube" - install -dm755 "${pkgdir}/usr/share/applications" - install -dm755 "${pkgdir}/usr/share/appdata" - install -dm755 "${pkgdir}/usr/share/${pkgname}" - install -dm755 "${pkgdir}/usr/bin" - - mv ./spotube.desktop "${pkgdir}/usr/share/applications" - mv ./spotube-logo.png "${pkgdir}/usr/share/icons/spotube/" - mv ./com.github.KRTirtho.Spotube.appdata.xml "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - cp -ra ./data ./lib ./spotube "${pkgdir}/usr/share/${pkgname}" - sed -i 's|com.github.KRTirtho.Spotube|spotube|' "${pkgdir}/usr/share/appdata/spotube.appdata.xml" - ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/spotube" -} diff --git a/packaging/aur/PKGBUILD.mustache b/packaging/aur/PKGBUILD.mustache new file mode 100644 index 00000000..5fa0a92a --- /dev/null +++ b/packaging/aur/PKGBUILD.mustache @@ -0,0 +1,32 @@ +# Maintainer: Kingkor Roy Tirtho +pkgname=spotube-bin +pkgver={{version}} +pkgrel={{pkgrel}} +pkgdesc="Open source, plugin powered music streaming client" +arch=(x86_64 aarch64) +url="https://spotube.cc" +license=('AGPL-3.0-or-later') +# libvlc + plugins for playback (FFmpeg demux/decode, GnuTLS for HTTPS streams, PulseAudio/PipeWire output), +# WebKitGTK 4.1 for the webview, and the X11/GL/ALSA libraries the AWT/Skiko natives link +depends=( + 'libvlc' 'vlc-plugins-base' 'vlc-plugin-ffmpeg' 'vlc-plugin-gnutls' 'vlc-plugin-pulse' + 'webkit2gtk-4.1' 'gtk3' 'glib2' 'dbus' 'alsa-lib' 'libglvnd' 'fontconfig' 'freetype2' + 'libx11' 'libxext' 'libxi' 'libxrender' 'libxtst' 'zlib' +) +provides=('spotube') +conflicts=('spotube') +options=('!strip' '!debug') +source_x86_64=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-x86_64.tar.xz") +source_aarch64=("https://github.com/team-spotube/spotube/releases/download/v${pkgver}/spotube-linux-${pkgver}-aarch64.tar.xz") +sha256sums_x86_64=('{{artifacts.linuxX86_64.sha256}}') +sha256sums_aarch64=('{{artifacts.linuxAarch64.sha256}}') + +package() { + cd "spotube-linux-${pkgver}-${CARCH}" + + install -dm755 "${pkgdir}/opt" "${pkgdir}/usr/bin" "${pkgdir}/usr/share" + cp -a app "${pkgdir}/opt/spotube" + cp -a share/. "${pkgdir}/usr/share/" + ln -s /opt/spotube/spotube "${pkgdir}/usr/bin/spotube" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/chocolatey/spotube.nuspec b/packaging/chocolatey/spotube.nuspec deleted file mode 100644 index 148e089e..00000000 --- a/packaging/chocolatey/spotube.nuspec +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - spotube - %{{SPOTUBE_VERSION}}% - https://github.com/KRTirtho/spotube/tree/master/choco-struct - Kingkor Roy Tirtho - - - - spotube (Install) - Kingkor Roy Tirtho - https://spotube.cc - - https://rawcdn.githack.com/KRTirtho/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/branding/spotube-logo.png - 2022 Spotube - - https://github.com/KRTirtho/spotube/blob/master/LICENSE - true - https://github.com/KRTirtho/spotube - https://spotube.cc - https://github.com/KRTirtho/spotube/issues/new - spotube music audio youtube flutter - 🎧 Open source music client that doesn't require Premium nor uses Electron! Available - for both desktop & mobile! - - Spotube is a Flutter based lightweight music client. It utilizes the power - of music metadata providers & Youtube's public API & creates a hazardless, performant - & resource - friendly User Experience - - # Features - - Open source/libre software - - Anonymous/guest login - - Cross platform support - - No telemetry, diagnostics or user data collection - - Lightweight & resource-friendly - - Native performance (Thanks to Flutter+Skia) - - Playback control is done locally instead of on the server - - Small size & less data usage - - No ads since it uses all public & free APIs (It is still recommended - to support the creators by watching/liking/subscribing to the artists' YouTube channels or - liking their tracks on different music platforms.) - - Time synced lyrics - - Downloadable tracks - - https://github.com/KRTirtho/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% - - - - - \ No newline at end of file diff --git a/packaging/chocolatey/spotube.nuspec.mustache b/packaging/chocolatey/spotube.nuspec.mustache new file mode 100644 index 00000000..77b2ba0c --- /dev/null +++ b/packaging/chocolatey/spotube.nuspec.mustache @@ -0,0 +1,48 @@ + + + + + + spotube + {{version}} + https://github.com/team-spotube/spotube/tree/master/packaging/chocolatey + Kingkor Roy Tirtho + + + + spotube (Install) + Team Spotube + https://spotube.cc + + https://rawcdn.githack.com/team-spotube/spotube/69a310c78f5ceaf4eab7dfee98f187d38211c9ba/assets/branding/spotube-logo.png + 2026 Kingkor Roy Tirtho and Spotube Contributors + + https://github.com/team-spotube/spotube/blob/master/LICENSE + true + https://github.com/team-spotube/spotube + https://spotube.cc + https://github.com/team-spotube/spotube/issues/new + spotube music audio music-player streaming lyrics plugins foss + Open source, plugin powered music streaming client + + A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. + + # Features + - Plugin powered: supports any platform or custom music service through plugins + - Community driven plugins for popular platforms, or create your own + - Freely downloadable tracks with tagged metadata + - Time synced lyrics regardless of plugin support + - No telemetry, diagnostics or user data collection + - Small size, low data usage and native performance + - Open source/libre software + + {{release.url}} + +{{release.notes}} + + + + + \ No newline at end of file diff --git a/packaging/chocolatey/tools/VERIFICATION.txt b/packaging/chocolatey/tools/VERIFICATION.txt.mustache similarity index 64% rename from packaging/chocolatey/tools/VERIFICATION.txt rename to packaging/chocolatey/tools/VERIFICATION.txt.mustache index 8b4f01af..4d1d6c40 100644 --- a/packaging/chocolatey/tools/VERIFICATION.txt +++ b/packaging/chocolatey/tools/VERIFICATION.txt.mustache @@ -5,19 +5,19 @@ in verifying that this package's contents are trustworthy. 1. The embedded files are also available as a release in GitHub. Please go to releases page -https://github.com/KRTirtho/spotube/releases +https://github.com/team-spotube/spotube/releases -Download same version as this choco package (example for v%{{SPOTUBE_VERSION}}%) -https://github.com/KRTirtho/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% +Download same version as this choco package (v{{version}}) +https://github.com/team-spotube/spotube/releases/tag/v{{version}} 1. get hashes. Run: powershell -command Get-FileHash tools\Spotube-windows-x86_64-setup.exe 2. The checksums should match the following: --- -Version Hashes for v%{{SPOTUBE_VERSION}}% +Version Hashes for v{{version}} Algorithm Hash Path --------- ---- ---- -SHA256 %{{WIN_SHA256}}% tools\Spotube-windows-x86_64-setup.exe \ No newline at end of file +SHA256 {{artifacts.windowsX64.sha256Upper}} tools\Spotube-windows-x86_64-setup.exe \ No newline at end of file diff --git a/packaging/chocolatey/tools/chocolateyinstall.ps1 b/packaging/chocolatey/tools/chocolateyinstall.ps1 index 8e84266e..f188e3cc 100644 --- a/packaging/chocolatey/tools/chocolateyinstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyinstall.ps1 @@ -5,13 +5,15 @@ $fileLocation = Join-Path $toolsDir 'Spotube-windows-x86_64-setup.exe' $packageArgs = @{ packageName = $env:ChocolateyPackageName - unzipLocation = $toolsDir - fileType = 'exe' #only one of these: exe, msi, msu - file = $fileLocation + fileType = 'exe' + file = $fileLocation - softwareName = 'spotube*' #part or all of the Display Name as you see it in Programs and Features. It should be enough to be unique - silentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' # Inno Setup + softwareName = 'Spotube*' + silentArgs = '/S' # NSIS (electron-builder one-click, per-user) validExitCodes= @(0) } -Install-ChocolateyInstallPackage @packageArgs \ No newline at end of file +Install-ChocolateyInstallPackage @packageArgs + +# The installer was only needed to install; don't keep a copy in the package directory +Remove-Item $fileLocation -Force -ErrorAction SilentlyContinue diff --git a/packaging/chocolatey/tools/chocolateyuninstall.ps1 b/packaging/chocolatey/tools/chocolateyuninstall.ps1 index 17b8e929..d1e9f55a 100644 --- a/packaging/chocolatey/tools/chocolateyuninstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyuninstall.ps1 @@ -1,17 +1,23 @@ $ErrorActionPreference = 'Stop'; # stop on all errors $packageArgs = @{ packageName = $env:ChocolateyPackageName - softwareName = 'spotube*' + softwareName = 'Spotube*' fileType = 'exe' - validExitCodes= @(0, 3010, 1605, 1614, 1641) - silentArgs = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' # Inno Setup + validExitCodes= @(0) } [array]$key = Get-UninstallRegistryKey -SoftwareName $packageArgs['softwareName'] if ($key.Count -eq 1) { $key | % { - $packageArgs['file'] = "$($_.UninstallString)" #NOTE: You may need to split this if it contains spaces, see below + # electron-builder writes UninstallString as "\Uninstall Spotube.exe" /currentuser + if ($_.UninstallString -match '^\s*"([^"]+)"\s*(.*)$') { + $packageArgs['file'] = $Matches[1] + $packageArgs['silentArgs'] = "$($Matches[2]) /S".Trim() + } else { + $packageArgs['file'] = $_.UninstallString + $packageArgs['silentArgs'] = '/S' + } Uninstall-ChocolateyPackage @packageArgs } @@ -22,4 +28,4 @@ if ($key.Count -eq 1) { Write-Warning "To prevent accidental data loss, no programs will be uninstalled." Write-Warning "Please alert package maintainer the following keys were matched:" $key | % {Write-Warning "- $($_.DisplayName)"} -} \ No newline at end of file +} diff --git a/packaging/flatpak/org.spotube.spotube.yml.mustache b/packaging/flatpak/org.spotube.spotube.yml.mustache new file mode 100644 index 00000000..a5544730 --- /dev/null +++ b/packaging/flatpak/org.spotube.spotube.yml.mustache @@ -0,0 +1,113 @@ +id: org.spotube.spotube +# GTK 3 and WebKitGTK 4.1 (the webview) come from the GNOME runtime +runtime: org.gnome.Platform +runtime-version: '51' +sdk: org.gnome.Sdk +command: spotube + +finish-args: + - --share=ipc + - --share=network + # The window is a Tao (Wayland/X11) one, so Wayland is used natively and X11 only as a fallback + - --socket=wayland + - --socket=fallback-x11 + - --socket=pulseaudio + - --device=dri + # Downloaded tracks go to ~/Downloads/Spotube; local tracks are scanned from ~/Music and ~/Downloads + - --filesystem=xdg-download + - --filesystem=xdg-music:ro + # Tray icon (StatusNotifierItem) + - --talk-name=org.kde.StatusNotifierWatcher + +cleanup: + - /include + - /lib/pkgconfig + - /share/man + - /share/doc + - '*.la' + - '*.a' + +modules: + # libvlc for vlcj (the audio player). Audio only: no video outputs, UI or Lua; decoding and demuxing + # go through the runtime's FFmpeg (proprietary codecs come from the auto-installed codecs-extra extension). + - name: vlc + build-options: + env: + # configure looks for a c99/c11 wrapper for its build-time tools + BUILDCC: gcc -std=gnu11 + config-opts: + - --disable-vlc + - --disable-qt + - --disable-skins2 + - --disable-ncurses + - --disable-lua + - --disable-a52 + - --disable-dbus + - --disable-xcb + - --disable-xvideo + - --disable-wayland + - --disable-vdpau + - --disable-libva + - --disable-gles2 + - --disable-sdl-image + - --disable-chromecast + - --disable-udev + - --disable-v4l2 + - --disable-dc1394 + - --disable-dv1394 + - --disable-linsys + - --disable-dvbpsi + - --disable-bluray + - --disable-dvdread + - --disable-dvdnav + - --disable-vcd + - --disable-libcddb + - --disable-screen + - --disable-addonmanagermodules + - --disable-update-check + cleanup: + - /share/applications + - /share/icons + - /share/kde4 + - /share/metainfo + - /share/solid + - /share/vlc + sources: + - type: archive + url: https://download.videolan.org/pub/videolan/vlc/3.0.24/vlc-3.0.24.tar.xz + sha256: e7cab503d1d7d5849b89d2cf0e1ee60d0ef6d012407791b644b9cfc0cc225fdf + x-checker-data: + type: anitya + project-id: 6504 + stable-only: true + versions: {<: '4.0'} + url-template: https://download.videolan.org/pub/videolan/vlc/$version/vlc-$version.tar.xz + + # The GraalVM native image, repackaged from the release tarball (packaging/package-linux-tarball.sh) + - name: spotube + buildsystem: simple + build-commands: + - cp -a app ${FLATPAK_DEST}/lib/spotube + - install -d ${FLATPAK_DEST}/bin + - ln -s ../lib/spotube/spotube ${FLATPAK_DEST}/bin/spotube + - cp -a share/. ${FLATPAK_DEST}/share/ + sources: + - type: archive + only-arches: [x86_64] + url: {{artifacts.linuxX86_64.url}} + sha256: {{artifacts.linuxX86_64.sha256}} + x-checker-data: + type: json + url: https://api.github.com/repos/team-spotube/spotube/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/team-spotube/spotube/releases/download/v" + $version + "/spotube-linux-" + $version + "-x86_64.tar.xz"' + is-main-source: true + - type: archive + only-arches: [aarch64] + url: {{artifacts.linuxAarch64.url}} + sha256: {{artifacts.linuxAarch64.sha256}} + x-checker-data: + type: json + url: https://api.github.com/repos/team-spotube/spotube/releases/latest + version-query: .tag_name | sub("^v"; "") + url-query: '"https://github.com/team-spotube/spotube/releases/download/v" + $version + "/spotube-linux-" + $version + "-aarch64.tar.xz"' diff --git a/packaging/linux/org.spotube.spotube.desktop b/packaging/linux/org.spotube.spotube.desktop new file mode 100644 index 00000000..6e56f0f6 --- /dev/null +++ b/packaging/linux/org.spotube.spotube.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Spotube +GenericName=Music Player +Comment=Open source, plugin powered music streaming client +Exec=spotube %U +Icon=org.spotube.spotube +Terminal=false +Categories=AudioVideo;Audio;Music;Player; +Keywords=music;streaming;player;audio;lyrics;playlist; +StartupNotify=true +StartupWMClass=org-spotube-spotube-MainKt diff --git a/packaging/linux/org.spotube.spotube.metainfo.xml.mustache b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache new file mode 100644 index 00000000..f8f510d5 --- /dev/null +++ b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache @@ -0,0 +1,119 @@ + + + org.spotube.spotube + + + com.github.KRTirtho.Spotube + + + com.github.KRTirtho.Spotube + + + Spotube + Plugin powered, open source music streaming client + + Team Spotube + + + CC0-1.0 + AGPL-3.0-or-later + + +

+ A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. +

+
    +
  • Plugin powered: supports any platform or custom music service through plugins
  • +
  • Community driven plugins for popular platforms, or create your own
  • +
  • Freely downloadable tracks with tagged metadata
  • +
  • Time synced lyrics regardless of plugin support
  • +
  • No telemetry, diagnostics or user data collection
  • +
  • Small size, low data usage and native performance
  • +
+
+ + org.spotube.spotube.desktop + + + AudioVideo + Audio + Music + + + music + streaming + player + lyrics + + + https://spotube.cc + https://github.com/team-spotube/spotube/issues + https://github.com/team-spotube/spotube + https://opencollective.com/spotube + https://github.com/team-spotube/spotube/blob/master/CONTRIBUTION.md + + + + Browse music from the service your plugin connects to + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/home-desktop-dark.png + + + Synced lyrics + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/lyrics-desktop-dark.png + + + Your playlists + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/playlists-desktop-dark.png + + + Search tracks, albums, artists, playlists and users + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/search-desktop-dark.png + + + Local folders and downloads + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/local-tracks-desktop-dark.png + + + Group Jam rooms + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/group-jam-desktop-dark.png + + + + + #84b2ff + #01185c + + + + + + 360 + + + pointing + keyboard + touch + + + + +{{#releases}} + + {{url}} +{{#notes}} + +{{#groups}} +

{{title}}

+
    +{{#entries}} +
  • {{text}}
  • +{{/entries}} +
+{{/groups}} +
+{{/notes}} +
+{{/releases}} +
+
diff --git a/packaging/package-linux-tarball.sh b/packaging/package-linux-tarball.sh new file mode 100755 index 00000000..9b2c4157 --- /dev/null +++ b/packaging/package-linux-tarball.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Packs the GraalVM native distributable into spotube-linux--.tar.xz, the +# portable tarball the AUR (spotube-bin) and Flathub (org.spotube.spotube) packages install from. +# +# Run after `:composeApp:createGraalvmNativeDistributable` WITHOUT -PappImage=true (the tarball uses the +# system's or the Flatpak runtime's VLC, GTK 3 and WebKitGTK, like the deb/rpm) and +# `:composeApp:renderPackagingLinux` (desktop entry + metainfo with the release notes). +# +# Usage: packaging/package-linux-tarball.sh [stable|nightly] [out-dir] +# +# Layout: +# spotube-linux--/ +# app/ binary + the JDK/Skiko libraries it loads +# share/applications/org.spotube.spotube.desktop +# share/metainfo/org.spotube.spotube.metainfo.xml +# share/icons/hicolor/512x512/apps/org.spotube.spotube.png +# LICENSE +set -euo pipefail + +version="${1:?usage: $0 [stable|nightly] [out-dir]}" +channel="${2:-stable}" +out_dir="${3:-.}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +app_dir="${SPOTUBE_APP_DIR:-$root/composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube}" +linux_dir="$root/composeApp/build/packaging/linux" + +case "$(uname -m)" in + x86_64 | amd64) arch=x86_64 ;; + aarch64 | arm64) arch=aarch64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +if [[ ! -x "$app_dir/spotube" ]]; then + echo "No native distributable at $app_dir; run :composeApp:createGraalvmNativeDistributable first" >&2 + exit 1 +fi +if [[ ! -f "$linux_dir/org.spotube.spotube.metainfo.xml" ]]; then + echo "No rendered metainfo in $linux_dir; run :composeApp:renderPackagingLinux first" >&2 + exit 1 +fi +# An AppImage build (-PappImage=true) leaves VLC plugins and a GTK/WebKit tree in the same dir +if [[ -d "$app_dir/plugins" || -d "$app_dir/usr" ]]; then + echo "$app_dir bundles VLC/GTK (an -PappImage=true build); rebuild without it" >&2 + exit 1 +fi + +name="spotube-linux-$version-$arch" +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +dest="$staging/$name" + +mkdir -p "$dest/share/applications" "$dest/share/metainfo" "$dest/share/icons/hicolor/512x512/apps" +cp -a "$app_dir" "$dest/app" +rm -f "$dest/app/.complete" +install -m644 "$linux_dir/org.spotube.spotube.desktop" "$dest/share/applications/" +install -m644 "$linux_dir/org.spotube.spotube.metainfo.xml" "$dest/share/metainfo/" +install -m644 "$root/composeApp/icons/$channel/icon.png" "$dest/share/icons/hicolor/512x512/apps/org.spotube.spotube.png" +install -m644 "$root/LICENSE" "$dest/LICENSE" + +mkdir -p "$out_dir" +tarball="$(cd "$out_dir" && pwd)/$name.tar.xz" +tar -C "$staging" --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@${SOURCE_DATE_EPOCH:-$(git -C "$root" log -1 --format=%ct)}" \ + -cf - "$name" | xz -T0 -9 > "$tarball" + +echo "$tarball" diff --git a/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache b/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache new file mode 100644 index 00000000..32490e24 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.installer.yaml.mustache @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +Platform: +- Windows.Desktop +MinimumOSVersion: 10.0.0.0 +# electron-builder one-click NSIS installer (Nucleus), installs per user; winget's nullsoft default /S is silent +InstallerType: nullsoft +Scope: user +InstallModes: +- silent +- silentWithProgress +UpgradeBehavior: install +ReleaseDate: {{release.date}} +Installers: +- Architecture: x64 + InstallerUrl: {{artifacts.windowsX64.url}} + InstallerSha256: {{artifacts.windowsX64.sha256Upper}} +ManifestType: installer +ManifestVersion: 1.10.0 diff --git a/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache new file mode 100644 index 00000000..7a9a168c --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.locale.en-US.yaml.mustache @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +PackageLocale: en-US +Publisher: Team Spotube +PublisherUrl: https://github.com/team-spotube +PublisherSupportUrl: https://github.com/team-spotube/spotube/issues +PrivacyUrl: https://spotube.cc/p/privacy +Author: Team Spotube +PackageName: Spotube +PackageUrl: https://spotube.cc +License: AGPL-3.0-or-later +LicenseUrl: https://github.com/team-spotube/spotube/blob/master/LICENSE +Copyright: Copyright (c) 2022-2026 Kingkor Roy Tirtho and Spotube Contributors +ShortDescription: Open source, plugin powered music streaming client +Description: |- + A cross-platform, extensible, open source music streaming platform. Bring your own music + metadata, playlists and audio sources with plugins made by the community or by yourself. +Moniker: spotube +Tags: +- audio +- music +- music-player +- streaming +- lyrics +- plugins +{{#release.notes}} +ReleaseNotes: |- +{{#indent2}}{{release.notes}}{{/indent2}} +{{/release.notes}} +ReleaseNotesUrl: {{release.url}} +ManifestType: defaultLocale +ManifestVersion: 1.10.0 diff --git a/packaging/winget/KRTirtho.Spotube.yaml.mustache b/packaging/winget/KRTirtho.Spotube.yaml.mustache new file mode 100644 index 00000000..1c92b1d4 --- /dev/null +++ b/packaging/winget/KRTirtho.Spotube.yaml.mustache @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json + +PackageIdentifier: KRTirtho.Spotube +PackageVersion: {{version}} +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.10.0 diff --git a/plugin_interfaces/build.gradle.kts b/plugin_interfaces/build.gradle.kts index b079c3eb..266b08b7 100644 --- a/plugin_interfaces/build.gradle.kts +++ b/plugin_interfaces/build.gradle.kts @@ -23,7 +23,7 @@ plugins { alias(libs.plugins.zipline.gradle.plugin) } -group = "dev.krtirtho.spotube" +group = "org.spotube.spotube" version = "0.1.0" kotlin { @@ -32,7 +32,7 @@ kotlin { // which platforms this KMP module supports. // See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets androidLibrary { - namespace = "dev.krtirtho.spotube.plugin_interfaces" + namespace = "org.spotube.spotube.plugin_interfaces" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() @@ -105,7 +105,7 @@ mavenPublishing { name = "plugin_interfaces" description = "Plugin interfaces for Spotube" inceptionYear = "2026" - url = "https://github.com/KRTirtho/spotube" + url = "https://github.com/team-spotube/spotube" licenses { license { name = "AGPL-3.0-or-later" @@ -115,15 +115,16 @@ mavenPublishing { } developers { developer { - id = "KRTirtho" - name = "Kingkor Roy Tirtho" - url = "https://github.com/KRTirtho/" + id = "team-spotube" + name = "Team Spotube" + url = "https://github.com/team-spotube/" + email = "team.spotube@proton.me" } } 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" + url = "https://github.com/team-spotube/spotube" + connection = "scm:git:git://github.com/team-spotube/spotube.git" + developerConnection = "scm:git:ssh://git@github.com/team-spotube/spotube.git" } } } \ No newline at end of file diff --git a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.android.kt b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.android.kt similarity index 94% rename from plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.android.kt rename to plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.android.kt index 6eae50bd..4af9ff8b 100644 --- a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.android.kt +++ b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core actual fun runPluginInitialized(block: suspend () -> Unit) { } \ No newline at end of file diff --git a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt similarity index 93% rename from plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt rename to plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt index fad98790..c6b5fc53 100644 --- a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt +++ b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { } \ No newline at end of file diff --git a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.android.kt b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.android.kt similarity index 96% rename from plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.android.kt rename to plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.android.kt index b69ed577..32d679ce 100644 --- a/plugin_interfaces/src/androidMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.android.kt +++ b/plugin_interfaces/src/androidMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.android.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) { diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.kt similarity index 89% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.kt index 7401d175..8c9ac44b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt similarity index 89% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt index 6c2aa09d..c539ce3e 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.kt @@ -15,6 +15,6 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal expect fun polyfill() \ No newline at end of file diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.kt similarity index 94% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.kt index 98fd3dda..d6a4b74c 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") expect class Logger(tag: String) { diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HeadersBuilder.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HeadersBuilder.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HeadersBuilder.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HeadersBuilder.kt index 7d3188a1..921831e4 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HeadersBuilder.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HeadersBuilder.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor class HeadersBuilder { private val headers: MutableMap> = mutableMapOf() diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt index 7c7df669..9bc56c7b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/HttpRequestBuilder.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor -import dev.krtirtho.plugin_interfaces.host_apis.HttpMethod +import org.spotube.plugin_interfaces.host_apis.HttpMethod import kotlinx.serialization.json.Json import kotlinx.serialization.serializer diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Interceptor.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Interceptor.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Interceptor.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Interceptor.kt index 57f8eed6..057d813d 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Interceptor.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Interceptor.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor interface Interceptor { suspend fun intercept(context: InterceptorContext): SpotrHttpResponse diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Serializers.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Serializers.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Serializers.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Serializers.kt index cb7bf06a..e0f5bbc7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/Serializers.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/Serializers.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClient.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClient.kt similarity index 97% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClient.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClient.kt index 8259f725..1f2d5887 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClient.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClient.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpMethod +import org.spotube.plugin_interfaces.host_apis.HttpClientAPI +import org.spotube.plugin_interfaces.host_apis.HttpMethod class SpotrClient( private val httpClientAPI: HttpClientAPI, diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClientConfig.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClientConfig.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClientConfig.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClientConfig.kt index 9c941d50..510bf2e5 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrClientConfig.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrClientConfig.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor class SpotrClientConfig( var defaultUrl: String = "", diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt similarity index 97% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt index 833d2b22..bae6ee3f 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/SpotrHttpResponse.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor import kotlinx.serialization.serializer diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/URLBuilder.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/URLBuilder.kt similarity index 97% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/URLBuilder.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/URLBuilder.kt index bf9aa3b7..23469d47 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/extras/spotor/URLBuilder.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/extras/spotor/URLBuilder.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.spotor +package org.spotube.plugin_interfaces.extras.spotor class URLBuilder { var protocol: String = "https" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/CryptoAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/CryptoAPI.kt similarity index 99% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/CryptoAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/CryptoAPI.kt index ea7c0e54..9e03efc6 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/CryptoAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/CryptoAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/HttpClientAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/HttpClientAPI.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/HttpClientAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/HttpClientAPI.kt index 1a44e7fa..0f893fd7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/HttpClientAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/HttpClientAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/PersistedStorageAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/PersistedStorageAPI.kt similarity index 95% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/PersistedStorageAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/PersistedStorageAPI.kt index eea2df55..8c8cd5ed 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/PersistedStorageAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/PersistedStorageAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/SystemInformationAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/SystemInformationAPI.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/SystemInformationAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/SystemInformationAPI.kt index ae4a38a5..c577752c 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/SystemInformationAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/SystemInformationAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/WebViewAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/WebViewAPI.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/WebViewAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/WebViewAPI.kt index 2207ce3c..588156da 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/host_apis/WebViewAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/host_apis/WebViewAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.host_apis +package org.spotube.plugin_interfaces.host_apis import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.Flow diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioAPI.kt similarity index 88% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioAPI.kt index cb3a5565..bd62a3d9 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.audio +package org.spotube.plugin_interfaces.plugin_apis.audio import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val AudioAPI_SERVICE_NAME = "AudioAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioModels.kt similarity index 96% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioModels.kt index 28f0eeb4..7b26c922 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/audio/AudioModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioModels.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.audio +package org.spotube.plugin_interfaces.plugin_apis.audio -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -28,6 +28,7 @@ data class AudioFormat( val qualities: List ) +@Serializable sealed interface AudioQuality { @Serializable @SerialName("lossy") diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPI.kt similarity index 95% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPI.kt index a2fd25b2..015b3377 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.core +package org.spotube.plugin_interfaces.plugin_apis.core import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.StateFlow diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt similarity index 94% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt index 71161f45..6209aa5a 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/core/CoreAPIModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.core +package org.spotube.plugin_interfaces.plugin_apis.core import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt similarity index 87% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt index 6dd2ce83..c23489e8 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsAPI.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.lyrics +package org.spotube.plugin_interfaces.plugin_apis.lyrics import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val LyricsAPI_SERVICE_NAME = "LyricsAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt similarity index 94% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt index 49f54189..28dd63ba 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/lyrics/LyricsModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.lyrics +package org.spotube.plugin_interfaces.plugin_apis.lyrics import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt similarity index 82% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt index fd573531..a46d574b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album +package org.spotube.plugin_interfaces.plugin_apis.metadata.album import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val MetadataAlbumAPI_SERVICE_NAME = "MetadataAlbumAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt similarity index 90% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt index d8e16737..876a28b8 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/album/MetadataAlbumModels.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album +package org.spotube.plugin_interfaces.plugin_apis.metadata.album -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt similarity index 79% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt index df5b383f..1e07cc4b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt @@ -15,14 +15,14 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist +package org.spotube.plugin_interfaces.plugin_apis.metadata.artist import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt similarity index 80% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt index 2e66061e..86b25928 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist +package org.spotube.plugin_interfaces.plugin_apis.metadata.artist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt similarity index 84% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt index 1110143e..83a68a6b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse +package org.spotube.plugin_interfaces.plugin_apis.metadata.browse import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt similarity index 78% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt index 940c15bc..4abb010b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse +package org.spotube.plugin_interfaces.plugin_apis.metadata.browse -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt similarity index 95% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt index 875e0886..de29829b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/common/CommonModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common +package org.spotube.plugin_interfaces.plugin_apis.metadata.common import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt similarity index 86% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt index bad85046..55f5400a 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistAPI.kt @@ -15,12 +15,12 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist +package org.spotube.plugin_interfaces.plugin_apis.metadata.playlist import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val MetadataPlaylistAPI_SERVICE_NAME = "MetadataPlaylistAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt similarity index 82% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt index 92a3cd6a..9af1c667 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/playlist/MetadataPlaylistModels.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist +package org.spotube.plugin_interfaces.plugin_apis.metadata.playlist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import kotlinx.serialization.Serializable @Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt similarity index 88% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt index 5062f727..286bc19b 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search +package org.spotube.plugin_interfaces.plugin_apis.metadata.search import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult const val MetadataSearchAPI_SERVICE_NAME = "MetadataSearchAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt similarity index 77% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt index c084341a..bf3ddbe2 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/search/MetadataSearchModels.kt @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search +package org.spotube.plugin_interfaces.plugin_apis.metadata.search -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt similarity index 85% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt index d51cf510..d30c78d3 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackAPI.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track +package org.spotube.plugin_interfaces.plugin_apis.metadata.track import app.cash.zipline.ZiplineService -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult const val MetadataTrackAPI_SERVICE_NAME = "MetadataTrackAPI" diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt similarity index 79% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt index 178e8ded..cc34d522 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/track/MetadataTrackModels.kt @@ -15,11 +15,11 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track +package org.spotube.plugin_interfaces.plugin_apis.metadata.track -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.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.Serializable @Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt similarity index 93% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt index dd4e947b..5a3f2180 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user +package org.spotube.plugin_interfaces.plugin_apis.metadata.user import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.StateFlow diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt similarity index 87% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt index 6d853510..85d96ba7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/metadata/user/MetadataUserModels.kt @@ -15,9 +15,9 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user +package org.spotube.plugin_interfaces.plugin_apis.metadata.user -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import kotlinx.serialization.Serializable @Serializable diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt similarity index 93% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt index e1dc6dae..951d3b95 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleAPI.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.scrobble +package org.spotube.plugin_interfaces.plugin_apis.scrobble import app.cash.zipline.ZiplineService diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt similarity index 88% rename from plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt rename to plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt index 202b398b..bd012db7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.plugin_apis.scrobble +package org.spotube.plugin_interfaces.plugin_apis.scrobble import kotlinx.serialization.Serializable @@ -28,6 +28,6 @@ data class ScrobbleTrack( val trackName: String, val artistName: String, val albumName: String?, - val streamingProvider: String, // e.g. "spotify", "apple_music" etc + val streamingProvider: String, // e.g. "greedy_green_corp", "apple_music" etc val durationMs: Long? = null, ) \ No newline at end of file diff --git a/plugin_interfaces/src/commonTest/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt b/plugin_interfaces/src/commonTest/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt new file mode 100644 index 00000000..ccaf9971 --- /dev/null +++ b/plugin_interfaces/src/commonTest/kotlin/org/spotube/plugin_interfaces/plugin_apis/audio/AudioQualitySerializationTest.kt @@ -0,0 +1,28 @@ +package org.spotube.plugin_interfaces.plugin_apis.audio + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class AudioQualitySerializationTest { + @Test + fun `audio format serializes sealed quality variants`() { + val formats = listOf( + AudioFormat( + codec = "opus", + container = "webm", + qualities = listOf(AudioQuality.Lossy(bitrate = 256_000)), + ), + AudioFormat( + codec = "flac", + container = "flac", + qualities = listOf(AudioQuality.Lossless(sampleRate = 48_000, channels = 2)), + ), + ) + + val encoded = Json.encodeToString(formats) + val decoded = Json.decodeFromString>(encoded) + + assertEquals(formats, decoded) + } +} diff --git a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.ios.kt b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.ios.kt similarity index 94% rename from plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.ios.kt rename to plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.ios.kt index 6eae50bd..4af9ff8b 100644 --- a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.ios.kt +++ b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core actual fun runPluginInitialized(block: suspend () -> Unit) { } \ No newline at end of file diff --git a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt similarity index 93% rename from plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt rename to plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt index fad98790..c6b5fc53 100644 --- a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt +++ b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { } \ No newline at end of file diff --git a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.ios.kt b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.ios.kt similarity index 96% rename from plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.ios.kt rename to plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.ios.kt index b69ed577..32d679ce 100644 --- a/plugin_interfaces/src/iosMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.ios.kt +++ b/plugin_interfaces/src/iosMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.ios.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) { diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interface/host_apis/.gitkeep b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interface/host_apis/.gitkeep similarity index 100% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interface/host_apis/.gitkeep rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interface/host_apis/.gitkeep diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.js.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.js.kt similarity index 91% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.js.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.js.kt index c8276361..eee9e634 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.js.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.js.kt @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core import app.cash.zipline.Zipline -import dev.krtirtho.plugin_interfaces.core.browser_apis.polyfill +import org.spotube.plugin_interfaces.core.browser_apis.polyfill private val zipline by lazy { Zipline.get() } diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt similarity index 93% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt index ba8b3596..98022b80 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.js.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { fastTextEncodingPolyfill() diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt similarity index 98% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt index 5684cd61..febf774c 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/FastTextEncodingPolyfill.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis fun fastTextEncodingPolyfill() { js( diff --git a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.js.kt b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.js.kt similarity index 96% rename from plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.js.kt rename to plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.js.kt index 59a852d0..c10b677d 100644 --- a/plugin_interfaces/src/jsMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.js.kt +++ b/plugin_interfaces/src/jsMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.js.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) { diff --git a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.jvm.kt b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.jvm.kt similarity index 94% rename from plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.jvm.kt rename to plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.jvm.kt index 6eae50bd..4af9ff8b 100644 --- a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/Initializer.jvm.kt +++ b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/Initializer.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core +package org.spotube.plugin_interfaces.core actual fun runPluginInitialized(block: suspend () -> Unit) { } \ No newline at end of file diff --git a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt similarity index 93% rename from plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt rename to plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt index fad98790..c6b5fc53 100644 --- a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt +++ b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/core/browser_apis/BrowserPolyfills.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.core.browser_apis +package org.spotube.plugin_interfaces.core.browser_apis internal actual fun polyfill() { } \ No newline at end of file diff --git a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.jvm.kt b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.jvm.kt similarity index 96% rename from plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.jvm.kt rename to plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.jvm.kt index b69ed577..32d679ce 100644 --- a/plugin_interfaces/src/jvmMain/kotlin/dev/krtirtho/plugin_interfaces/extras/logger/Logger.jvm.kt +++ b/plugin_interfaces/src/jvmMain/kotlin/org/spotube/plugin_interfaces/extras/logger/Logger.jvm.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.krtirtho.plugin_interfaces.extras.logger +package org.spotube.plugin_interfaces.extras.logger @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class Logger actual constructor(tag: String) {