AVPlayer misreads the duration of some fragmented MP4 streams (it reports
double the real length, leaving the second half silent and never ending the
track). Playback on iOS now goes through VLCKit 4, added as a Swift package
pinned to videolan/vlckit@6b87735.
- NativePlayerEngine (iosMain): single-media engine contract that Swift
implements and registers at launch; VLCKit ships as an SPM binary target,
so it can't be bound with cinterop from Gradle.
- AudioPlayer.ios.kt: rewritten on the engine. Playlist, loop modes, shuffle
and auto-advance live in Kotlin, mirroring the JVM player (the old iOS
player never advanced to the next track).
- VlcPlayerEngine.swift: VLCKit-backed engine. VLCKit 4 has no "ended" state,
so natural ends are told apart from requested stops; after a pause/resume
it stops reporting time, so a no-op seek restores position updates.
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.
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#3200Fixes#3193Fixes#3196
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
- 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.
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.
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.
- 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.
- 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
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.
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.
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>
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>
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.
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.
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.
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
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.
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.
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.
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
- 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