Compare commits

...

143 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
9a47a1af5f
Merge branch 'dev' into fix/desugar-jdk-libs-nio 2026-11-03 13:42:50 +06:00
Kingkor Roy Tirtho
431fd2353f
Merge pull request #3204 from team-spotube/fix/large-list-memory
fix: memory blow-ups and crashes with large lists and local media
2026-11-03 13:15:19 +06:00
Kingkor Roy Tirtho
0bf0e1816c
Merge pull request #3203 from team-spotube/fix/linux-string-lookup-segfault
fix(desktop): stop non-composable string lookups from querying the system theme
2026-11-02 15:26:26 +06:00
Kingkor Roy Tirtho
f23c66c0c5 build: raise the desktop heap ceiling to 1 GB
384 MB left little headroom for large libraries and long sessions. It is
a ceiling, not a reservation: the serial GC only grows the heap to what
is live.
2026-11-02 14:20:10 +06:00
Kingkor Roy Tirtho
8537718225 fix: stop memory blow-ups and crashes with large lists and local media
TrackList rendered every loaded track inside one LazyColumn item, so all
rows stayed composed and recomposed together; scrolling ~1350 saved
tracks exhausted the desktop heap (#3200). Rows are now separate lazy
items and the card is drawn once behind them. Only the current row
depends on the playing state, the saved tracks screen no longer reloads
from page 1 whenever a page arrives, cached tracks are streamed in 64 KB
chunks instead of read whole, and queue loading/restoring maps entries
in one pass instead of quadratically.

Desktop kept full-size embedded covers for local files and serialized
them as JSON number arrays into the local media index and the queue,
which is persisted on every change (#3193). Covers are now shrunk to
160 px like on Android, the queue no longer persists them (restore fills
them in from the index), and store files already bloated past 8 MB are
dropped instead of loaded.

Lists keyed on plugin ids crashed on blank or repeated ids (#3196); they
now go through uniqueLazyKeys. Queue persistence failures are logged
instead of crashing the app, a likely cause of #3198.

Fixes #3200
Fixes #3193
Fixes #3196
2026-11-02 13:37:05 +06:00
Kingkor Roy Tirtho
1b089a5fec fix(desktop): stop non-composable string lookups from querying the system theme
Compose Resources' getString/getPluralString/getStringArray call
getSystemResourceEnvironment() on every lookup, which on desktop asks
Skiko for the system theme. On Linux that is a blocking D-Bus call to the
desktop portal on the calling thread, and from a background coroutine it
segfaulted the GraalVM AppImage on Fedora GNOME when installing a plugin
from the Welcome screen.

Strings never depend on the theme, so app code now uses wrappers in
core/i18n that reuse the resource environment the UI already computed
(published by AppLocaleProvider), falling back to the library lookup
before anything is composed. A test guards against importing the library
getters again.

Fixes #3183
2026-11-02 10:17:35 +06:00
Kingkor Roy Tirtho
0541fd9d23
Merge pull request #3197 from team-spotube/feat/jam-room-server
feat(jam): synced Group Jam on a Durable Object room server
2026-11-02 09:31:20 +06:00
Kingkor Roy Tirtho
ac5971846b fix(jam): keep guests on the host's recording across song changes
- The host sends its source for the current song and the next two with
  every anchor, so guests switch to the same recording before the song
  starts. Guests drop the cached audio and stream URL for a re-pinned
  song and reload it.
- Pins are retried once the room's queue arrives, instead of being lost
  when a guest joins before the queue.
- A guest that finishes a song a little early waits for the host instead
  of jumping back to the old song, and guests don't start endless
  playback on their own.
- Position tracking restarts on every song change, ignoring stale
  reports from the previous song.
- The device queue keeps its queue and current-entry flows live all the
  time. With WhileSubscribed they went stale when nothing on screen
  watched them, and the stream proxy answered 404 for queued tracks.
- Simulation tests run on the real device queue, cover recording
  switches, early song ends, idle CPU use and a long soak session.
2026-10-31 23:03:10 +06:00
Kingkor Roy Tirtho
aaa4d66db1 refactor(jam): move the room server to its own repository
The jam room server now lives in https://github.com/team-spotube/jam-server.
Remove jam-server/ and its deploy workflow, and point code comments, test
instructions, AGENTS.md and the self-hosting note (all locales) at the new
repository.
2026-10-31 19:01:44 +06:00
Kingkor Roy Tirtho
45a7758eb0 feat(jam): harden the room server, add device bans and friendly names
Security:
- Participants connect with a per-room key derived from a random per-install
  secret; others only see an id derived from it, so nobody can reconnect as
  someone else (including the host).
- "create" only works on a fresh code or for the room's own host, so empty
  rooms can't be taken over.
- Per-socket message and queue budgets disconnect clients that flood; the
  host throttles itself well under them.
- Bans also cover the device: a hashed digest of device traits combined with
  the network, stored only as an opaque per-room value. Earlier members are
  never caught by someone else's ban.
- Peer queue items and suggestions are sanitized (streaming tracks only, no
  URLs, https covers); suggestions are credited to the server-attested sender;
  local file paths go out as opaque ids. Names are cleaned server-side.
- Room codes and keys use a secure random source; the deploy workflow is
  read-only.

Names:
- Participants get a stable generated name ("Mellow Otter") per install, and
  can pick their own on the Jam screen or in Jam settings. The participant
  list marks "(you)".

Also translate the Jam strings into all locales and add tests for the above.
2026-10-31 18:14:45 +06:00
Kingkor Roy Tirtho
0a0b0c9374 feat(jam): replace MQTT with a Durable Object room server and synced playback
- Add jam-server/: a Cloudflare Worker with one Durable Object per room code.
  It tracks membership, host handoff and kicks/bans, keeps the queue for late
  joiners, routes guest commands to the host and serves the room clock.
- Sync playback: devices estimate the server clock, the host sends position
  anchors in server time, guests follow by nudging playback rate (seeking only
  when far off) and stream the host's audio source when they share a plugin.
- Seek, play/pause, skip and jump apply to the whole room.
- Recover from network loss instead of crashing: socket failures reconnect
  with backoff, silent sockets are detected, and an expired room is recreated
  by its host.
- Keep cost and battery low: anchors only on change (plus host drift and a
  60 s heartbeat), kept in memory rather than storage, free runtime keep-alives
  once a minute, rare clock re-syncs, and no polling while paused or buffering.
- Remove the MQTT broker settings and libraries.
- Add sync unit tests, client/session integration tests (JAM_TEST_URL) and a
  network-loss test, plus a deploy workflow for the server.
2026-10-31 16:26:41 +06:00
Kingkor Roy Tirtho
b12e98a8c5 fix(flatpak): grant the Wayland socket so the webview doesn't render black 2026-10-30 22:43:39 +06:00
Kingkor Roy Tirtho
35700920b4 docs: improve formatting and clarity in the documentation 2026-10-30 16:10:00 +06:00
Kingkor Roy Tirtho
e04b3414e0 chore(assets): refresh screenshots, store graphics and Steam hero
- Add new desktop and iOS screenshots under assets/screenshots, named by screen, theme and form factor, and optimized with pngquant + oxipng
- Add feature graphics under assets/feature-graphics: Play Store feature graphic, README hero and mobile strip, and phone/desktop store shots
- Use them in the README, Flathub metainfo (plain screenshots) and F-Droid metadata (phone screenshots + featureGraphic)
- Replace the Steam library hero with one made from the new UI
- Remove the old desktop and mobile screenshots
2026-10-30 14:43:47 +06:00
Kingkor Roy Tirtho
9f4ce11509 fix(player): smooth the expanded player swipe
Read sheet progress in draw/derived scopes instead of composition so a
drag no longer recomposes the player every frame, keep the expanded
player, floating player and bottom bar composed once shown instead of
building them mid-drag, and settle with a spring that carries fling
velocity. Add a drag test for CompactPlayerOverlay.
2026-10-30 14:16:51 +06:00
Kingkor Roy Tirtho
bba725a139 fix(android): fully close the app on swipe-away and avoid duplicate cookie DataStore
Initialize NewPipeDownloader once per process so recreating the Activity
no longer opens a second DataStore on spotube_cookies.preferences_pb.
Kill the process after PlaybackService shuts down on task removal so the
proxy server, MQTT client and singletons don't linger in the background.
2026-10-30 13:35:39 +06:00
Kingkor Roy Tirtho
c058074cff fix(plugin): update default selected plugins to use built-in plugins with distinct abilities 2026-10-30 13:08:34 +06:00
Kingkor Roy Tirtho
2aaa0db81e fix(broker): update default broker settings and improve connection error handling 2026-10-30 11:50:39 +06:00
Kingkor Roy Tirtho
14bc279653 fix(ios): crashing on search screen 2026-10-30 11:10:25 +06:00
Kingkor Roy Tirtho
2c1148a126 chore: remove devil's name 2026-10-30 10:33:11 +06:00
Kingkor Roy Tirtho
fa9b42cf10 cd(build): add support for building WebView native libraries on Linux and Windows 2026-10-29 16:03:55 +06:00
Kingkor Roy Tirtho
8d4ea82a7e fix(build): add headless mode for Linux and update CI to prepare dependencies 2026-10-29 15:01:43 +06:00
Kingkor Roy Tirtho
eb972c8633 fix(dependencies): update Android target SDK and various library versions 2026-10-29 11:25:22 +06:00
Kingkor Roy Tirtho
aca3aeb592 fix(theme): follow the OS dark mode on desktop
Compose's isSystemInDarkTheme never updates under the Tao window, so Theme.SYSTEM
stayed light. Use Nucleus' darkmode-detector on JVM and keep Compose's check on mobile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:53:47 +06:00
Kingkor Roy Tirtho
5b36edd259 fix(windows): make VLC playback work through the stream proxy
The proxy copied every upstream header, duplicating Content-Length, which VLC
rejects. Forward only the range/validator headers. Bump vlcj-bundler to 0.2.1,
which bundles the Matroska demuxer needed for WebM. Make StreamingUrlRepository's
platform injectable and add VLC log switch via DebugConfig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
ea01dec6ea fix(settings): palette swatches follow the active light/dark appearance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
79266fd483 fix(windows): window not spawning due to early COM initialization 2026-10-27 16:35:27 +06:00
Kingkor Roy Tirtho
31a3233c79 ci: remove nightly tag movement from release workflow 2026-10-26 18:11:35 +06:00
Kingkor Roy Tirtho
91867f571d ci: remove nightly tag movement from release workflow 2026-10-26 17:39:24 +06:00
Kingkor Roy Tirtho
cde359a9de cd: add iOS build process and package IPA for release 2026-10-26 16:52:56 +06:00
Kingkor Roy Tirtho
1b4fbe8d25 chore: update changelog for 6.0.0-beta.0 and optimize Gradle build settings 2026-10-26 16:40:46 +06:00
Kingkor Roy Tirtho
67e452493a feat(release): add beta release type
A beta is the stable build released as <version>-beta.N, with N set via
betaNumber in composeApp/build.gradle.kts. Betas and stable releases are
drafts; only nightly publishes immediately.
2026-10-26 16:34:02 +06:00
Kingkor Roy Tirtho
d7c7e3108d docs: add new features and enhancements for version 6.0.0 2026-10-26 16:09:24 +06:00
Kingkor Roy Tirtho
1ea5f1d1ee fix: update website URLs from spotube.org to spotube.cc 2026-10-26 15:31:17 +06:00
Kingkor Roy Tirtho
9bb80ebb47 Merge branch 'master' into dev 2026-10-26 15:23:11 +06:00
Kingkor Roy Tirtho
a7e47d0e00
Merge pull request #3165 from team-spotube/ios-crash-fix
Ios crash fix
2026-10-26 15:16:29 +06:00
Kingkor Roy Tirtho
bdf3655a23 fix(ios): make the login WebView identify as Mobile Safari
accounts.spotify.com rejected iOS logins ("Oops, something went wrong") because
the WebView sent WKWebView's default User-Agent, which lacks Safari's
"Version/x ... Safari/604.1" and reads as an embedded browser. The random
User-Agent header never reached any request: the screen replaces the content
before it loads.

Have WebKit build Safari's exact UA (with its frozen OS version) via a probe
WKWebView using Safari's applicationNameForUserAgent, prime it at startup and
apply it through webSettings.customUserAgentString so every request uses it.
2026-10-26 15:04:26 +06:00
Kingkor Roy Tirtho
cc21568457 fix(build): restore version catalog lost in dev merge
The merge of dev resolved the libs.versions.toml conflict by taking the wrong
catalog: it reverted the Kotlin 2.4.21 / Compose 1.12.2 / Zipline 1.28.0
upgrade and dropped dev's jmustache entries, so buildSrc failed with
"Unresolved reference 'jmustache'". Keep both sides: mokkery 3.5.1 plus jmustache.
2026-10-26 15:04:26 +06:00
Kingkor Roy Tirtho
7fa3b029f6 Merge branch 'dev' into ios-crash-fix
# Conflicts:
#	gradle/libs.versions.toml
2026-10-26 13:45:32 +06:00
Kingkor Roy Tirtho
cb8316d141 fix(ios): stream formats AVPlayer can play
AVPlayer can't open WebM/Ogg or DASH, so the default opus/webm stream failed
silently on iOS.

- Rank streams the platform's player can play above format/quality prefs
- Default iOS streaming to aac/mp4, migrate saved WebM/Ogg prefs on iOS and
  hide unplayable formats in the picker (desktop/Android keep opus/webm)
- Normalize codec/container names on iOS like androidJvm (mp4a -> aac, m4a -> mp4)
- Wait up to 30s for the audio plugin to load when resolving, so the queue
  restored at startup isn't failed permanently before plugins are ready
2026-10-26 13:40:00 +06:00
Kingkor Roy Tirtho
99ba08501b fix(server): don't crash when the proxy port is still in use
CIO reports a failed bind from its accept coroutine; unhandled, that
exception terminates the process on Kotlin/Native before the port fallback
can run, so relaunching iOS shortly after playback crashed (EADDRINUSE).
Contain it with a parent CoroutineExceptionHandler and enable reuseAddress
so the preferred port can be rebound while old connections are in TIME_WAIT.
2026-10-26 13:40:00 +06:00
Kingkor Roy Tirtho
0acc457890 feat(plugin): implement automatic plugin updates and enhance JSON parsing 2026-10-26 12:44:04 +06:00
Kingkor Roy Tirtho
7eb56c8593 fix(ios): run Zipline on an 8 MiB stack thread
newSingleThreadContext threads get only 512 KiB of stack on iOS, while
QuickJS may recurse ~6 MiB deep when compiling a plugin, so the app crashed
with SIGBUS at startup once a plugin was installed. Host the dispatcher on an
NSThread with an 8 MiB stack, matching JVM/Android.
2026-10-23 11:54:29 +06:00
Kingkor Roy Tirtho
ff7a1714ff refactor(network): central HttpClientFactory; drop Ktor CIO client
On iOS the CIO client engine competed with Darwin as the default engine and
has no TLS on Kotlin/Native, so HTTPS requests (e.g. plugin discovery) failed.

Remove ktor-client-cio and build every client through a Koin-provided
HttpClientFactory that owns one client on the platform default engine
(Darwin on iOS, OkHttp on Android/desktop). Consumers use the shared
default/json clients or derive their own via create { }, sharing the engine.
2026-10-23 11:54:29 +06:00
Kingkor Roy Tirtho
09df4b494c build: upgrade to Kotlin 2.4.21 and Compose Multiplatform 1.12.2
Several dependencies (coil 3.5.0, millennium, mqtt/ditchoom, composeunstyled,
markdown-renderer, composewebview, kmp-zip, filekit) now ship klibs built with
Kotlin 2.4, which Kotlin/Native 2.3 refuses to read, breaking the iOS build.

- Bump zipline (1.28.0), mokkery (3.5.1) and ksp (2.3.12) for the new compiler
- Type TOP_LEVEL_ROUTES as Set<NavKey> (Kotlin 2.4 inferred Set<Any>)
- Import ComposeApp in iOSApp.swift, which uses ExternalUriHandler
2026-10-23 11:54:29 +06:00
Kingkor Roy Tirtho
2edbfefe93 fix(gradle): restore VFS watch setting and clean up properties 2026-10-23 10:12:10 +06:00
Kingkor Roy Tirtho
8ab9463c9a feat(packaging): Flathub, AUR, WinGet and Chocolatey publishing
- Flatpak manifest (GNOME 51, libvlc built from source) installing from a new
  portable spotube-linux-<version>-<arch>.tar.xz, shared with the AUR package
- Release workflow builds and attaches the Linux tarballs for x86_64 and aarch64
- Package files are JMustache templates rendered by :composeApp:renderPackaging<Target>
  with the version, release asset checksums and git-cliff release notes; the
  metainfo release history is generated
- Publish workflow (workflow_dispatch, release tag input) renders once and pushes to
  Flathub, the AUR, WinGet (PR from team-spotube/winget-pkgs) and Chocolatey
- Chocolatey and WinGet updated for the NSIS installer
2026-10-23 10:08:29 +06:00
Kingkor Roy Tirtho
79d47c1d87 feat: enhance loading states for various UI components and update dependencies 2026-10-20 20:32:47 +06:00
Kingkor Roy Tirtho
08892c2aa0 fix(downloads): respect download format/quality and never save manifests or video
- Downloads resolve streams with the user's download format/quality via a
  new StreamRequest instead of the streaming settings, and bypass the
  playback stream-URL cache in both directions
- Downloads only consider progressive streams; DASH/HLS manifests were
  previously saved as-is instead of audio
- NewPipe: map AAC "mp4a.40.x"/"m4a" to the advertised "aac"/"mp4" so real
  AAC streams can match (previously the hard-coded aac/mp4 manifests always
  won), report each stream's real delivery method, skip streams without a
  format or non-URL content
- Fail the download on non-2xx responses, on a range request answered with
  a full 200, and on video/* content, with localized reasons, instead of
  writing the response body to disk
- Save AAC as .m4a
- Tests for request overrides, manifest exclusion and cache isolation
2026-10-20 16:03:48 +06:00
Kingkor Roy Tirtho
8bbefdfb7d fix(android): app stuck on splash screen from main-thread string loading
PlaybackService.onCreate runs on the main thread and called
runBlocking { getString(...) } for its notification strings. Compose
resources share one in-flight load per strings file (AsyncCache creates the
Deferred in the caller's scope); when App()'s stringResource started that
load on the main dispatcher first, blocking main to await it deadlocked and
the first frame never rendered. Timing-dependent, so mostly seen under the
debugger.

Create the notification channel immediately under the app name and update
its localized name/description from serviceScope. The short-lived
placeholder notification no longer carries localized text.
2026-10-20 16:03:48 +06:00
Kingkor Roy Tirtho
d4961bb5ed feat: download diagnostics, live connect status, remote settings on devices screen
Downloads
- Show the real download folder: "Current folder: <path> (default)" in
  settings instead of the misleading "default music folder", and a
  "Saved to <path>" line on the Downloads screen
- Register finished downloads with Android's MediaStore (MediaScanner) so
  music players and the Files app see them immediately
- Add tagged logs across the download path to diagnose format/quality
  selection: raw NewPipe streams ([Streams]), every selection candidate
  with its rank key and fallback warnings ([Resolve]), and per-download
  enqueue prefs, resolved stream, HEAD/GET status + content type, segment
  responses and written vs expected bytes ([Download])

Connect status
- Mobile Home keeps its Devices / Group Jam / Support icons (Jam now uses
  the sound icon) and shows a slim banner only while remote-controlling a
  device or in a Jam ("Playing on krtx", "Hosting · 3 listeners")
- Sidebar Devices / Group Jam rows get a live dot and status line
- New ConnectStatusViewModel feeds both

Remote control settings
- Extract "Allow remote control" and "Device name" into shared
  remoteControlSettingCards, used by Playback settings and a new
  "This device" section on the Devices screen
2026-10-20 15:54:23 +06:00
Kingkor Roy Tirtho
b38a4f6963 feat(remote): redesign remote control and devices screens, fix connection issues
- Remove the unused "Enable Connect" setting and its strings
- Redesign RemoteControlScreen to match the expanded player: artwork sized
  to fit, side-by-side queue panel on wide screens, proper connecting /
  disconnected / error states, "Playing on <device>" subtitle
- Seek and volume sliders keep local drag state and only commit on release
  (volume throttled while dragging) instead of sending a command per frame
- Redesign DevicesScreen with the settings card components, a connected
  device card, error banner and empty state; ViewModels expose one UiState
- Fix tapping an already-connected device doing nothing; reopen its remote
  screen instead, and switch devices by disconnecting first
- Fix the controller getting stuck in Connecting after the host denies a
  request: Ktor never delivers Close frames to `incoming`, so detect the
  close reason after the receive loop ends
- Fix a UI freeze when leaving the devices screen on desktop: run discovery
  (and JmDNS's blocking close/resolve) on Dispatchers.IO
- Advertise the LAN address of the default-route interface on desktop;
  dns-sd-kt otherwise picks any site-local address, e.g. a Docker bridge
2026-10-20 15:11:10 +06:00
Kingkor Roy Tirtho
facc8ed0b9 fix(android): update playback state management to ensure guests follow host's play state 2026-10-20 14:28:18 +06:00
Kingkor Roy Tirtho
0b21eff4c7 fix(android): simplify service start logic for PlaybackService to avoid crashes on background playback 2026-10-20 13:44:30 +06:00
Kingkor Roy Tirtho
9d5854023b fix(android): update sharing functionality to use chooser intent with new task flag 2026-10-20 13:19:04 +06:00
Kingkor Roy Tirtho
38e9bbc1d8 fix(android): create intermediate directories for Rust targets to prevent runtime errors 2026-10-20 13:00:29 +06:00
Kingkor Roy Tirtho
64fb23e62b
Merge pull request #3152 from team-spotube/macos-dmg-fixes
fix(macos): add GraalVM runtime dependency and initialize GraalVm in …
2026-10-19 19:19:00 +06:00
Kingkor Roy Tirtho
64a78727ce
Update gradle/libs.versions.toml 2026-10-19 19:18:25 +06:00
Kingkor Roy Tirtho
046f724c9b
Merge branch 'dev' into macos-dmg-fixes 2026-10-19 19:15:56 +06:00
Kingkor Roy Tirtho
e92e62613e chore: implement Linux AppImage bundling for GTK and WebKitGTK, update dependencies and documentation 2026-10-19 19:15:13 +06:00
Kingkor Roy Tirtho
7ee3c70c96 fix(macos): add GraalVM runtime dependency and initialize GraalVm in main function 2026-10-19 18:50:27 +06:00
Kingkor Roy Tirtho
069b586d44 chore: update release workflow and documentation for dependency management 2026-10-19 11:50:53 +06:00
Kingkor Roy Tirtho
ced26462a9 chore: update GitHub Actions workflow for release process and improve permissions 2026-10-19 11:16:29 +06:00
Kingkor Roy Tirtho
d4d8ebd853 chore: configure UniFFI to generate bindings from host library on Linux arm64 2026-10-19 10:34:46 +06:00
Kingkor Roy Tirtho
2f7eb742cf chore: disable Android Rust build tasks on Linux arm64 hosts 2026-10-18 23:49:34 +06:00
Kingkor Roy Tirtho
b333c299d1 chore: update Android SDK setup to include NDK for composeApp configuration 2026-10-18 22:58:57 +06:00
Kingkor Roy Tirtho
613159e26e chore: add --stacktrace option to gradlew command for better debugging 2026-10-18 20:47:51 +06:00
Kingkor Roy Tirtho
33a8fdb8c2 cd: add GitHub token for API calls and set up Android SDK in workflow 2026-10-18 20:33:51 +06:00
Kingkor Roy Tirtho
03aaffeca8 chore: update spotube gradle plugin version 2026-10-18 20:07:20 +06:00
Kingkor Roy Tirtho
1233967982 chore: update contact information and library version in project files 2026-10-18 19:23:18 +06:00
Kingkor Roy Tirtho
17f2068950 feat(navigation): implement fade-through and stack transition animations for navigation 2026-10-18 18:31:51 +06:00
Kingkor Roy Tirtho
75ced1285e feat(local-tracks): implement LocalFolder screen and view model for folder navigation 2026-10-18 18:04:53 +06:00
Kingkor Roy Tirtho
c767b8dcd6 chore: update GitHub Actions to use latest versions of checkout, setup-java, setup-gradle, and upload-artifact 2026-10-17 19:21:42 +06:00
Kingkor Roy Tirtho
aa83708499 feat(graalvm): enable GraalVM native image support and update packaging configurations 2026-10-17 17:01:40 +06:00
Kingkor Roy Tirtho
62c1fe7902 feat: enhance Android build configuration with stable/nightly flavors and update launcher icons 2026-10-16 21:34:44 +06:00
Kingkor Roy Tirtho
9fba2aa33d feat(window): implement window state persistence and restoration 2026-10-16 20:49:16 +06:00
Kingkor Roy Tirtho
9db35e092b feat(server): implement port fallback mechanism for playback proxy server 2026-10-16 20:06:12 +06:00
Kingkor Roy Tirtho
2474c26a83 refactor: move package name from dev.krtirtho to org.spotube 2026-10-16 15:37:15 +06:00
Kingkor Roy Tirtho
7743d6d4c3 feat(lyrics): implement SyncedLyricsList component and enhance LyricsViewModel for better state management 2026-10-16 15:12:10 +06:00
Kingkor Roy Tirtho
77a9cef3a1 fix(stream-proxy): improve logging for cache timeout and handle disconnection scenarios 2026-10-16 14:44:39 +06:00
Kingkor Roy Tirtho
c9d16a25c6 fix(audio-player): android shuffle wasnt working 2026-10-16 14:36:27 +06:00
Kingkor Roy Tirtho
da394d40ea fix(audio-api): enhance fallback mechanism for text search results in YouTube 2026-10-16 14:29:43 +06:00
Kingkor Roy Tirtho
09d77211b9 fix(newpipe): fall back through multiple searches when ISRC search misses
NewPipe durations are seconds, not ms, so duration scoring never matched.
Fix the uploader match direction, normalize titles, and walk a ladder of
searches (ISRC, songs, videos, title only) until a confident match is found.
Return nothing rather than unrelated results. Add detailed ISRC/NewPipe logs.
2026-10-16 14:26:39 +06:00
Kingkor Roy Tirtho
61dd89f3ed fix(stream-proxy): stream upstream audio instead of buffering the whole track
Ktor's request() buffers the full body before returning, so ExoPlayer timed
out waiting for headers. Use prepareRequest().execute{} and tee the body to
the cache file, committing only complete 200 responses. Treat player
disconnects (broken pipe) as debug-level noise rather than upstream errors.
2026-10-16 14:26:39 +06:00
Kingkor Roy Tirtho
ba62ff03c3 feat(user-agents): implement dynamic user agent fetching and caching mechanism 2026-10-16 13:47:08 +06:00
Kingkor Roy Tirtho
83115ea99a feat(plugin): add login prompt for metadata plugin access and implement authentication view model 2026-10-16 12:06:13 +06:00
Kingkor Roy Tirtho
59f1b82368 feat(ui): update MillenniumModalBottomSheet with compact padding and spacing adjustments 2026-10-16 12:01:36 +06:00
Kingkor Roy Tirtho
c57e33fe0d feat(ui): replace IndeterminateProgress with new loading components and enhance dialog structures 2026-10-16 11:49:48 +06:00
Kingkor Roy Tirtho
daf54b74f4 feat(plugin): implement MetadataPluginGuard to manage plugin dependencies and user prompts 2026-10-16 09:29:08 +06:00
Kingkor Roy Tirtho
2bef59ee76 chore: add l10n strings 2026-10-15 23:26:42 +06:00
Kingkor Roy Tirtho
9f79505e1c feat(i18n): replace hardcoded strings with localized resources across multiple components 2026-10-15 14:07:56 +06:00
Kingkor Roy Tirtho
0e0e242095 feat(navigation): add custom navigation transition specifications for push and pop actions 2026-10-15 13:18:26 +06:00
Kingkor Roy Tirtho
fb96984d6e feat(ui): integrate LocalTextStyle for dynamic tinting in player controls and UI components 2026-10-15 13:12:39 +06:00
Kingkor Roy Tirtho
58dfa2de92 feat(library): add Library screen list mode beside grid mode, Local Track, Download screen, Local Library folders dialog visual improvement 2026-10-15 11:36:39 +06:00
Kingkor Roy Tirtho
cf9476ab08 feat(library): enhance library UI with new tab titles, filtering options, and improved state management 2026-10-14 21:52:24 +06:00
Kingkor Roy Tirtho
5b442a93a9 feat(jam): implement group jam functionality with UI components and state management 2026-10-14 20:39:54 +06:00
Kingkor Roy Tirtho
d5eaf101eb fix: add window insets to top bar and bottom navigation bar, queue sheet 2026-10-14 19:26:54 +06:00
Kingkor Roy Tirtho
fda5bb8a60 feat(sidebar): adjust spacing in sidebar and setting cards for improved layout 2026-10-14 16:41:48 +06:00
Kingkor Roy Tirtho
1ccd70a18c feat(search): enhance search functionality with recent search suggestions and improved UI components 2026-10-14 16:29:45 +06:00
Kingkor Roy Tirtho
93dfea9a30 feat(player): refactor artist display and improve search field labels 2026-10-14 13:53:19 +06:00
Kingkor Roy Tirtho
d720467338 feat(support): implement support reminder dialog and donation options 2026-10-14 09:51:07 +06:00
Kingkor Roy Tirtho
8efcbd4af4 feat(player): add content descriptions for play, pause, and add to queue actions 2026-10-13 13:06:22 +06:00
Kingkor Roy Tirtho
76518a01a1 feat(onboarding): enhance welcome screen with structured guide steps and improved layout 2026-10-13 13:03:27 +06:00
Kingkor Roy Tirtho
caad76ba95 refactor: replace currentWindowAdaptiveInfo with currentWindowWidthBreakpoint for adaptive layouts 2026-10-11 21:08:25 +06:00
Kingkor Roy Tirtho
bb9f225fd7
Merge pull request #3136 from henrik242/kmp-zip-1-0-0
Upgrade kmp-zip to 1.0.0
2026-10-11 19:51:34 +06:00
Kingkor Roy Tirtho
cfd472016e refactor: integrate millennium UI 2026-10-11 17:17:34 +06:00
Kingkor Roy Tirtho
963b360a81 feat(ui): new custom surface integration on all components 2026-10-09 14:51:30 +06:00
Kingkor Roy Tirtho
9f96b07ebd feat(ui): replace Scaffold with BaseScaffold for consistent surface theming across screens 2026-10-07 18:45:22 +06:00
Kingkor Roy Tirtho
4da5b25a5c feat(surface): implement Frutiger-Aero themed surfaces with glass and acrylic styles 2026-10-07 15:29:25 +06:00
Kingkor Roy Tirtho
fa9ad9981c feat(settings): add 'About' section and improve header visibility in settings sections 2026-10-07 14:07:09 +06:00
Kingkor Roy Tirtho
5c0652b395 feat(previews): add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields 2026-10-07 10:39:26 +06:00
Kingkor Roy Tirtho
71d7649db6 feat(welcome): implement onboarding flow with multiple pages and donation options 2026-10-06 15:29:27 +06:00
Kingkor Roy Tirtho
fab60107e3 feat(scrobble): implement ScrobbleManager to handle track scrobbling and integrate with audio player 2026-10-02 20:57:50 +06:00
Kingkor Roy Tirtho
eeaff3c1db refactor(main): simplify main function and remove unused WebKit GPU compositing logic 2026-10-02 20:05:23 +06:00
Henrik Brautaset Aronsen
0def3e2e49
Upgrade kmp-zip to 1.0.0
https://github.com/henrik242/kmp-zip/blob/main/CHANGELOG.md
2026-09-30 10:06:42 +02:00
Kingkor Roy Tirtho
4e2afeaea4 feat(update): refactor update checking mechanism and implement new dialog for app and plugin updates 2026-09-30 09:58:04 +06:00
Kingkor Roy Tirtho
3a85ad0896 feat(update): implement update checker with dialog for available updates 2026-09-25 22:39:33 +06:00
Kingkor Roy Tirtho
046c17d3df feat(autocomplete): simplify focus management and remove unused key event handling 2026-09-25 21:39:08 +06:00
Kingkor Roy Tirtho
2713e63902 feat(search): enhance LazyRow with drag scrolling functionality 2026-09-25 21:14:34 +06:00
Kingkor Roy Tirtho
86ec117cfd feat(navigation): refactor sidebar and bottombar navigation structure with new tab and group models 2026-09-25 21:07:19 +06:00
Kingkor Roy Tirtho
78168d269c feat: implement shuffle play functionality for albums, playlists, and saved tracks 2026-09-25 18:47:29 +06:00
Kingkor Roy Tirtho
50c3b04eb1 feat: refactor track source management to support plugin-specific caching and retrieval 2026-09-25 18:29:08 +06:00
Kingkor Roy Tirtho
0c6479570c
Merge pull request #3126 from team-spotube/rtc-integration
feat: add remote control and jam session
2026-09-25 17:09:36 +06:00
Kingkor Roy Tirtho
a4d157e9c2 feat(jam-settings): implement JamSettings screen and view model for broker configuration 2026-09-25 16:44:48 +06:00
Kingkor Roy Tirtho
df3dd2ad88 feat(player-options): enhance options menu with localized strings and icons 2026-09-25 15:03:24 +06:00
Kingkor Roy Tirtho
1e6aee142b feat(player-options): add PlayerOptionsViewModel and enhance player UI with options menu 2026-09-25 14:53:10 +06:00
Kingkor Roy Tirtho
df9403f2d3 feat(release): add support for Linux ARM64 builds and artifact uploads 2026-09-12 10:09:03 +06:00
Kingkor Roy Tirtho
6fabc876a5 chore: remove residual plans 2026-09-12 00:04:10 +06:00
Kingkor Roy Tirtho
4c724a333b feat(jam-session): enhance guest functionality with track addition and participant moderation options 2026-09-12 00:01:34 +06:00
Kingkor Roy Tirtho
ad994f3ca3 feat(jam-session): implement track addition to jam session and enhance UI feedback for jam state 2026-09-11 22:04:59 +06:00
Kingkor Roy Tirtho
a2b9f4178c feat(jam-session): integrate MQTT broker configuration and enhance jam session settings 2026-09-11 19:35:54 +06:00
Kingkor Roy Tirtho
571aea8d38 feat(remote-control): refactor service registration and cleanup logic for improved reliability 2026-09-05 09:52:06 +06:00
Kingkor Roy Tirtho
9ec7704d7b feat(jam-session): enhance jam session functionality with improved playback synchronization and error handling 2026-09-05 09:38:44 +06:00
Kingkor Roy Tirtho
60e38f4868 feat(jam-session): add connection state management and update UI feedback for session status 2026-09-04 15:46:56 +06:00
Kingkor Roy Tirtho
0eb07ef9b6 feat(webrtc): update ICE server configuration and enhance logging for peer connection events 2026-09-04 14:17:37 +06:00
Kingkor Roy Tirtho
7bf17afc42 feat(remote-control): enhance remote playback functionality with new commands and UI integration 2026-09-04 12:02:32 +06:00
Kingkor Roy Tirtho
1c169d007a feat(remote-control): enhance queue management and player state synchronization 2026-09-04 09:24:15 +06:00
Kingkor Roy Tirtho
c5d35bfc93 fix(remote-control): update connection handling and improve logging for remote control events 2026-09-04 09:07:08 +06:00
Kingkor Roy Tirtho
df3a6dcbd2 feat(remote-control): implement remote playback functionality and UI components 2026-08-28 18:28:24 +06:00
Kingkor Roy Tirtho
1aeee1db79 fix(sidebar): add spacer to AppSidebar for improved layout 2026-08-28 13:18:04 +06:00
Kingkor Roy Tirtho
f21442b1c2 feat(deeplinks): implement deep link handling for jam sessions 2026-08-28 09:33:39 +06:00
Kingkor Roy Tirtho
d3a8548661 feat(devices): add device discovery and remote control features 2026-08-21 19:50:09 +06:00
Kingkor Roy Tirtho
69a310c78f
Release 5.1.2+45 (#3049)
* chore: fix white screen issue

* fix(newpipe): fallback to muxed streams if no audio stream is available

* fix: dismiss search dropdown and keyboard on submission

Co-authored-by: Tobse <1190109+TobseF@users.noreply.github.com>

* fix: custom image helper null exception

* chore: generate changelog and bump version for release

---------

Co-authored-by: tomasalias <pasek.domi@post.cz>
Co-authored-by: Akshat-104 <akkusuryan@gmail.com>
Co-authored-by: Tobse <1190109+TobseF@users.noreply.github.com>
2026-06-05 10:49:09 +06:00
790 changed files with 64396 additions and 22704 deletions

View File

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

View File

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

View 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"

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

@ -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
--- ---
![Spotube Desktop](assets/branding/spotube-screenshot.png) ![Spotube Desktop](assets/feature-graphics/readme-hero.png)
![Spotube Mobile](assets/feature-graphics/readme-mobile.png)
![Spotube Mobile](assets/branding/mobile-screenshots/combined.jpg) ****</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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

14
buildSrc/build.gradle.kts Normal file
View 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)
}

View 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"))
}
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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,11 +46,132 @@ 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.
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead. // The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
@ -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") }
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

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

View File

@ -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,22 +126,44 @@ 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
) )
} }
} }
} }
/**
* 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
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
startForeground(NOTIFICATION_ID, notification) // On Android 15+ a service created from the background (e.g. by a jam
// sync or queue restoration) hits this in startForeground instead of at
// the startForegroundService call site. Don't crash: playback keeps
// running in-process, just without the notification until the app is
// foregrounded and the service can start properly.
try {
startForeground(NOTIFICATION_ID, notification)
} catch (e: Exception) {
Log.w(TAG, "startForeground not allowed; continuing without notification", e)
}
librarySession = 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
} }

View File

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

View File

@ -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 { private fun readTags(item: PendingTrack): TagInfo? {
"discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}" val path = item.filePath ?: return null
val key = "$path:${item.modifiedAt}"
tagCache[key]?.let { return it }
val file = File(path)
if (!file.isFile || !file.canRead()) return null
val info = try {
val meta = uniffi.compose_app.readAudioMetadata(path)
TagInfo(
title = meta.title,
artists = meta.artists,
album = meta.album,
durationMs = meta.durationMs,
cover = meta.coverBytes?.let(::thumbnail))
} catch (t: Throwable) {
logger.d { "readTags: failed path=$path reason=${t.message}" }
null
} }
if (info != null) tagCache[key] = info
return info
}
return folders // Embedded covers can be several MB; the cache is serialised as JSON, so keep a small JPEG.
private fun thumbnail(bytes: ByteArray): ByteArray? {
if (bytes.isEmpty()) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (bounds.outWidth / (sample * 2) >= THUMBNAIL_SIZE && bounds.outHeight / (sample * 2) >= THUMBNAIL_SIZE) sample *= 2
val decoded = BitmapFactory.decodeByteArray(
bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
val scale = THUMBNAIL_SIZE.toFloat() / minOf(decoded.width, decoded.height)
val bitmap = if (scale < 1f) Bitmap.createScaledBitmap(
decoded, (decoded.width * scale).toInt().coerceAtLeast(1),
(decoded.height * scale).toInt().coerceAtLeast(1), true) else decoded
return ByteArrayOutputStream().use {
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it)
it.toByteArray()
}
} }
private fun resolveCandidateRoots(roots: List<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")
)
} }
} }

View File

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

Some files were not shown because too many files have changed in this diff Show More