Compare commits
143 Commits
ed3743bbea
...
9a47a1af5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a47a1af5f | ||
|
|
431fd2353f | ||
|
|
0bf0e1816c | ||
|
|
f23c66c0c5 | ||
|
|
8537718225 | ||
|
|
1b089a5fec | ||
|
|
0541fd9d23 | ||
|
|
ac5971846b | ||
|
|
aaa4d66db1 | ||
|
|
45a7758eb0 | ||
|
|
0a0b0c9374 | ||
|
|
b12e98a8c5 | ||
|
|
35700920b4 | ||
|
|
e04b3414e0 | ||
|
|
9f4ce11509 | ||
|
|
bba725a139 | ||
|
|
c058074cff | ||
|
|
2aaa0db81e | ||
|
|
14bc279653 | ||
|
|
2c1148a126 | ||
|
|
fa9b42cf10 | ||
|
|
8d4ea82a7e | ||
|
|
eb972c8633 | ||
|
|
aca3aeb592 | ||
|
|
5b36edd259 | ||
|
|
ea01dec6ea | ||
|
|
79266fd483 | ||
|
|
31a3233c79 | ||
|
|
91867f571d | ||
|
|
cde359a9de | ||
|
|
1b4fbe8d25 | ||
|
|
67e452493a | ||
|
|
d7c7e3108d | ||
|
|
1ea5f1d1ee | ||
|
|
9bb80ebb47 | ||
|
|
a7e47d0e00 | ||
|
|
bdf3655a23 | ||
|
|
cc21568457 | ||
|
|
7fa3b029f6 | ||
|
|
cb8316d141 | ||
|
|
99ba08501b | ||
|
|
0acc457890 | ||
|
|
7eb56c8593 | ||
|
|
ff7a1714ff | ||
|
|
09df4b494c | ||
|
|
2edbfefe93 | ||
|
|
8ab9463c9a | ||
|
|
79d47c1d87 | ||
|
|
08892c2aa0 | ||
|
|
8bbefdfb7d | ||
|
|
d4961bb5ed | ||
|
|
b38a4f6963 | ||
|
|
facc8ed0b9 | ||
|
|
0b21eff4c7 | ||
|
|
9d5854023b | ||
|
|
38e9bbc1d8 | ||
|
|
64fb23e62b | ||
|
|
64a78727ce | ||
|
|
046f724c9b | ||
|
|
e92e62613e | ||
|
|
7ee3c70c96 | ||
|
|
069b586d44 | ||
|
|
ced26462a9 | ||
|
|
d4d8ebd853 | ||
|
|
2f7eb742cf | ||
|
|
b333c299d1 | ||
|
|
613159e26e | ||
|
|
33a8fdb8c2 | ||
|
|
03aaffeca8 | ||
|
|
1233967982 | ||
|
|
17f2068950 | ||
|
|
75ced1285e | ||
|
|
c767b8dcd6 | ||
|
|
aa83708499 | ||
|
|
62c1fe7902 | ||
|
|
9fba2aa33d | ||
|
|
9db35e092b | ||
|
|
2474c26a83 | ||
|
|
7743d6d4c3 | ||
|
|
77a9cef3a1 | ||
|
|
c9d16a25c6 | ||
|
|
da394d40ea | ||
|
|
09d77211b9 | ||
|
|
61dd89f3ed | ||
|
|
ba62ff03c3 | ||
|
|
83115ea99a | ||
|
|
59f1b82368 | ||
|
|
c57e33fe0d | ||
|
|
daf54b74f4 | ||
|
|
2bef59ee76 | ||
|
|
9f79505e1c | ||
|
|
0e0e242095 | ||
|
|
fb96984d6e | ||
|
|
58dfa2de92 | ||
|
|
cf9476ab08 | ||
|
|
5b442a93a9 | ||
|
|
d5eaf101eb | ||
|
|
fda5bb8a60 | ||
|
|
1ccd70a18c | ||
|
|
93dfea9a30 | ||
|
|
d720467338 | ||
|
|
8efcbd4af4 | ||
|
|
76518a01a1 | ||
|
|
caad76ba95 | ||
|
|
bb9f225fd7 | ||
|
|
cfd472016e | ||
|
|
963b360a81 | ||
|
|
9f96b07ebd | ||
|
|
4da5b25a5c | ||
|
|
fa9ad9981c | ||
|
|
5c0652b395 | ||
|
|
71d7649db6 | ||
|
|
fab60107e3 | ||
|
|
eeaff3c1db | ||
|
|
0def3e2e49 | ||
|
|
4e2afeaea4 | ||
|
|
3a85ad0896 | ||
|
|
046c17d3df | ||
|
|
2713e63902 | ||
|
|
86ec117cfd | ||
|
|
78168d269c | ||
|
|
50c3b04eb1 | ||
|
|
0c6479570c | ||
|
|
a4d157e9c2 | ||
|
|
df3dd2ad88 | ||
|
|
1e6aee142b | ||
|
|
df9403f2d3 | ||
|
|
6fabc876a5 | ||
|
|
4c724a333b | ||
|
|
ad994f3ca3 | ||
|
|
a2b9f4178c | ||
|
|
571aea8d38 | ||
|
|
9ec7704d7b | ||
|
|
60e38f4868 | ||
|
|
0eb07ef9b6 | ||
|
|
7bf17afc42 | ||
|
|
1c169d007a | ||
|
|
c5d35bfc93 | ||
|
|
df3a6dcbd2 | ||
|
|
1aeee1db79 | ||
|
|
f21442b1c2 | ||
|
|
d3a8548661 | ||
|
|
69a310c78f |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,5 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Ask a question
|
- 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
|
about: The place for questions, support and feature requests
|
||||||
|
|||||||
4
.github/workflows/maven-publish.yml
vendored
@ -10,9 +10,9 @@ jobs:
|
|||||||
runs-on: macOS-latest
|
runs-on: macOS-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|||||||
274
.github/workflows/spotube-publish-binary.yml
vendored
Normal file
@ -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/<target> with
|
||||||
|
# Gradle (:composeApp:renderPackaging<Target>: 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"
|
||||||
595
.github/workflows/spotube-release-binary.yml
vendored
@ -10,7 +10,21 @@ on:
|
|||||||
default: 'stable'
|
default: 'stable'
|
||||||
options:
|
options:
|
||||||
- stable
|
- stable
|
||||||
|
- beta
|
||||||
- nightly
|
- 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:
|
dry_run:
|
||||||
description: 'Dry run (build & upload artifacts only, skip creating release)'
|
description: 'Dry run (build & upload artifacts only, skip creating release)'
|
||||||
required: false
|
required: false
|
||||||
@ -22,57 +36,133 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
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:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
COMPOSE_WEBVIEW_REPO: kdroidFilter/ComposeNativeWebview
|
COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview
|
||||||
COMPOSE_WEBVIEW_COMMIT: 5aeb268a2c37ff4dbfb76924573344a7c4f763ce
|
# fix/linux-wayland-input
|
||||||
GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin
|
COMPOSE_WEBVIEW_COMMIT: b11ac748de1219f9432338c067a2c5dafef3072a
|
||||||
VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-deps:
|
validate-inputs:
|
||||||
name: Prepare Dependencies
|
name: Validate Inputs
|
||||||
runs-on: macos-latest
|
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:
|
steps:
|
||||||
- name: Checkout ComposeNativeWebview
|
- name: Checkout ComposeNativeWebview
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
repository: ${{ env.COMPOSE_WEBVIEW_REPO }}
|
repository: ${{ env.COMPOSE_WEBVIEW_REPO }}
|
||||||
ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }}
|
ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
path: compose-webview
|
path: compose-webview
|
||||||
|
|
||||||
- name: Checkout gradle-plugin
|
- name: Set up JDK 21
|
||||||
uses: actions/checkout@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
repository: ${{ env.GRADLE_PLUGIN_REPO }}
|
distribution: temurin
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
java-version: 21
|
||||||
path: gradle-plugin
|
|
||||||
|
|
||||||
- name: Checkout vlcj-bundler
|
- name: Build Linux WebView native library
|
||||||
uses: actions/checkout@v4
|
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:
|
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 }}
|
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
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v4
|
||||||
|
|
||||||
- name: Set up Rust
|
- name: Set up Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Publish compose-webview to mavenLocal
|
- name: Publish compose-webview to mavenLocal
|
||||||
working-directory: compose-webview
|
working-directory: compose-webview
|
||||||
@ -80,39 +170,39 @@ jobs:
|
|||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew publishToMavenLocal
|
./gradlew publishToMavenLocal
|
||||||
|
|
||||||
- name: Publish gradle-plugin to mavenLocal
|
- name: Verify published JVM jar carries every WebView backend
|
||||||
working-directory: gradle-plugin
|
|
||||||
run: |
|
run: |
|
||||||
chmod +x gradlew
|
jar=$(ls ~/.m2/repository/dev/nucleusframework/composewebview-jvm/*/composewebview-jvm-*.jar | grep -v -e sources -e javadoc | head -1)
|
||||||
./gradlew publishToMavenLocal
|
contents=$(unzip -l "$jar")
|
||||||
|
for f in linux-x64/libcompose_webview_linux.so linux-aarch64/libcompose_webview_linux.so \
|
||||||
- name: Publish vlcj-bundler to mavenLocal
|
win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll \
|
||||||
working-directory: vlcj-bundler
|
darwin-aarch64/libcompose_webview_macos.dylib darwin-x64/libcompose_webview_macos.dylib; do
|
||||||
run: |
|
grep -q "nucleus/native/$f" <<< "$contents" || { echo "::error::$jar is missing $f"; exit 1; }
|
||||||
chmod +x gradlew
|
done
|
||||||
./gradlew publishToMavenLocal
|
|
||||||
|
|
||||||
- name: Upload maven local
|
- name: Upload maven local
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: maven-local
|
name: maven-local
|
||||||
path: ~/.m2/repository/
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Download maven local
|
- name: Download maven local
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: maven-local
|
name: maven-local
|
||||||
path: ~/.m2/repository/
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -121,35 +211,46 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Decode keystore
|
- name: Decode keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE: ${{ secrets.KEYSTORE }}
|
||||||
run: |
|
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
|
- name: Configure Android signing
|
||||||
working-directory: composeApp
|
working-directory: composeApp
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
|
||||||
|
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
cat >> local.properties <<EOF
|
cat >> local.properties <<EOF
|
||||||
release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks
|
release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks
|
||||||
release.signing.storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
release.signing.storePassword=${KEYSTORE_PASSWORD}
|
||||||
release.signing.keyAlias=${{ secrets.KEYSTORE_KEY_ALIAS }}
|
release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}
|
||||||
release.signing.keyPassword=${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Assemble Release
|
- name: Assemble Release
|
||||||
run: ./gradlew :composeApp:assembleRelease
|
run: |
|
||||||
|
if [ "${{ inputs.release_type }}" = "nightly" ]; then
|
||||||
|
./gradlew :composeApp:assembleNightlyRelease -Pnightly=true -PbuildNumber=${{ github.run_number }}
|
||||||
|
else
|
||||||
|
./gradlew :composeApp:assembleStableRelease
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
APK_FILE=$(find composeApp/build/outputs/apk/release -name "*.apk" -type f | head -1)
|
APK_FILE=$(find composeApp/build/outputs/apk -path "*elease*" -name "*.apk" -type f | head -1)
|
||||||
cp "$APK_FILE" Spotube-android-all-arch.apk
|
cp "$APK_FILE" Spotube-android-all-arch.apk
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: Spotube-android-all-arch.apk
|
path: Spotube-android-all-arch.apk
|
||||||
@ -162,24 +263,29 @@ jobs:
|
|||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Linux Desktop
|
name: Linux Desktop
|
||||||
needs: prepare-deps
|
needs: [validate-inputs, prepare-deps]
|
||||||
|
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }}
|
||||||
|
timeout-minutes: 120
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
# Full history: the metainfo's release notes come from git-cliff
|
||||||
|
- uses: actions/checkout@v7
|
||||||
- name: Download maven local
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: maven-local
|
fetch-depth: 0
|
||||||
path: ~/.m2/repository/
|
|
||||||
|
|
||||||
- name: Install packaging tools
|
- name: Install packaging tools
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y rpm fuse
|
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
|
||||||
|
|
||||||
|
- name: Download maven local
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: maven-local
|
||||||
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -188,33 +294,202 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Cache GraalVM toolchain
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.gradle/nucleus/graalvm
|
||||||
|
key: graalvm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/libs.versions.toml', 'composeApp/build.gradle.kts') }}
|
||||||
|
restore-keys: graalvm-${{ runner.os }}-${{ runner.arch }}-
|
||||||
|
|
||||||
- name: Package Linux
|
- name: Package Linux
|
||||||
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm
|
env:
|
||||||
|
# Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
|
||||||
|
|
||||||
|
# Portable tarball the AUR and Flathub packages install from. Like deb/rpm it uses the system's VLC,
|
||||||
|
# GTK and WebKitGTK, so it is packed from the distributable before the AppImage build replaces it
|
||||||
|
- name: Install git-cliff
|
||||||
|
uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: git-cliff
|
||||||
|
|
||||||
|
- name: Package Linux tarball
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.release_type }}" = "nightly" ]; then
|
||||||
|
VERSION=nightly
|
||||||
|
else
|
||||||
|
VERSION=$(./gradlew -q :composeApp:printVersion)
|
||||||
|
fi
|
||||||
|
# Desktop entry + metainfo, with the release history from git-cliff
|
||||||
|
./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
|
||||||
|
packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type == 'nightly' && 'nightly' || 'stable' }}" .
|
||||||
|
|
||||||
|
- name: Upload tarball
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: linux-tarball
|
||||||
|
path: spotube-linux-*-x86_64.tar.xz
|
||||||
|
|
||||||
|
# Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages
|
||||||
|
- name: Package Linux AppImage
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
|
||||||
|
|
||||||
- name: Rename Linux artifacts
|
- name: Rename Linux artifacts
|
||||||
run: |
|
run: |
|
||||||
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
|
DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1)
|
||||||
RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1)
|
RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1)
|
||||||
cp "$DEB_FILE" Spotube-linux-x86_64.deb
|
cp "$DEB_FILE" Spotube-linux-x86_64.deb
|
||||||
cp "$RPM_FILE" Spotube-linux-x86_64.rpm
|
cp "$RPM_FILE" Spotube-linux-x86_64.rpm
|
||||||
|
APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1)
|
||||||
|
cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage
|
||||||
|
|
||||||
- name: Upload DEB
|
- name: Upload DEB
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-deb
|
name: linux-deb
|
||||||
path: Spotube-linux-x86_64.deb
|
path: Spotube-linux-x86_64.deb
|
||||||
|
|
||||||
- name: Upload RPM
|
- name: Upload RPM
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: linux-rpm
|
name: linux-rpm
|
||||||
path: Spotube-linux-x86_64.rpm
|
path: Spotube-linux-x86_64.rpm
|
||||||
|
|
||||||
|
- name: Upload AppImage
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: linux-appimage
|
||||||
|
path: Spotube-linux-x86_64.AppImage
|
||||||
|
|
||||||
|
- name: Setup tmate session on failure
|
||||||
|
if: ${{ inputs.debug_ssh && failure() }}
|
||||||
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
with:
|
||||||
|
limit-access-to-actor: true
|
||||||
|
|
||||||
|
build-linux-arm64:
|
||||||
|
name: Linux Desktop (aarch64)
|
||||||
|
needs: [validate-inputs, prepare-deps]
|
||||||
|
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }}
|
||||||
|
timeout-minutes: 120
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
# Full history: the metainfo's release notes come from git-cliff
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# The arm64 runner image ships without an Android SDK/NDK, but :composeApp configuration needs both
|
||||||
|
# (Gobley's cargo plugin resolves the NDK directory for the Android Rust targets)
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v4
|
||||||
|
with:
|
||||||
|
packages: tools platform-tools ndk;28.2.13676358
|
||||||
|
|
||||||
|
- name: Install packaging tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Cache GraalVM toolchain
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.gradle/nucleus/graalvm
|
||||||
|
key: graalvm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/libs.versions.toml', 'composeApp/build.gradle.kts') }}
|
||||||
|
restore-keys: graalvm-${{ runner.os }}-${{ runner.arch }}-
|
||||||
|
|
||||||
|
- name: Package Linux
|
||||||
|
env:
|
||||||
|
# Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
|
||||||
|
|
||||||
|
# Portable tarball the AUR and Flathub packages install from. Like deb/rpm it uses the system's VLC,
|
||||||
|
# GTK and WebKitGTK, so it is packed from the distributable before the AppImage build replaces it
|
||||||
|
- name: Install git-cliff
|
||||||
|
uses: taiki-e/install-action@v2
|
||||||
|
with:
|
||||||
|
tool: git-cliff
|
||||||
|
|
||||||
|
- name: Package Linux tarball
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.release_type }}" = "nightly" ]; then
|
||||||
|
VERSION=nightly
|
||||||
|
else
|
||||||
|
VERSION=$(./gradlew -q :composeApp:printVersion)
|
||||||
|
fi
|
||||||
|
# Desktop entry + metainfo, with the release history from git-cliff
|
||||||
|
./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
|
||||||
|
packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type == 'nightly' && 'nightly' || 'stable' }}" .
|
||||||
|
|
||||||
|
- name: Upload tarball
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: linux-tarball-arm64
|
||||||
|
path: spotube-linux-*-aarch64.tar.xz
|
||||||
|
|
||||||
|
# Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages
|
||||||
|
- name: Package Linux AppImage
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
|
||||||
|
|
||||||
|
- name: Rename Linux artifacts
|
||||||
|
run: |
|
||||||
|
DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1)
|
||||||
|
RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1)
|
||||||
|
cp "$DEB_FILE" Spotube-linux-aarch64.deb
|
||||||
|
cp "$RPM_FILE" Spotube-linux-aarch64.rpm
|
||||||
|
APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1)
|
||||||
|
cp "$APPIMAGE_FILE" Spotube-linux-aarch64.AppImage
|
||||||
|
|
||||||
|
- name: Upload DEB
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: linux-deb-arm64
|
||||||
|
path: Spotube-linux-aarch64.deb
|
||||||
|
|
||||||
|
- name: Upload RPM
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: linux-rpm-arm64
|
||||||
|
path: Spotube-linux-aarch64.rpm
|
||||||
|
|
||||||
|
- name: Upload AppImage
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: linux-appimage-arm64
|
||||||
|
path: Spotube-linux-aarch64.AppImage
|
||||||
|
|
||||||
- name: Setup tmate session on failure
|
- name: Setup tmate session on failure
|
||||||
if: ${{ inputs.debug_ssh && failure() }}
|
if: ${{ inputs.debug_ssh && failure() }}
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
@ -223,19 +498,21 @@ jobs:
|
|||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Windows Desktop
|
name: Windows Desktop
|
||||||
needs: prepare-deps
|
needs: [validate-inputs, prepare-deps]
|
||||||
|
if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }}
|
||||||
|
timeout-minutes: 120
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Download maven local
|
- name: Download maven local
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: maven-local
|
name: maven-local
|
||||||
path: ~/.m2/repository/
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -244,19 +521,35 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
|
# native-image links with MSVC
|
||||||
|
- name: Set up MSVC
|
||||||
|
uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
|
||||||
|
- name: Cache GraalVM toolchain
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.gradle/nucleus/graalvm
|
||||||
|
key: graalvm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/libs.versions.toml', 'composeApp/build.gradle.kts') }}
|
||||||
|
restore-keys: graalvm-${{ runner.os }}-${{ runner.arch }}-
|
||||||
|
|
||||||
- name: Package EXE
|
- name: Package EXE
|
||||||
run: ./gradlew :composeApp:packageReleaseExe
|
env:
|
||||||
|
# Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./gradlew :composeApp:packageGraalvmExe -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
|
||||||
|
|
||||||
- name: Rename Windows artifacts
|
- name: Rename Windows artifacts
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$EXE_FILE = Get-ChildItem -Path composeApp/build -Filter "*.exe" -Recurse | Select-Object -First 1
|
# Top level only: subfolders hold the unpacked app, and __* are electron-builder helpers
|
||||||
|
$EXE_FILE = Get-ChildItem -Path composeApp/build/compose/binaries/main/graalvm-exe -Filter "*.exe" |
|
||||||
|
Where-Object { $_.Name -notlike '__*' } | Select-Object -First 1
|
||||||
Copy-Item $EXE_FILE.FullName Spotube-windows-x86_64-setup.exe
|
Copy-Item $EXE_FILE.FullName Spotube-windows-x86_64-setup.exe
|
||||||
|
|
||||||
- name: Upload EXE
|
- name: Upload EXE
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: windows-exe
|
name: windows-exe
|
||||||
path: Spotube-windows-x86_64-setup.exe
|
path: Spotube-windows-x86_64-setup.exe
|
||||||
@ -269,19 +562,21 @@ jobs:
|
|||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
name: macOS Desktop
|
name: macOS Desktop
|
||||||
needs: prepare-deps
|
needs: [validate-inputs, prepare-deps]
|
||||||
|
if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }}
|
||||||
|
timeout-minutes: 120
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Download maven local
|
- name: Download maven local
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: maven-local
|
name: maven-local
|
||||||
path: ~/.m2/repository/
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@ -290,21 +585,40 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Cache GraalVM toolchain
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.gradle/nucleus/graalvm
|
||||||
|
key: graalvm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/libs.versions.toml', 'composeApp/build.gradle.kts') }}
|
||||||
|
restore-keys: graalvm-${{ runner.os }}-${{ runner.arch }}-
|
||||||
|
|
||||||
- name: Package DMG
|
- name: Package DMG
|
||||||
run: ./gradlew :composeApp:packageReleaseDmg
|
env:
|
||||||
|
# Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# The free macOS runner has 3 cores / 7 GB. With the default 4g Gradle + 3g Kotlin daemons idling,
|
||||||
|
# native-image only gets ~3 GB and spends ~40% of its time in GC (47 min build vs ~15 on Linux).
|
||||||
|
# A single-use daemon with a smaller heap and in-process Kotlin compilation leaves the RAM to it.
|
||||||
|
run: >
|
||||||
|
./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
|
- name: Rename macOS artifact
|
||||||
run: |
|
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
|
cp "$DMG_FILE" Spotube-macos-universal.dmg
|
||||||
|
|
||||||
- name: Upload DMG
|
- name: Upload DMG
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: macos-dmg
|
name: macos-dmg
|
||||||
path: Spotube-macos-universal.dmg
|
path: Spotube-macos-universal.dmg
|
||||||
@ -315,30 +629,124 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
limit-access-to-actor: true
|
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:
|
create-release:
|
||||||
name: Create GitHub 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:
|
needs:
|
||||||
|
- validate-inputs
|
||||||
|
- prepare-deps
|
||||||
- build-android
|
- build-android
|
||||||
- build-linux
|
- build-linux
|
||||||
|
- build-linux-arm64
|
||||||
- build-windows
|
- build-windows
|
||||||
- build-macos
|
- build-macos
|
||||||
|
- build-ios
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
steps:
|
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
|
- name: Set release variables
|
||||||
id: vars
|
id: vars
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ inputs.release_type }}" = "stable" ]; then
|
if [ "${{ inputs.release_type }}" != "nightly" ]; then
|
||||||
VERSION=$(grep 'versionName' composeApp/build.gradle.kts | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
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}"
|
TAG="v${VERSION}"
|
||||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "is_draft=true" >> "$GITHUB_OUTPUT"
|
echo "is_draft=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "release_name=Spotube ${TAG}" >> "$GITHUB_OUTPUT"
|
echo "release_name=Spotube ${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
TAG="nightly"
|
TAG="nightly"
|
||||||
VERSION="$(date +%Y-%m-%d)"
|
VERSION="$(date +%Y-%m-%d)-${{ github.run_number }}"
|
||||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "is_draft=false" >> "$GITHUB_OUTPUT"
|
echo "is_draft=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "release_name=Nightly ${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "release_name=Nightly ${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
@ -346,13 +754,14 @@ jobs:
|
|||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
|
|
||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
|
if: ${{ inputs.platform == 'all' }}
|
||||||
run: |
|
run: |
|
||||||
echo "## Downloads" > release-notes.md
|
echo "## Downloads" > release-notes.md
|
||||||
echo "" >> 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
|
if [ -d "$dir" ]; then
|
||||||
echo "### $dir" >> release-notes.md
|
echo "### $dir" >> release-notes.md
|
||||||
for f in "$dir"/*; do
|
for f in "$dir"/*; do
|
||||||
@ -363,19 +772,29 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.vars.outputs.tag }}
|
tag_name: ${{ steps.vars.outputs.tag }}
|
||||||
name: ${{ steps.vars.outputs.release_name }}
|
# A new tag (stable) would otherwise point at the default branch instead of the built commit
|
||||||
body_path: release-notes.md
|
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 }}
|
draft: ${{ steps.vars.outputs.is_draft }}
|
||||||
prerelease: ${{ steps.vars.outputs.is_prerelease }}
|
prerelease: ${{ steps.vars.outputs.is_prerelease }}
|
||||||
files: |
|
files: |
|
||||||
android-apk/*.apk
|
android-apk/*.apk
|
||||||
android-aab/*.aab
|
|
||||||
linux-deb/*.deb
|
linux-deb/*.deb
|
||||||
linux-rpm/*.rpm
|
linux-rpm/*.rpm
|
||||||
linux-appimage/*.AppImage
|
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
|
windows-exe/*.exe
|
||||||
macos-dmg/*.dmg
|
macos-dmg/*.dmg
|
||||||
|
ios-ipa/*.ipa
|
||||||
|
|||||||
17
.mcp.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
"mcp": {
|
"mcp": {
|
||||||
"compose-hot-reload": {
|
"compose-hot-reload": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"disabled": false,
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"command": [
|
"command": [
|
||||||
"./gradlew",
|
"./gradlew",
|
||||||
@ -11,6 +12,10 @@
|
|||||||
"--console=plain",
|
"--console=plain",
|
||||||
"hotMcpServer"
|
"hotMcpServer"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"url": "http://127.0.0.1:64342/stream",
|
||||||
|
"type": "remote"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
37
.vscode/settings.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
63
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.
|
- `: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
|
## Real app entrypoints
|
||||||
- Desktop/JVM: `composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt` (`mainClass = dev.krtirtho.spotube.MainKt`).
|
- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`).
|
||||||
- Android: `composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt`.
|
- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`.
|
||||||
- iOS bridge: `composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`.
|
- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`.
|
||||||
|
|
||||||
## High-value commands
|
## High-value commands
|
||||||
- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only.
|
- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only.
|
||||||
- Run desktop: `:composeApp:run`
|
- 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`
|
- 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`
|
- 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.
|
- No lint/typecheck/formatter tasks are configured; `:composeApp:check` is the only aggregated check.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations.
|
- `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).
|
- **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
|
## 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/`.
|
- 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 architecture
|
||||||
- `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system.
|
- `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.
|
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||||
|
|
||||||
## UI component patterns
|
## 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 `<string name="...">` 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<UiState>` 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.
|
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` 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.
|
- **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.
|
- **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`)
|
## Release workflow (`.github/workflows/release.yml`)
|
||||||
- Triggered by `workflow_dispatch` with a `release_type` choice input (`stable` | `nightly`).
|
- Triggered by `workflow_dispatch` with a `release_type` choice input (`stable` | `nightly`).
|
||||||
- `prepare-deps` job checks out & publishes to `mavenLocal`:
|
- `prepare-deps` job checks out & publishes `kdroidFilter/ComposeNativeWebview` (compose-webview) to `mavenLocal`. The spotube gradle plugin and vlcj-bundler now come from Maven Central.
|
||||||
- `kdroidFilter/ComposeNativeWebview` (compose-webview)
|
- 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-<format>/`), not jpackage/JVM ones.
|
||||||
- `team-spotube/gradle-plugin` (spotubeGradle + vlcjBundler plugins)
|
|
||||||
- Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`.
|
|
||||||
- **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release.
|
- **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release.
|
||||||
- **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease.
|
- **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.
|
- **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.
|
- `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/<target>` (`linux`, `aur`, `flatpak`, `winget`, `chocolatey`) holds JMustache templates (`*.mustache`) plus files copied as is. `:composeApp:renderPackaging<Target>` renders them into `composeApp/build/packaging/<target>` (`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=<dir>`; 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-<version>-<arch>.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<UiState>` 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.
|
||||||
|
|||||||
1711
CHANGELOG.md
@ -30,19 +30,19 @@ All types of contributions are encouraged and valued. See the [Table of Contents
|
|||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
|
|
||||||
This project and everyone participating in it is governed by the
|
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
|
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
|
## 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:
|
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 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.
|
- 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.
|
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.
|
- 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)).
|
- 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/KRTirtho/spotubeissues?q=label%3Abug).
|
- 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.
|
- 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:
|
- Collect information about the bug:
|
||||||
- Stack trace (Traceback)
|
- 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:
|
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.
|
- 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.
|
- 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.
|
- 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
|
#### Before Submitting an Enhancement
|
||||||
|
|
||||||
- Make sure that you are using the latest version.
|
- 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.
|
- 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/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.
|
- 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
|
- 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
|
||||||
|
|
||||||
<!-- omit in toc -->
|
<!-- omit in toc -->
|
||||||
|
|
||||||
#### How Do I Submit a Good Enhancement Suggestion?
|
#### 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.
|
- 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.
|
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
|
||||||
|
|||||||
@ -59,4 +59,4 @@ This policy is effective as of 2022-04-01
|
|||||||
|
|
||||||
**Contact Us**
|
**Contact Us**
|
||||||
|
|
||||||
If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at krtirtho@gmail.com.
|
If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at team.spotube@proton.me
|
||||||
192
README.md
@ -2,7 +2,8 @@
|
|||||||
<img width="600" src="assets/branding/spotube_banner.png" alt="Spotube Logo">
|
<img width="600" src="assets/branding/spotube_banner.png" alt="Spotube Logo">
|
||||||
|
|
||||||
A cross-platform extensible open-source music streaming platform.<br>
|
A cross-platform extensible open-source music streaming platform.<br>
|
||||||
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!
|
||||||
|
|
||||||
<a href="https://spotube.cc"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
|
<a href="https://spotube.cc"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
|
||||||
<a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a>
|
<a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a>
|
||||||
@ -16,17 +17,16 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|
|
||||||

|
****</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 🌃 Features
|
## 🌃 Features
|
||||||
|
|
||||||
- 🧩 Plugin powered, supports any platform or custom music service through plugins.
|
- 🧩 Plugin powered, supports any platform or custom music service through plugins.
|
||||||
- 🗺️ Community driven plugins for popular platforms or create your own.
|
- 🗺️ 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.
|
- 🖥️ 📱 Cross-platform support.
|
||||||
- 🪶 Small size & less data usage.
|
- 🪶 Small size & less data usage.
|
||||||
- 🕒 Time synced lyrics regardless of the plugin support.
|
- 🕒 Time synced lyrics regardless of the plugin support.
|
||||||
@ -41,17 +41,187 @@ Download Spotube from the Official Website: https://spotube.cc
|
|||||||
|
|
||||||
### 🔄 Nightly Builds
|
### 🔄 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
|
<details>
|
||||||
|
<summary>
|
||||||
|
<h3 style="display: inline;">🕳️ Building from source [Click to expand]</h3>
|
||||||
|
</summary>
|
||||||
|
<hr/>
|
||||||
|
<a href="https://github.com/team-spotube/spotube/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/KRTirtho/spotube/spotube-release-binary.yml?+label=Build%20Status"></a>
|
||||||
|
|
||||||
<a href="https://github.com/KRTirtho/spotube/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/KRTirtho/spotube/spotube-release-binary.yml?+label=Build%20Status"></a>
|
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`. <br/>Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. <br/>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-<format>`
|
||||||
|
|
||||||
|
> [!Tip]
|
||||||
|
> Add `-Pnightly=true` for the nightly channel (separate icons and name).
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>How the Linux AppImage bundle works</summary>
|
||||||
|
|
||||||
|
- [`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`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 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<version>-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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<hr/>
|
||||||
|
|
||||||
|
## 📚 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
|
## 👥 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
|
- [Owen Connor](https://github.com/owencz1998) - The Cool Discord Moderator
|
||||||
- [Piotr Rogowski](https://github.com/karniv00l) - The MacOS Developer
|
- [Piotr Rogowski](https://github.com/karniv00l) - The MacOS Developer
|
||||||
- [Rusty Apple](https://github.com/RustyApple) - The Mysterious Unknown Guy
|
- [Rusty Apple](https://github.com/RustyApple) - The Mysterious Unknown Guy
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 771 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 771 KiB |
BIN
assets/feature-graphics/play-feature-graphic.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/feature-graphics/readme-hero.png
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
assets/feature-graphics/readme-mobile.png
Normal file
|
After Width: | Height: | Size: 317 KiB |
BIN
assets/feature-graphics/store-desktop-01-home.png
Normal file
|
After Width: | Height: | Size: 611 KiB |
BIN
assets/feature-graphics/store-desktop-02-lyrics.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
assets/feature-graphics/store-desktop-03-playlists.png
Normal file
|
After Width: | Height: | Size: 627 KiB |
BIN
assets/feature-graphics/store-desktop-04-search.png
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
assets/feature-graphics/store-desktop-05-local-tracks.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
assets/feature-graphics/store-desktop-06-group-jam.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
assets/feature-graphics/store-mobile-01-home.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
assets/feature-graphics/store-mobile-02-player.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
assets/feature-graphics/store-mobile-03-library.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/feature-graphics/store-mobile-04-devices.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/feature-graphics/store-mobile-05-group-jam.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 47 KiB |
BIN
assets/screenshots/devices-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
assets/screenshots/devices-mobile-light.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
assets/screenshots/downloads-desktop-light.png
Normal file
|
After Width: | Height: | Size: 424 KiB |
BIN
assets/screenshots/group-jam-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
assets/screenshots/group-jam-mobile-light.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/screenshots/home-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
BIN
assets/screenshots/home-desktop-light.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
BIN
assets/screenshots/home-mobile-dark.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
assets/screenshots/home-mobile-light.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
assets/screenshots/home-player-dark.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/screenshots/home-player-light.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
assets/screenshots/library-mobile-light.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
assets/screenshots/local-tracks-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 427 KiB |
BIN
assets/screenshots/local-tracks-desktop-light.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
assets/screenshots/lyrics-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
assets/screenshots/lyrics-desktop-light.png
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
assets/screenshots/playlists-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 897 KiB |
BIN
assets/screenshots/playlists-desktop-light.png
Normal file
|
After Width: | Height: | Size: 901 KiB |
BIN
assets/screenshots/search-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
assets/screenshots/search-desktop-light.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
assets/screenshots/search-mobile-light.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
14
buildSrc/build.gradle.kts
Normal file
@ -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)
|
||||||
|
}
|
||||||
8
buildSrc/settings.gradle.kts
Normal file
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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/<triplet>/webkit2gtk-4.1/WebKit*Process helper processes
|
||||||
|
// usr/lib/<triplet>/gio/modules/libgiognutls.so TLS backend for WebKit/libsoup
|
||||||
|
// libwebkit2gtk has the helper path compiled in as /usr/lib/<triplet>/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 `<root>/linux-<arch>/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<String>
|
||||||
|
|
||||||
|
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<String, File>()
|
||||||
|
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<String>()
|
||||||
|
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/<triplet>/webkit2gtk-4.1[/injected-bundle]" -> "././lib/<triplet>/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<String> =
|
||||||
|
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<String, String> {
|
||||||
|
val release = ubuntuRelease.get()
|
||||||
|
val index = HashMap<String, String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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<String>
|
||||||
|
|
||||||
|
@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<String>
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Renders one package manager's directory (packaging/<target>) 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.<key>.{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<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val pkgrel: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val repositoryUrl: Property<String>
|
||||||
|
|
||||||
|
/** Release assets by template key, e.g. `linuxX86_64` -> `spotube-linux-6.0.0-x86_64.tar.xz`. */
|
||||||
|
@get:Input
|
||||||
|
abstract val artifactNames: MapProperty<String, String>
|
||||||
|
|
||||||
|
/** 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<String, String>
|
||||||
|
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<Int>
|
||||||
|
|
||||||
|
@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=<dir with the release assets>.",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
output.parentFile.mkdirs()
|
||||||
|
output.writeText(rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun templateData(): Map<String, Any> {
|
||||||
|
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<String, File> {
|
||||||
|
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<String, Any> {
|
||||||
|
val groups = release.commits
|
||||||
|
.groupBy { it.group }
|
||||||
|
.toSortedMap()
|
||||||
|
.map { (group, commits) ->
|
||||||
|
mapOf(
|
||||||
|
// cliff.toml orders groups with an HTML comment prefix: "<!-- 0 -->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<Map<String, String>>
|
||||||
|
"${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<ChangelogRelease> {
|
||||||
|
val releases = JsonSlurper().parse(file) as List<Map<String, Any?>>
|
||||||
|
return releases.map { release ->
|
||||||
|
val commits = (release["commits"] as List<Map<String, Any?>>).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<ChangelogCommit>)
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,7 +27,7 @@ render_always = true
|
|||||||
# An array of regex based postprocessors to modify the changelog.
|
# An array of regex based postprocessors to modify the changelog.
|
||||||
postprocessors = [
|
postprocessors = [
|
||||||
# Replace the placeholder <REPO> with a URL.
|
# Replace the placeholder <REPO> with a URL.
|
||||||
{ pattern = '<REPO>', replace = "https://github.com/KRTirtho/spotube" },
|
{ pattern = '<REPO>', replace = "https://github.com/team-spotube/spotube" },
|
||||||
]
|
]
|
||||||
# render body even when there are no releases to process
|
# render body even when there are no releases to process
|
||||||
# render_always = true
|
# render_always = true
|
||||||
|
|||||||
110
composeApp/Cargo.lock
generated
@ -56,6 +56,19 @@ dependencies = [
|
|||||||
"winnow",
|
"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]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@ -133,6 +146,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
"lofty",
|
"lofty",
|
||||||
|
"log",
|
||||||
|
"parking_lot",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
"uniffi",
|
"uniffi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -207,6 +224,18 @@ dependencies = [
|
|||||||
"autocfg",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@ -286,6 +315,15 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
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]]
|
[[package]]
|
||||||
name = "lofty"
|
name = "lofty"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@ -365,6 +403,29 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@ -377,6 +438,12 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plain"
|
name = "plain"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@ -407,6 +474,15 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@ -426,6 +502,12 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scroll"
|
name = "scroll"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@ -522,6 +604,12 @@ version = "0.3.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smawk"
|
name = "smawk"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@ -598,6 +686,27 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.5.11"
|
version = "0.5.11"
|
||||||
@ -660,6 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
|
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-compat",
|
||||||
"bytes",
|
"bytes",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
|
|||||||
@ -4,10 +4,19 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
uniffi = "0.29.4"
|
uniffi = { version = "0.29.4", features = ["tokio"] }
|
||||||
lofty = "0.24.0"
|
lofty = "0.24.0"
|
||||||
discord-rich-presence = "1.1.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]
|
[lib]
|
||||||
crate-type = ["cdylib", "staticlib"]
|
crate-type = ["cdylib", "staticlib"]
|
||||||
path = "src/commonMain/rust/lib.rs"
|
path = "src/commonMain/rust/lib.rs"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
|||||||
@ -16,10 +16,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import gobley.gradle.GobleyHost
|
import gobley.gradle.GobleyHost
|
||||||
|
import gobley.gradle.Variant
|
||||||
import gobley.gradle.cargo.dsl.jvm
|
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 dev.nucleusframework.desktop.application.dsl.TargetFormat
|
||||||
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
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.io.FileInputStream
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
@ -39,10 +46,131 @@ plugins {
|
|||||||
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
kotlin("plugin.atomicfu") version libs.versions.kotlin
|
||||||
}
|
}
|
||||||
|
|
||||||
vlcjBundler {
|
val bundleId = "org.spotube.spotube"
|
||||||
packageName = "dev.krtirtho.spotube.core.generated" // choose a different package
|
val version = "6.0.0"
|
||||||
objectName = "VLCBundleLoaderGenerated" // or rename the object
|
// Set to N to release <version>-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=<run>, 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/<target> rendered into build/packaging/<target> with the version, the
|
||||||
|
// release assets' checksums (-PpackagingArtifactsDir=<dir>) and the git-cliff changelog. See RenderPackagingTask.
|
||||||
|
val gitCliffContext = tasks.register<GitCliffContextTask>("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<RenderPackagingTask>("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<DownloadLinuxGtkBundleTask>("downloadLinuxGtkBundle") {
|
||||||
|
outputDir.set(layout.buildDirectory.dir("linux-gtk-bundle"))
|
||||||
|
ubuntuRelease.set("noble")
|
||||||
|
}
|
||||||
|
val linuxGtkBundleDir: Provider<Directory> = downloadLinuxGtkBundle.flatMap { it.outputDir }
|
||||||
|
|
||||||
|
// One resources root holding VLC and GTK/WebKit, laid out for the AppImage
|
||||||
|
val assembleAppImageResources = tasks.register<Sync>("assembleAppImageResources") {
|
||||||
|
from(vlcjBundler.vlcNativesDirectory)
|
||||||
|
from(linuxGtkBundleDir)
|
||||||
|
exclude("**/.complete")
|
||||||
|
into(layout.buildDirectory.dir("appimage-resources"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
|
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
|
||||||
@ -65,9 +193,11 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jvm()
|
jvm()
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val commonMain by getting {
|
val commonMain by getting {
|
||||||
|
kotlin.srcDir(generatedAppInfoDir)
|
||||||
dependencies {
|
dependencies {
|
||||||
// Project dependencies
|
// Project dependencies
|
||||||
implementation(project(":plugin_interfaces"))
|
implementation(project(":plugin_interfaces"))
|
||||||
@ -75,7 +205,6 @@ kotlin {
|
|||||||
// Jetpack Compose dependencies
|
// Jetpack Compose dependencies
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.ktor3)
|
implementation(libs.coil.network.ktor3)
|
||||||
@ -86,6 +215,10 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation(libs.dns.sd.kt)
|
||||||
|
|
||||||
|
// Millennium UI
|
||||||
|
implementation(libs.millennium)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
@ -103,9 +236,10 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
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.core)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
|
implementation(libs.ktor.server.websockets)
|
||||||
// Zipline
|
// Zipline
|
||||||
api(libs.zipline.core)
|
api(libs.zipline.core)
|
||||||
implementation(libs.zipline.loader)
|
implementation(libs.zipline.loader)
|
||||||
@ -117,12 +251,6 @@ kotlin {
|
|||||||
// icons
|
// icons
|
||||||
implementation(libs.feather.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
|
// androidx-datastore
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
@ -137,8 +265,6 @@ kotlin {
|
|||||||
|
|
||||||
implementation(libs.semver)
|
implementation(libs.semver)
|
||||||
|
|
||||||
implementation(libs.material.kolor)
|
|
||||||
|
|
||||||
// crypto
|
// crypto
|
||||||
implementation(libs.cryptography.core)
|
implementation(libs.cryptography.core)
|
||||||
implementation(libs.cryptography.provider.optimal)
|
implementation(libs.cryptography.provider.optimal)
|
||||||
@ -159,7 +285,13 @@ kotlin {
|
|||||||
|
|
||||||
// Shimmer effect
|
// Shimmer effect
|
||||||
implementation(libs.compose.shimmer)
|
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 {
|
commonTest.dependencies {
|
||||||
@ -223,25 +355,40 @@ kotlin {
|
|||||||
implementation(libs.jna)
|
implementation(libs.jna)
|
||||||
|
|
||||||
implementation(libs.nucleus.core.runtime)
|
implementation(libs.nucleus.core.runtime)
|
||||||
|
implementation(libs.nucleus.graalvm.runtime)
|
||||||
implementation(libs.nucleus.nucleus.application)
|
implementation(libs.nucleus.nucleus.application)
|
||||||
implementation(libs.nucleus.decorated.window.tao)
|
implementation(libs.nucleus.decorated.window.tao)
|
||||||
implementation(libs.compose.native.tray)
|
implementation(libs.compose.native.tray)
|
||||||
implementation(libs.nucleus.media.control)
|
implementation(libs.nucleus.media.control)
|
||||||
|
implementation(libs.nucleus.darkmode.detector)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "dev.krtirtho.spotube"
|
namespace = "org.spotube.spotube"
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.krtirtho.spotube"
|
applicationId = bundleId
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = appBuildNumber
|
||||||
versionName = project.findProperty("versionName") as String? ?: "1.0"
|
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 {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@ -273,6 +420,14 @@ android {
|
|||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
signingConfig = signingConfigs.getByName("release")
|
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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
@ -288,23 +443,28 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nucleus.application {
|
nucleus.application {
|
||||||
mainClass = "dev.krtirtho.spotube.MainKt"
|
mainClass = "org.spotube.spotube.MainKt"
|
||||||
|
|
||||||
jvmArgs += listOf(
|
jvmArgs += listOf(
|
||||||
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
|
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
|
||||||
// --- ADD THESE FOR SPEED & LOW RAM ---
|
// --- ADD THESE FOR SPEED & LOW RAM ---
|
||||||
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
|
"-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:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
|
||||||
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
|
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
|
||||||
)
|
)
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
packageName = "dev.krtirtho.spotube"
|
packageName = bundleId
|
||||||
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
|
// 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")
|
licenseFile = project.file("../LICENSE")
|
||||||
|
|
||||||
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
|
appResourcesRootDir.set(
|
||||||
|
if (bundleForAppImage) assembleAppImageResources.map { layout.buildDirectory.dir("appimage-resources").get() } else vlcjBundler.vlcNativesDirectory
|
||||||
|
)
|
||||||
|
|
||||||
targetFormats(
|
targetFormats(
|
||||||
TargetFormat.Dmg,
|
TargetFormat.Dmg,
|
||||||
@ -317,14 +477,54 @@ nucleus.application {
|
|||||||
|
|
||||||
modules("jdk.unsupported")
|
modules("jdk.unsupported")
|
||||||
|
|
||||||
homepage = "https://spotube.cc"
|
homepage = homePageUrl
|
||||||
|
|
||||||
linux {
|
linux {
|
||||||
|
iconFile.set(project.file("$desktopIconDir/icon.png"))
|
||||||
modules("jdk.security.auth")
|
modules("jdk.security.auth")
|
||||||
debMaintainer = "Team Spotube <team.spotube@proton.me>"
|
debMaintainer = "Team Spotube <team.spotube@proton.me>"
|
||||||
rpmLicenseType = "AGPL-3.0-or-later"
|
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 {
|
windows {
|
||||||
|
iconFile.set(project.file("$desktopIconDir/icon.ico"))
|
||||||
shortcut = true
|
shortcut = true
|
||||||
menu = true
|
menu = true
|
||||||
dirChooser = true
|
dirChooser = true
|
||||||
@ -332,6 +532,9 @@ nucleus.application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
macOS {
|
macOS {
|
||||||
|
iconFile.set(project.file("$desktopIconDir/icon.icns"))
|
||||||
|
bundleID = if (isNightly) "$bundleId.nightly" else bundleId
|
||||||
|
dockName = appName
|
||||||
notarization {
|
notarization {
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -341,14 +544,100 @@ nucleus.application {
|
|||||||
buildTypes.release.proguard {
|
buildTypes.release.proguard {
|
||||||
configurationFiles.from(project.file("proguard-rules.pro"))
|
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<org.gradle.jvm.tasks.Jar>().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<Copy>().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
|
// 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
|
// https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks
|
||||||
tasks.withType<ComposeHotRun>().configureEach {
|
tasks.withType<ComposeHotRun>().configureEach {
|
||||||
|
// Populates the resources dir below with the VLC natives (appResourcesRootDir); hot run doesn't depend on it by default
|
||||||
|
dependsOn("prepareAppResources")
|
||||||
jvmArgs(
|
jvmArgs(
|
||||||
"--enable-native-access=ALL-UNNAMED"
|
"--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(
|
systemProperty(
|
||||||
"compose.application.resources.dir",
|
"compose.application.resources.dir",
|
||||||
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()
|
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()
|
||||||
@ -356,7 +645,44 @@ tasks.withType<ComposeHotRun>().configureEach {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cargo {
|
cargo {
|
||||||
|
// Gobley defaults desktop to the debug profile, which ships an unstripped ~31 MB library
|
||||||
|
if (isProductionBuild) jvmVariant = Variant.Release
|
||||||
builds.jvm {
|
builds.jvm {
|
||||||
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
|
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gobley adds build/intermediates/rust/<triple>/<variant> 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1332
composeApp/graalvm/reachability-metadata.json
Normal file
BIN
composeApp/icons/nightly/icon.icns
Normal file
BIN
composeApp/icons/nightly/icon.ico
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
composeApp/icons/nightly/icon.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
composeApp/icons/stable/icon.icns
Normal file
BIN
composeApp/icons/stable/icon.ico
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
composeApp/icons/stable/icon.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
@ -15,14 +15,14 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.newpipe
|
package org.spotube.spotube.core.newpipe
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
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.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@ -82,10 +82,16 @@ class NewPipeDownloader(private val cookieJar: PersistentCookieJar) : Downloader
|
|||||||
companion object {
|
companion object {
|
||||||
private var instance: NewPipeDownloader? = null
|
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) {
|
fun init(paths: Paths) {
|
||||||
|
if (instance != null) return
|
||||||
val cookieJar = PersistentCookieJar(paths)
|
val cookieJar = PersistentCookieJar(paths)
|
||||||
instance = NewPipeDownloader(cookieJar)
|
instance = NewPipeDownloader(cookieJar).also { NewPipe.init(it) }
|
||||||
NewPipe.init(instance)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -15,24 +15,35 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.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 dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
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 io.ktor.http.Url
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.schabi.newpipe.extractor.ServiceList.YouTube
|
import org.schabi.newpipe.extractor.ServiceList.YouTube
|
||||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
||||||
|
import org.schabi.newpipe.extractor.stream.DeliveryMethod
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
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 class NewPipeService {
|
||||||
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
private val logger = Logger.withTag("NewPipeService")
|
||||||
|
|
||||||
|
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val extractor = YouTube.getSearchExtractor(
|
val extractor = YouTube.getSearchExtractor(
|
||||||
query,
|
query,
|
||||||
mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS),
|
mutableListOf(
|
||||||
|
when (kind) {
|
||||||
|
SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
|
||||||
|
SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS
|
||||||
|
}),
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
extractor.fetchPage()
|
extractor.fetchPage()
|
||||||
@ -46,9 +57,8 @@ actual class NewPipeService {
|
|||||||
title = item.name,
|
title = item.name,
|
||||||
url = item.url,
|
url = item.url,
|
||||||
uploader = item.uploaderName,
|
uploader = item.uploaderName,
|
||||||
durationMs = item.duration,
|
durationMs = secondsToMs(item.duration),
|
||||||
thumbnailUrl = item.thumbnails.first().url,
|
thumbnailUrl = item.thumbnails.first().url)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -61,14 +71,33 @@ actual class NewPipeService {
|
|||||||
val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id")
|
val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id")
|
||||||
extractor.fetchPage()
|
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
|
val audioStreams = extractor.audioStreams
|
||||||
.mapNotNull { stream ->
|
.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(
|
AudioStream.Lossy(
|
||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = normalizeCodec(stream.codec),
|
||||||
container = stream.format!!.suffix,
|
container = normalizeContainer(format.suffix),
|
||||||
bitrate = stream.bitrate,
|
bitrate = stream.bitrate,
|
||||||
)
|
protocol = stream.deliveryMethod.toProtocol())
|
||||||
} + buildList {
|
} + buildList {
|
||||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||||
AudioStream.Lossy(
|
AudioStream.Lossy(
|
||||||
@ -90,7 +119,6 @@ actual class NewPipeService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val videoStreams = extractor.videoStreams
|
val videoStreams = extractor.videoStreams
|
||||||
.filter { stream -> stream.isUrl && stream.format != null }
|
.filter { stream -> stream.isUrl && stream.format != null }
|
||||||
.map { stream ->
|
.map { stream ->
|
||||||
@ -98,18 +126,29 @@ actual class NewPipeService {
|
|||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = stream.codec,
|
||||||
bitrate = stream.bitrate,
|
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(
|
VideoInfo(
|
||||||
id = id,
|
id = id,
|
||||||
title = extractor.name,
|
title = extractor.name,
|
||||||
url = extractor.url,
|
url = extractor.url,
|
||||||
uploader = extractor.uploaderName,
|
uploader = extractor.uploaderName,
|
||||||
durationMs = extractor.length,
|
durationMs = secondsToMs(extractor.length),
|
||||||
thumbnailUrl = extractor.thumbnails.first().url,
|
thumbnailUrl = extractor.thumbnails.first().url,
|
||||||
// fallback to video streams if no audio streams are available
|
// fallback to video streams if no audio streams are available
|
||||||
audioStreams = audioStreams.ifEmpty {
|
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 = videoStreams
|
videoStreams = videoStreams
|
||||||
@ -117,3 +156,14 @@ actual class NewPipeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@ -16,13 +16,19 @@
|
|||||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation"
|
||||||
|
tools:targetApi="33" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
|||||||
BIN
composeApp/src/androidMain/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@ -15,14 +15,17 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube
|
package org.spotube.spotube
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
import org.spotube.spotube.core.deeplink.ExternalUriHandler
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
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.FileKit
|
||||||
import io.github.vinceglb.filekit.dialogs.init
|
import io.github.vinceglb.filekit.dialogs.init
|
||||||
|
|
||||||
@ -31,9 +34,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
FileKit.init(this)
|
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 {
|
setContent {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.dataString?.let(ExternalUriHandler::onNewUri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -15,13 +15,11 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube
|
package org.spotube.spotube
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Intent
|
import org.spotube.spotube.core.di.initKoin
|
||||||
import android.os.Build
|
import org.spotube.spotube.core.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.di.initKoin
|
|
||||||
import dev.krtirtho.spotube.media.PlaybackService
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Paths.init(this)
|
||||||
initKoin {
|
initKoin {
|
||||||
androidContext(this@MyApplication)
|
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() {
|
override fun onTerminate() {
|
||||||
@ -15,7 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube
|
package org.spotube.spotube
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@ -15,18 +15,18 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.util.Log
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import dev.krtirtho.spotube.media.PlaybackService
|
import org.spotube.spotube.media.PlaybackService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
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 val appContext: Context = (context as Context).applicationContext
|
||||||
|
|
||||||
private fun ensureServiceStarted() {
|
private fun ensureServiceStarted() {
|
||||||
val intent = Intent(appContext, PlaybackService::class.java)
|
// A plain start, never startForegroundService: that obliges the service to call
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
// startForeground() within seconds of *every* call, and once Media3 has demoted it
|
||||||
appContext.startForegroundService(intent)
|
// on pause nothing does, so the app is killed (ForegroundServiceDidNotStartInTime).
|
||||||
} else {
|
// Media3's MediaSessionService promotes itself to the foreground when playback starts.
|
||||||
appContext.startService(intent)
|
// 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 var disposed = false
|
||||||
|
|
||||||
private val currentPlaylist = mutableListOf<MediaItem>()
|
private val currentPlaylist = mutableListOf<MediaItem>()
|
||||||
|
private val originalPlaylist = mutableListOf<MediaItem>()
|
||||||
|
private var shuffleEnabled = false
|
||||||
private val urlIndexMap = mutableMapOf<String, Int>()
|
private val urlIndexMap = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
private val _playerState = MutableStateFlow(PlayerState.IDLE)
|
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) {
|
override fun onVolumeChanged(volume: Float) {
|
||||||
_volume.tryEmit(volume)
|
_volume.tryEmit(volume)
|
||||||
}
|
}
|
||||||
@ -195,7 +198,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
.setArtist(artist)
|
.setArtist(artist)
|
||||||
.setAlbumTitle(album)
|
.setAlbumTitle(album)
|
||||||
.apply {
|
.apply {
|
||||||
if (coverURL.isNotBlank()) {
|
if (coverBytes != null) {
|
||||||
|
setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||||
|
} else if (coverURL.isNotBlank()) {
|
||||||
setArtworkUri(android.net.Uri.parse(coverURL))
|
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) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
withContext(Dispatchers.Main) {
|
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)
|
exoPlayer.seekTo(targetMs)
|
||||||
_position.tryEmit(exoPlayer.currentPosition.milliseconds)
|
_position.tryEmit(exoPlayer.currentPosition.milliseconds)
|
||||||
}
|
}
|
||||||
@ -246,8 +256,37 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
actual override suspend fun shuffle(enabled: Boolean) {
|
actual override suspend fun shuffle(enabled: Boolean) {
|
||||||
withContext(Dispatchers.Main) {
|
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)
|
_shuffleMode.tryEmit(enabled)
|
||||||
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
|
syncCurrentItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,6 +298,10 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
currentPlaylist.addAll(playlist)
|
currentPlaylist.addAll(playlist)
|
||||||
|
originalPlaylist.clear()
|
||||||
|
originalPlaylist.addAll(playlist)
|
||||||
|
shuffleEnabled = false
|
||||||
|
_shuffleMode.tryEmit(false)
|
||||||
urlIndexMap.clear()
|
urlIndexMap.clear()
|
||||||
playlist.forEachIndexed { index, item ->
|
playlist.forEachIndexed { index, item ->
|
||||||
urlIndexMap[item.url] = index
|
urlIndexMap[item.url] = index
|
||||||
@ -291,6 +334,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
currentPlaylist.add(mediaItem)
|
currentPlaylist.add(mediaItem)
|
||||||
|
originalPlaylist.add(mediaItem)
|
||||||
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
exoPlayer.addMediaItem(mediaItem.toExoMediaItem())
|
exoPlayer.addMediaItem(mediaItem.toExoMediaItem())
|
||||||
@ -303,6 +347,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size)
|
val insertIndex = (currentIndex + 1).coerceAtMost(currentPlaylist.size)
|
||||||
|
|
||||||
currentPlaylist.add(insertIndex, mediaItem)
|
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()
|
urlIndexMap.clear()
|
||||||
currentPlaylist.forEachIndexed { index, item ->
|
currentPlaylist.forEachIndexed { index, item ->
|
||||||
urlIndexMap[item.url] = index
|
urlIndexMap[item.url] = index
|
||||||
@ -317,6 +363,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val index = urlIndexMap[mediaItem.url] ?: return@withContext
|
val index = urlIndexMap[mediaItem.url] ?: return@withContext
|
||||||
currentPlaylist.removeAt(index)
|
currentPlaylist.removeAt(index)
|
||||||
|
originalPlaylist.removeAll { it.url == mediaItem.url }
|
||||||
urlIndexMap.clear()
|
urlIndexMap.clear()
|
||||||
currentPlaylist.forEachIndexed { i, item ->
|
currentPlaylist.forEachIndexed { i, item ->
|
||||||
urlIndexMap[item.url] = i
|
urlIndexMap[item.url] = i
|
||||||
@ -332,6 +379,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
val item = currentPlaylist.removeAt(fromIndex)
|
val item = currentPlaylist.removeAt(fromIndex)
|
||||||
currentPlaylist.add(toIndex, item)
|
currentPlaylist.add(toIndex, item)
|
||||||
|
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) {
|
||||||
|
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex))
|
||||||
|
}
|
||||||
urlIndexMap.clear()
|
urlIndexMap.clear()
|
||||||
currentPlaylist.forEachIndexed { i, it ->
|
currentPlaylist.forEachIndexed { i, it ->
|
||||||
urlIndexMap[it.url] = i
|
urlIndexMap[it.url] = i
|
||||||
@ -384,6 +434,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
disposed = true
|
disposed = true
|
||||||
exoPlayer.release()
|
exoPlayer.release()
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
|
originalPlaylist.clear()
|
||||||
urlIndexMap.clear()
|
urlIndexMap.clear()
|
||||||
|
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
@ -15,21 +15,21 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.di
|
package org.spotube.spotube.core.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import org.spotube.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import org.spotube.spotube.core.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.share.AndroidShareService
|
import org.spotube.spotube.core.share.AndroidShareService
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import org.spotube.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.media.MediaBrowseHelper
|
import org.spotube.spotube.media.MediaBrowseHelper
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.AndroidLocalMediaDiscoveryService
|
import org.spotube.spotube.modules.library.local_tracks.media.AndroidLocalMediaDiscoveryService
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaDiscoveryService
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
actual val platformModules = module {
|
actual val platformModules = module {
|
||||||
single { Paths(get()) }
|
single { Paths() }
|
||||||
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
|
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
|
||||||
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
|
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
|
||||||
single<ShareService> { AndroidShareService(get()) }
|
single<ShareService> { AndroidShareService(get()) }
|
||||||
@ -45,7 +45,6 @@ actual val platformModules = module {
|
|||||||
collectionPlaybackHelper = get(),
|
collectionPlaybackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
settingsRepository = get(),
|
settingsRepository = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -15,12 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube
|
package org.spotube.spotube.core.discovery
|
||||||
|
|
||||||
class Greeting {
|
// The OS's own service-discovery stack advertises on the right network.
|
||||||
private val platform = getPlatform()
|
internal actual fun preferredAdvertiseAddress(): String? = null
|
||||||
|
|
||||||
fun greet(): String {
|
|
||||||
return "Hello, ${platform.name}!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@ -15,14 +15,15 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.paths
|
package org.spotube.spotube.core.paths
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
|
||||||
actual class Paths(
|
actual class Paths {
|
||||||
val context: Context
|
private val context: Context
|
||||||
) {
|
get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" }
|
||||||
|
|
||||||
actual fun getApplicationCacheDirPath(): String {
|
actual fun getApplicationCacheDirPath(): String {
|
||||||
return context.cacheDir.absolutePath
|
return context.cacheDir.absolutePath
|
||||||
}
|
}
|
||||||
@ -38,4 +39,13 @@ actual class Paths(
|
|||||||
actual fun getMusicCacheDirPath(): String {
|
actual fun getMusicCacheDirPath(): String {
|
||||||
return context.cacheDir.absolutePath + "/music_cache"
|
return context.cacheDir.absolutePath + "/music_cache"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -15,18 +15,22 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.share
|
package org.spotube.spotube.core.share
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import androidx.core.app.ShareCompat
|
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 {
|
class AndroidShareService(private val context: Context) : ShareService {
|
||||||
override fun share(url: String, title: String) {
|
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")
|
.setType("text/plain")
|
||||||
.setSubject(title)
|
.setSubject(title)
|
||||||
.setText(url)
|
.setText(url)
|
||||||
.startChooser()
|
.createChooserIntent()
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(chooser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -15,7 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package org.spotube.spotube.core.webview
|
||||||
|
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebStorage
|
import android.webkit.WebStorage
|
||||||
@ -15,7 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.zipline
|
package org.spotube.spotube.core.zipline
|
||||||
|
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
@ -15,32 +15,43 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.media
|
package org.spotube.spotube.media
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import org.spotube.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
import org.spotube.spotube.modules.album.AlbumRepository
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
import org.spotube.spotube.modules.home.HomeScreenRepository
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import org.spotube.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import org.spotube.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import org.spotube.spotube.modules.plugin.PluginManager
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
import org.spotube.spotube.modules.search.SearchRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||||
import kotlinx.coroutines.flow.first
|
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(
|
class MediaBrowseHelper(
|
||||||
private val pluginManager: PluginManager,
|
private val pluginManager: PluginManager,
|
||||||
@ -53,8 +64,7 @@ class MediaBrowseHelper(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository) {
|
||||||
) {
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MEDIA_ID_ROOT = "root"
|
const val MEDIA_ID_ROOT = "root"
|
||||||
@ -222,13 +232,13 @@ class MediaBrowseHelper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildRootChildren(): List<MediaItem> {
|
private suspend fun buildRootChildren(): List<MediaItem> {
|
||||||
return listOf(
|
return listOf(
|
||||||
MediaItem.Builder()
|
MediaItem.Builder()
|
||||||
.setMediaId(MEDIA_ID_BROWSE)
|
.setMediaId(MEDIA_ID_BROWSE)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Browse")
|
.setTitle(getString(Res.string.media_browser_browse))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
@ -238,7 +248,7 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId(MEDIA_ID_LIBRARY)
|
.setMediaId(MEDIA_ID_LIBRARY)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Library")
|
.setTitle(getString(Res.string.library_toolbar_compact_title))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
@ -248,13 +258,12 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId(MEDIA_ID_QUEUE)
|
.setMediaId(MEDIA_ID_QUEUE)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Now Playing")
|
.setTitle(getString(Res.string.media_browser_now_playing))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildBrowseItems(): List<MediaItem> {
|
private suspend fun buildBrowseItems(): List<MediaItem> {
|
||||||
@ -266,7 +275,7 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId(MEDIA_ID_BROWSE_FEATURED)
|
.setMediaId(MEDIA_ID_BROWSE_FEATURED)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Featured")
|
.setTitle(getString(Res.string.media_browser_featured))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.setExtras(gridExtras)
|
.setExtras(gridExtras)
|
||||||
@ -355,7 +364,7 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId(MEDIA_ID_SAVED_PLAYLISTS)
|
.setMediaId(MEDIA_ID_SAVED_PLAYLISTS)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Playlists")
|
.setTitle(getString(Res.string.library_tab_playlists))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
@ -365,7 +374,7 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId(MEDIA_ID_SAVED_ALBUMS)
|
.setMediaId(MEDIA_ID_SAVED_ALBUMS)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Albums")
|
.setTitle(getString(Res.string.library_tab_albums))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
@ -375,7 +384,7 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId(MEDIA_ID_SAVED_ARTISTS)
|
.setMediaId(MEDIA_ID_SAVED_ARTISTS)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Artists")
|
.setTitle(getString(Res.string.library_tab_artists))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
@ -385,13 +394,12 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId(MEDIA_ID_SAVED_TRACKS)
|
.setMediaId(MEDIA_ID_SAVED_TRACKS)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Saved Tracks")
|
.setTitle(getString(Res.string.media_browser_saved_tracks))
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
||||||
@ -460,7 +468,7 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId("$MEDIA_ID_ARTIST_TRACKS:$artistId")
|
.setMediaId("$MEDIA_ID_ARTIST_TRACKS:$artistId")
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Top Tracks")
|
.setTitle(getString(Res.string.media_browser_top_tracks))
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.build()
|
.build()
|
||||||
@ -470,13 +478,12 @@ class MediaBrowseHelper(
|
|||||||
.setMediaId("$MEDIA_ID_ARTIST_ALBUMS:$artistId")
|
.setMediaId("$MEDIA_ID_ARTIST_ALBUMS:$artistId")
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle("Albums")
|
.setTitle(getString(Res.string.library_tab_albums))
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
||||||
@ -15,7 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.media
|
package org.spotube.spotube.media
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@ -23,12 +23,14 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.media3.common.MediaItem as Media3MediaItem
|
import androidx.media3.common.MediaItem as Media3MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||||
import androidx.media3.session.LibraryResult
|
import androidx.media3.session.LibraryResult
|
||||||
import androidx.media3.session.MediaLibraryService
|
import androidx.media3.session.MediaLibraryService
|
||||||
import androidx.media3.session.MediaLibraryService.LibraryParams
|
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.Futures
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.google.common.util.concurrent.SettableFuture
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
import dev.krtirtho.spotube.MainActivity
|
import org.spotube.spotube.MainActivity
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import org.spotube.spotube.R
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import org.spotube.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@ -52,9 +55,15 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
import 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)
|
@OptIn(UnstableApi::class)
|
||||||
class PlaybackService : MediaLibraryService(), KoinComponent {
|
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||||
|
private var taskRemoved = false
|
||||||
|
|
||||||
|
|
||||||
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
|
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
|
||||||
private val audioPlayerQueue: AudioPlayerQueue by inject()
|
private val audioPlayerQueue: AudioPlayerQueue by inject()
|
||||||
@ -69,6 +78,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
|
|
||||||
createNotificationChannel()
|
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(
|
val sessionActivity = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
0,
|
||||||
@ -78,15 +97,25 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
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)
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Spotube")
|
.setContentTitle("Spotube")
|
||||||
.setContentText("Playback service")
|
.setSmallIcon(R.drawable.ic_stat_spotube)
|
||||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
||||||
.setContentIntent(sessionActivity)
|
.setContentIntent(sessionActivity)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
// 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)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "startForeground not allowed; continuing without notification", e)
|
||||||
|
}
|
||||||
|
|
||||||
librarySession =
|
librarySession =
|
||||||
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
||||||
@ -98,19 +127,32 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
Log.i(TAG, "Library session created")
|
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")
|
@SuppressLint("ObsoleteSdkInt")
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val channel = NotificationChannel(
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
CHANNEL_ID,
|
fun channel(name: String, description: String? = null) =
|
||||||
"Playback",
|
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
NotificationManager.IMPORTANCE_LOW
|
this.description = description
|
||||||
).apply {
|
|
||||||
description = "Media playback controls"
|
|
||||||
setShowBadge(false)
|
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)
|
removeSession(librarySession)
|
||||||
librarySession.release()
|
librarySession.release()
|
||||||
super.onDestroy()
|
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?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
taskRemoved = true
|
||||||
pauseAllPlayersAndStopSelf()
|
pauseAllPlayersAndStopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,20 +400,20 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
when {
|
when {
|
||||||
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") -> {
|
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_PLAYLIST}:") -> {
|
||||||
val playlistId = mediaId.removePrefix("${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)
|
helper.playPlaylist(playlistId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> {
|
mediaId.startsWith("${MediaBrowseHelper.MEDIA_ID_ALBUM}:") -> {
|
||||||
val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:")
|
val albumId = mediaId.removePrefix("${MediaBrowseHelper.MEDIA_ID_ALBUM}:")
|
||||||
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject()
|
||||||
helper.playAlbum(albumId)
|
helper.playAlbum(albumId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> {
|
mediaId == MediaBrowseHelper.MEDIA_ID_SAVED_TRACKS -> {
|
||||||
val helper: dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper by inject()
|
val helper: org.spotube.spotube.core.playback.CollectionPlaybackHelper by inject()
|
||||||
helper.playSavedTracks()
|
helper.playSavedTracks()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -15,19 +15,13 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.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 {
|
internal actual fun addToMediaLibrary(path: String) {
|
||||||
val searchMode = MutableStateFlow(false)
|
val context = GlobalContext.get().get<Context>()
|
||||||
val currentTab = MutableStateFlow(LibraryTab.Playlists)
|
MediaScannerConnection.scanFile(context, arrayOf(path), null, null)
|
||||||
|
|
||||||
fun onTabSelected(tab: LibraryTab) {
|
|
||||||
currentTab.value = tab
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSearchMode(enabled: Boolean) {
|
|
||||||
searchMode.value = enabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -15,7 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.library.local_tracks.media
|
package org.spotube.spotube.modules.library.local_tracks.media
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@ -25,25 +25,60 @@ import android.os.Build
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.provider.MediaStore
|
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 org.koin.core.component.KoinComponent
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AndroidLocalMediaDiscoveryService(
|
class AndroidLocalMediaDiscoveryService(
|
||||||
private val context: Context,
|
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
||||||
) : LocalMediaDiscoveryService, KoinComponent {
|
|
||||||
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
||||||
|
|
||||||
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
private class TagInfo(
|
||||||
|
val title: String?,
|
||||||
|
val artists: List<String>,
|
||||||
|
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<String>,
|
||||||
|
val mediaStoreAlbum: String?,
|
||||||
|
val mediaStoreDurationMs: Long)
|
||||||
|
|
||||||
|
// Parsing tags + cover with lofty is the expensive part, so keep results across rescans.
|
||||||
|
private val tagCache = ConcurrentHashMap<String, TagInfo>()
|
||||||
|
|
||||||
|
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> = withContext(Dispatchers.IO) {
|
||||||
|
discoverFoldersBlocking(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun discoverFoldersBlocking(roots: List<String>): List<LocalMediaFolder> {
|
||||||
logger.i { "discoverFolders: start roots=${roots.size}" }
|
logger.i { "discoverFolders: start roots=${roots.size}" }
|
||||||
if (!hasReadPermission()) {
|
if (!hasReadPermission()) {
|
||||||
logger.w { "discoverFolders: missing media permission, returning empty result" }
|
logger.w { "discoverFolders: missing media permission, returning empty result" }
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>()
|
val pending = mutableListOf<PendingTrack>()
|
||||||
|
val knownFilePaths = hashSetOf<String>()
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
var totalRows = 0
|
var totalRows = 0
|
||||||
@ -60,7 +95,8 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
MediaStore.Audio.Media.ALBUM,
|
MediaStore.Audio.Media.ALBUM,
|
||||||
MediaStore.Audio.Media.DURATION,
|
MediaStore.Audio.Media.DURATION,
|
||||||
MediaStore.Audio.Media.MIME_TYPE,
|
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)
|
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||||
} else {
|
} else {
|
||||||
emptyArray()
|
emptyArray()
|
||||||
@ -79,6 +115,8 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||||
val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE)
|
val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE)
|
||||||
val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH)
|
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 {
|
logger.d {
|
||||||
"discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)"
|
"discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)"
|
||||||
@ -120,21 +158,23 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
.takeUnless { it.equals("<unknown>", true) }
|
.takeUnless { it.equals("<unknown>", true) }
|
||||||
val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() }
|
val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() }
|
||||||
val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L)
|
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 {
|
val name = rawTitle.ifBlank {
|
||||||
displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName
|
displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
val track = LocalMediaTrack(
|
pending += PendingTrack(
|
||||||
path = uri,
|
uri = uri,
|
||||||
name = name.ifBlank { "Unknown track" },
|
filePath = filePath,
|
||||||
artists = listOfNotNull(artist),
|
modifiedAt = modifiedAt,
|
||||||
durationMs = durationMs,
|
folderPath = folderPath,
|
||||||
album = album,
|
fallbackName = name.ifBlank { "Unknown track" },
|
||||||
coverBytes = null,
|
mediaStoreArtists = listOfNotNull(artist),
|
||||||
)
|
mediaStoreAlbum = album,
|
||||||
|
mediaStoreDurationMs = durationMs)
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
|
||||||
acceptedRows += 1
|
acceptedRows += 1
|
||||||
}
|
}
|
||||||
} ?: run {
|
} ?: run {
|
||||||
@ -145,21 +185,13 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
logger.w { "discoverFolders: MediaStore query returned null cursor" }
|
logger.w { "discoverFolders: MediaStore query returned null cursor" }
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaStoreFolders = tracksByFolder.entries
|
// Files MediaStore already indexed must not be listed a second time by the filesystem scan.
|
||||||
.map { (folderPath, tracks) ->
|
discoverFileSystemFallback(roots, knownFilePaths, pending)
|
||||||
LocalMediaFolder(
|
|
||||||
path = folderPath,
|
|
||||||
name = normalizeFolderName(folderPath),
|
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
|
||||||
|
|
||||||
val fallbackFolders = discoverFileSystemFallback(roots)
|
val merged = buildFolders(pending)
|
||||||
val merged = mergeFolders(mediaStoreFolders, fallbackFolders)
|
|
||||||
|
|
||||||
logger.i {
|
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()) {
|
if (merged.isNotEmpty()) {
|
||||||
@ -172,8 +204,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
|
|
||||||
override fun observeChanges(
|
override fun observeChanges(
|
||||||
roots: List<String>,
|
roots: List<String>,
|
||||||
onChanged: LocalMediaChangeCallback,
|
onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
|
||||||
): LocalMediaObservation? {
|
|
||||||
logger.i { "observeChanges: start roots=${roots.size}" }
|
logger.i { "observeChanges: start roots=${roots.size}" }
|
||||||
if (!hasReadPermission()) {
|
if (!hasReadPermission()) {
|
||||||
logger.w { "observeChanges: missing media permission, observer not registered" }
|
logger.w { "observeChanges: missing media permission, observer not registered" }
|
||||||
@ -190,8 +221,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
context.contentResolver.registerContentObserver(
|
context.contentResolver.registerContentObserver(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||||
true,
|
true,
|
||||||
observer,
|
observer)
|
||||||
)
|
|
||||||
|
|
||||||
logger.i { "observeChanges: observer registered" }
|
logger.i { "observeChanges: observer registered" }
|
||||||
|
|
||||||
@ -227,58 +257,109 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
return extension in SUPPORTED_EXTENSIONS
|
return extension in SUPPORTED_EXTENSIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun discoverFileSystemFallback(roots: List<String>): List<LocalMediaFolder> {
|
private fun discoverFileSystemFallback(
|
||||||
|
roots: List<String>,
|
||||||
|
knownFilePaths: Set<String>,
|
||||||
|
out: MutableList<PendingTrack>) {
|
||||||
val candidateRoots = resolveCandidateRoots(roots)
|
val candidateRoots = resolveCandidateRoots(roots)
|
||||||
if (candidateRoots.isEmpty()) {
|
if (candidateRoots.isEmpty()) {
|
||||||
logger.d { "discoverFileSystemFallback: no candidate roots" }
|
logger.d { "discoverFileSystemFallback: no candidate roots" }
|
||||||
return emptyList()
|
return
|
||||||
}
|
}
|
||||||
|
val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/"
|
||||||
logger.d {
|
val seen = hashSetOf<String>()
|
||||||
"discoverFileSystemFallback: scanning roots=${candidateRoots.joinToString()}"
|
var added = 0
|
||||||
}
|
|
||||||
|
|
||||||
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>()
|
|
||||||
var scannedFiles = 0
|
|
||||||
var acceptedFiles = 0
|
|
||||||
|
|
||||||
candidateRoots.forEach { root ->
|
candidateRoots.forEach { root ->
|
||||||
scanDirectory(root) { file ->
|
scanDirectory(root) { file ->
|
||||||
scannedFiles += 1
|
if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory
|
||||||
val extension = file.extension.lowercase(Locale.getDefault())
|
val absolute = file.absolutePath
|
||||||
if (extension !in SUPPORTED_EXTENSIONS) return@scanDirectory
|
if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory
|
||||||
acceptedFiles += 1
|
|
||||||
|
|
||||||
val folderPath = file.parentFile?.absolutePath ?: "Unknown"
|
// Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge.
|
||||||
val name = file.nameWithoutExtension.ifBlank { file.name }
|
val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/"
|
||||||
val track = LocalMediaTrack(
|
val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent
|
||||||
path = file.absolutePath,
|
out += PendingTrack(
|
||||||
name = name,
|
uri = absolute,
|
||||||
artists = emptyList(),
|
filePath = absolute,
|
||||||
durationMs = 0L,
|
modifiedAt = file.lastModified() / 1000,
|
||||||
album = null,
|
folderPath = folderPath,
|
||||||
coverBytes = null,
|
fallbackName = file.nameWithoutExtension.ifBlank { file.name },
|
||||||
)
|
mediaStoreArtists = emptyList(),
|
||||||
|
mediaStoreAlbum = null,
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
mediaStoreDurationMs = 0L)
|
||||||
|
added += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" }
|
||||||
|
}
|
||||||
|
|
||||||
val folders = tracksByFolder.entries
|
private suspend fun buildFolders(pending: List<PendingTrack>): List<LocalMediaFolder> {
|
||||||
.map { (folderPath, tracks) ->
|
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(
|
LocalMediaFolder(
|
||||||
path = folderPath,
|
path = path,
|
||||||
name = normalizeFolderName(folderPath),
|
name = normalizeFolderName(path),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
|
|
||||||
logger.i {
|
|
||||||
"discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return folders
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<String>): List<File> {
|
private fun resolveCandidateRoots(roots: List<String>): List<File> {
|
||||||
@ -292,8 +373,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
|
||||||
)
|
|
||||||
|
|
||||||
return (configured + defaults)
|
return (configured + defaults)
|
||||||
.map { it.absoluteFile }
|
.map { it.absoluteFile }
|
||||||
@ -318,37 +398,8 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeFolders(
|
|
||||||
mediaStoreFolders: List<LocalMediaFolder>,
|
|
||||||
fallbackFolders: List<LocalMediaFolder>,
|
|
||||||
): List<LocalMediaFolder> {
|
|
||||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
|
||||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
|
||||||
|
|
||||||
val merged = linkedMapOf<String, MutableList<LocalMediaTrack>>()
|
|
||||||
val seenTrackPaths = mutableSetOf<String>()
|
|
||||||
|
|
||||||
(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 {
|
companion object {
|
||||||
|
private const val THUMBNAIL_SIZE = 160
|
||||||
private val SUPPORTED_EXTENSIONS = setOf(
|
private val SUPPORTED_EXTENSIONS = setOf(
|
||||||
"mp3",
|
"mp3",
|
||||||
"m4a",
|
"m4a",
|
||||||
@ -357,7 +408,6 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
"wav",
|
"wav",
|
||||||
"ogg",
|
"ogg",
|
||||||
"opus",
|
"opus",
|
||||||
"wma",
|
"wma")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -15,7 +15,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.library.local_tracks.media
|
package org.spotube.spotube.modules.library.local_tracks.media
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
|
|||||||
|
|
||||||
var isGranted by remember {
|
var isGranted by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED,
|
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
) { granted ->
|
|
||||||
isGranted = granted ||
|
isGranted = granted ||
|
||||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
return LocalMediaPermissionState(
|
return LocalMediaPermissionState(
|
||||||
isGranted = isGranted,
|
isGranted = isGranted,
|
||||||
requestPermission = { launcher.launch(permission) },
|
requestPermission = { launcher.launch(permission) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||