android auto: the car plays what is here, resumes, and is told the rest — soulseek dials back, and the storm is over
Four things the car could not do, all from a process with no Activity.
A streamed playlist row plays cold when its bytes are already here. The
page's memory of which peer file a catalog track resolved to lived only in
localStorage; it is mirrored now to `<data>/stream-sources.json`
(stream_sources.rs) — every remembered source, and the whole store once at
boot — and auto_browse.rs looks a row up there, computes its cache file
with the same hash playback::cache_path uses, and calls it playable when
the file is byte-complete. GhettoTechie reads "8 of 40 here" cold and a
tapped row plays from the cache with its catalog cover and colour.
The car resumes, the way every other player does. Where playback was is in
`<data>/resume.json` (resume.rs): the page names the queue whenever it
writes its session, the engine's pump keeps the position. media3's
MediaButtonReceiver is declared, so a PLAY key into a dead app — a
Bluetooth head unit, the Quick Settings card, Auto's autoplay on
connection — starts the service and lands in onPlaybackResumption, which
answers with the `ss:resume` row; the RECENT root serves the same row, and
the Recent tab lists it first. A force-stopped app came back PLAYING where
it left off from one `cmd media_session dispatch play`.
The car is told. A tap that cannot play — a streamed row with no cache copy
while the app is cold, a hand-off the resolver refused — used to be
silence. MediaHub.fail puts the reason in the playback state (STATE_ERROR,
which Auto draws as a card) and sends a SessionError carrying "Open
SoulStream"; the next track withdraws it. The page reports its own verdict
through the new `remote_failed` command.
Every tap while the app is UP is the page's. A library row tapped in the
car used to start on the engine underneath the page's own queue — the bar
naming one track while another played. `AutoNative.describe` spells the
row for the page and `playMediaId` plays it as the page's queue; the plugin
disarms the hand-off in onDestroy, and with the page gone the card's
transport goes to the engine. When the app opens over a car session, the
headless queue is handed to the page once (`playback::init` takes it before
clearing) and restoreSession builds the bar from it.
Every playlist row is playable: Auto draws a non-playable, non-browsable
item as a tile and BROWSES it on tap ("No items"). Rows that need the app
say so and fail on tap with the card; rows whose file is here wear the
host's own downloaded mark. The Recent tab's album subtitle counted played
tracks; it counts the album's now. The page never clears the resume point
and only drops its own session once it has booted — a visibility change
under a launch with the screen off wiped both.
The Soulseek session dials itself back after a drop. A server connection
dies for reasons that are nobody's — the phone changes networks, Android
freezes a backgrounded process past the five-minute keepalive — and until
now every one of those ended as `Event::Disconnected`, the page stamped
offline, and every streamed track after it refused with "sign in on
Community"; in a car nobody opens the login card. `schedule_reconnect`
redials with the stored credentials at 5, 15, 45, 120, then 300 s with
jitter, under the server's rapid-login throttle. Every session carries a
generation: an explicit login or disconnect bumps it, a redial loop stands
down when the user acts, and a drop reported by an OLDER session's pump
(login_inner shuts the old client down and its reader reports after the new
session is up) no longer stamps the fresh session offline — that race was
there all along. The page shows "connecting" on `reconnecting`, the
resolver waits up to twenty seconds for a dial in progress, and debug
builds expose `debug_drop_connection`. Verified by the debug command (drop →
redial 1 in 5 s → online at 7 s) and by a real drop when the head unit
connected: redial 1 failed on DNS, redial 2 at +15 s logged in.
The notification storm was media3 restarting the service into
onStartCommand. The morning's logcat had the card re-posted six times a
second for over a minute ("Package enqueue rate is 6.6 … Shedding"), then
"Too many PendingIntent created for uid" (media3 mints a fresh one for the
custom button on every rebuild), then twenty refused foreground starts in
two hundred milliseconds. media3 starts this service ITSELF on every
foreground notification update — MediaNotificationManager.startForeground
calls startForegroundService(startSelfIntent), a bare component intent with
nothing but a uid extra — and onStartCommand promoted on every intent it
saw, which re-posted the card, which made media3 start the service again.
It ran for as long as the service was foreground: while a track played,
and for the ten-minute tail after a pause (the capture stops at pause +
10:00 exactly). onStartCommand now promotes only for an intent that carries
an action or a data uri. A 250 ms coalescer on card rebuilds
(MediaHub.refreshSoon) logs the rate; it stayed silent through the storm,
which is what pointed at the restart. A full minute of playback after the
fix: zero shedding lines, zero PendingIntent complaints, zero refused
starts.
Verified on the Desktop Head Unit and the Pixel: the tree, the Continue
row, cold cached playback, the error card and recovery from it, the warm
hand-off, the media-key resume, the redial; mock walk 255/255, phone walk
142/142.
This commit is contained in:
1 parent
8f46f9c663
commit
17942fc04b
28 files changed
+2413
-209
No files matched your search
+120
-24
@@ -1116,6 +1116,93 @@ identically. And a Bluetooth headset's own buttons, which take a path
|
||||
`input keyevent` does not. These four are the first things to try after the
|
||||
first deploy of anything that touches this plugin.
|
||||
|
||||
## Android Auto: cold playback, resume, and what the car is told
|
||||
|
||||
The car binds `MediaService` with the app COLD — no Activity, no WebView, no
|
||||
`AppHandle` — and everything below is about making that process play what
|
||||
it can, say what it cannot, and pick up where the phone left off. Four
|
||||
pieces, all of them landed 2026-09-18:
|
||||
|
||||
**What plays cold.** Library tracks always did. Now a playlist's streamed
|
||||
rows play cold too, when their bytes are already on the phone: the page's
|
||||
memory of which peer file a catalog track resolved to
|
||||
(`soulstream.streamSources.v1` in localStorage) is mirrored to
|
||||
`<data>/stream-sources.json` (`stream_sources.rs`) — one entry every time a
|
||||
source is remembered, and the whole store once at boot for memories made
|
||||
before the mirror existed — and `auto_browse.rs` looks a streamed row up
|
||||
there, computes its cache file with the same hash `playback::cache_path`
|
||||
uses, and calls it playable when the file is byte-complete. Such a row is
|
||||
"artist · album" in the car; one that is not is "artist · needs the app",
|
||||
which is the amber-dot row of the design. A playlist's subtitle counts
|
||||
cached streams as "here". A tap on a row that needs the app is handed to
|
||||
the app when it is up (`playRemote`), exactly as before.
|
||||
|
||||
**Keep offline.** A playlist's More sheet has "Keep offline"
|
||||
(`src/mobile/offline.svelte.js`, the list in `config.json`
|
||||
`offline_playlists`): every streamed row is fetched into the stream cache in
|
||||
the background — the remembered peer file first, else the resolver's ladder,
|
||||
then the same `preload_stream` the player uses for the next track, one row
|
||||
at a time, polled until whole — and pinned (`keep` in the mirror), which the
|
||||
cache pruner honours, so the retention window cannot undo it. The fetch
|
||||
rides the downloads notice in the shade, and that is also what keeps the
|
||||
process alive once the app leaves the screen; Android 12+ lets that service
|
||||
start only from the foreground, so a pass that begins on screen keeps going
|
||||
in a pocket. The playlist header says "Kept offline · N of M here" once a
|
||||
pass has looked.
|
||||
|
||||
**Resume, like every other player.** Where playback was is written to
|
||||
`<data>/resume.json` (`resume.rs`) by two hands: the page names the queue
|
||||
(a window of 25 either side of the current track, each entry by the file
|
||||
that plays it cold — a local path, or the peer file whose cache may hold it)
|
||||
every time it writes its own session, and the engine's pump keeps the
|
||||
position, every five seconds while playing and at once on a pause or seek.
|
||||
Three things read it. media3's `MediaButtonReceiver` is now declared, so a
|
||||
PLAY key into a dead app — a Bluetooth head unit's button, the Quick
|
||||
Settings resume card, Android Auto's autoplay on connection — starts the
|
||||
service and lands in `LibraryTree.onPlaybackResumption`, which answers with
|
||||
the `ss:resume` row and the position; media3 sets it, and
|
||||
`play_media_id("ss:resume", startMs)` starts the saved queue at the saved
|
||||
entry and seeks. A host asking for the RECENT root (`EXTRA_RECENT`) gets a
|
||||
root whose one child is that row, "Continue · <record> · m:ss". And the
|
||||
Recent tab lists it first. An entry whose file is gone is skipped; if the
|
||||
one that was playing is gone, the next playable one starts at zero rather
|
||||
than three minutes into a different song.
|
||||
|
||||
**The handover.** When the app opens over a car session the engine is
|
||||
already playing a queue the page has never heard of. `playback::init` takes
|
||||
that queue off the headless engine before clearing it, `player_state` hands
|
||||
it over once, and `restoreSession` builds the bar from it — instead of
|
||||
restoring the page's own older session over live audio, which put the wrong
|
||||
title on the bar.
|
||||
|
||||
**What the car is told.** A tap that cannot play — a streamed row with no
|
||||
cache copy while the app is cold, a hand-off the resolver refused, a row
|
||||
whose file is gone — used to be silence. `MediaHub.fail` now puts the reason
|
||||
in the playback state (`STATE_ERROR` with the message, which Auto draws as a
|
||||
card) and sends a `SessionError` carrying "Open SoulStream" as the
|
||||
resolution action; the next track that plays withdraws it. The page reports
|
||||
its own verdict back through `remote_failed` when a hand-off fails.
|
||||
|
||||
**The notification storm, found and fixed.** 2026-09-18's logcat had the
|
||||
card re-posted six times a second for over a minute, then "Too many
|
||||
PendingIntent created" (media3 mints a fresh one for the custom button on
|
||||
every rebuild) and twenty refused foreground starts in two hundred
|
||||
milliseconds. The cause was a loop between `MediaService.onStartCommand` and
|
||||
media3: media3 starts the service ITSELF on every foreground notification
|
||||
update (`MediaNotificationManager.startForeground` →
|
||||
`startForegroundService(startSelfIntent)`, a bare component intent with a
|
||||
uid extra and nothing else), and `onStartCommand` promoted on every intent
|
||||
— re-posting the card, which made media3 start the service again. It ran
|
||||
for as long as the service was in the foreground: while a track played, and
|
||||
for the ten-minute tail after it paused (the capture shows it stopping at
|
||||
exactly pause + 10 min). `onStartCommand` now promotes only for an intent
|
||||
that carries an action or a data uri — a media key, a card button — and
|
||||
leaves media3's self-start to media3. Separately, every `refresh()` of the
|
||||
media3 player goes through `MediaHub.refreshSoon`, which coalesces asks
|
||||
inside a 250 ms window and logs `card asked to rebuild Nx in 10s` when the
|
||||
rate is silly; it stayed silent through the storm, which is what pointed
|
||||
away from our own rebuilds and at the service restart.
|
||||
|
||||
## What a screen reader says
|
||||
|
||||
Every control on the phone is named by what it does, in the app's own
|
||||
@@ -1186,6 +1273,26 @@ would notice.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
**The network comes back on its own (2026-09-18).** A Soulseek server
|
||||
connection dies for reasons that are nobody's: the phone changes networks,
|
||||
Android freezes a backgrounded process for long enough that the five-minute
|
||||
keepalive never goes out and the server closes the socket, a laptop sleeps.
|
||||
Until now every one of those ended as `Event::Disconnected`, the page stamped
|
||||
"offline", and every streamed track after that was refused with "sign in on
|
||||
Community" until somebody opened the login card — in a car, nobody does.
|
||||
`commands::schedule_reconnect` now dials the dropped session back with the
|
||||
stored credentials: 5 s, 15 s, 45 s, 2 min, then every 5 min, with jitter,
|
||||
which stays well under the server's rapid-login throttle (eight to ten and it
|
||||
silently drops the handshake). The page hears `soulseek:state { kind:
|
||||
"reconnecting", attempt, in_secs }` and shows "connecting" rather than
|
||||
"offline"; the resolver waits up to twenty seconds for a dial in progress
|
||||
before refusing a track. An explicit Disconnect ends the wish; an explicit
|
||||
login starts a new generation, and a drop reported by an older session's
|
||||
pump — which used to stamp a fresh session offline, a race that was there
|
||||
all along — now says nothing. Debug builds expose `debug_drop_connection`
|
||||
(`__ss.api.debugDropConnection()`), which severs the socket exactly the way a
|
||||
network drop would, so the redial can be watched.
|
||||
|
||||
Thirty rounds built the happy path. A phone is also in a lift, signed out, on
|
||||
a dead cell, or holding a library folder that has gone away, and until this
|
||||
round every one of those showed up as an empty box, a spinner with no end, or
|
||||
@@ -2413,27 +2520,16 @@ symptom.
|
||||
the share sheet, which no walk reaches. Work down the smoke list in "The
|
||||
release build" before trusting a release APK on a phone.
|
||||
|
||||
- **Android Auto can see the app, and there is nothing inside.** Round
|
||||
forty-nine made `MediaService` a `MediaLibraryService`, put three actions
|
||||
on one intent filter (media3's `MediaLibraryService` and
|
||||
`MediaSessionService`, plus the legacy
|
||||
`android.media.browse.MediaBrowserService` that Auto actually binds), and
|
||||
added `res/xml/automotive_app_desc.xml` with the
|
||||
`com.google.android.gms.car.application` meta-data that declares the app to
|
||||
Auto. So Auto lists SoulStream, opens it, and gets a root — immediately,
|
||||
from `Futures.immediateFuture`, with nothing on the main thread — and the
|
||||
root is empty. `onGetChildren` is not overridden, so the default answers
|
||||
`ERROR_NOT_SUPPORTED` and the tree is one browsable folder with no rows in
|
||||
it. What is still missing is everything under it: the library read out of
|
||||
the database **read-only** from a process with no Activity and no Tauri
|
||||
(`crates/library` is plain rusqlite and `JNI_OnLoad` publishes the Context
|
||||
at `System.loadLibrary` time, so browse can work cold), playing what it
|
||||
lists — which needs `COMMAND_SET_MEDIA_ITEM` and its neighbours, still
|
||||
deliberately unadvertised, plus the `queueBase` → `playQueueAt()` mapping —
|
||||
and search. Catalog and Soulseek rows will never play from cold: the
|
||||
resolver is 872 lines of shared JS and Soulseek needs a live session, so
|
||||
they get `STATE_ERROR` with a resolution intent instead, which is also what
|
||||
Google's quality bar asks for. The walk proves the two front doors resolve
|
||||
(`auto-browse`, `auto-library`) and `tools/auto-prober/` proves the root
|
||||
comes back cold; the car screen itself still needs the Desktop Head Unit or
|
||||
a car.
|
||||
- **Android Auto, what is still not there.** The tree, cold playback of
|
||||
library tracks and cached streams, search and voice, resume and autoplay,
|
||||
and the error card are in (see "Android Auto: cold playback, resume, and
|
||||
what the car is told"). Not yet: a streamed row with NO cache copy still
|
||||
cannot play from a cold process — the resolver is shared JS and Soulseek
|
||||
needs a live session — and the car says so and offers to open the app;
|
||||
"Keep offline" is the answer for a playlist that matters. **Save**, the
|
||||
design's second transport action, is unbuilt. The car's own plays are not
|
||||
written to `plays` (the headless process opens the library read-only by
|
||||
rule), so a record heard only in the car does not climb the phone's Recent
|
||||
— the resume row covers the common case. Catalog browsing beyond
|
||||
playlists, the queue as a browsable list, and the `queueBase` →
|
||||
`playQueueAt()` mapping for tapping a queue row are still absent.
|
||||
@@ -49,6 +49,26 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- media3's own media-button receiver, and the reason the app can
|
||||
start COLD from a PLAY key: a Bluetooth head unit's play button,
|
||||
the Quick Settings "resume" card after a reboot, and Android
|
||||
Auto's autoplay on connection are all KEYCODE_MEDIA_PLAY arriving
|
||||
at a process that does not exist. The receiver starts the
|
||||
service in the foreground (MediaService.onStartCommand promotes
|
||||
at once, see holdForeground) and media3 then asks
|
||||
LibraryTree.onPlaybackResumption what to play. It forwards
|
||||
ONLY play / play-pause / headset-hook into a dead app — a next or
|
||||
a pause into nothing is dropped, not started. While the app is
|
||||
alive the framework talks to the session directly and the
|
||||
receiver is not involved. -->
|
||||
<receiver
|
||||
android:name="androidx.media3.session.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- What puts SoulStream in Android Auto's app list at all.
|
||||
The resource is res/xml/automotive_app_desc.xml and the
|
||||
reference is resolved at LINK time, so a missing file fails
|
||||
|
||||
+267
-21
@@ -115,16 +115,24 @@ import org.json.JSONArray
|
||||
object MediaHub {
|
||||
@Volatile var onAction: ((String, Double) -> Unit)? = null
|
||||
/**
|
||||
* Play something this process cannot resolve on its own — a catalog or
|
||||
* Soulseek row — by handing it to the app, which has the resolver.
|
||||
* Play a browse row THROUGH THE APP: the page owns the queue while it
|
||||
* exists (shuffle, repeat, failover, the resolver, the session it
|
||||
* restores), so every car tap is handed to it whole — a library album, a
|
||||
* playlist row, the resume row — rather than started on the engine
|
||||
* underneath it, which left the bar naming one track while another
|
||||
* played. The first argument is the row as `AutoNative.describe` spells
|
||||
* it for the page; the second the position to start at, or -1.
|
||||
*
|
||||
* Non-null ONLY while the plugin is loaded, which means only while a
|
||||
* WebView exists. That is exactly the test for "is the app warm", so it
|
||||
* doubles as the discriminator: set, and the car's tap becomes the app's
|
||||
* tap; null, and there is nothing on this phone that could play it.
|
||||
* tap; null, and the engine in this process plays what it can itself.
|
||||
*/
|
||||
@Volatile var onRemote: ((Long, Long) -> Unit)? = null
|
||||
@Volatile var onPlay: ((String, Long) -> Unit)? = null
|
||||
@Volatile var state: NowPlayingArgs? = null
|
||||
/** Why the last thing asked for did not play, for the car screen; null
|
||||
* the moment anything plays. See [fail]. */
|
||||
@Volatile var error: String? = null
|
||||
/** the cover as ENCODED bytes — media3 decodes them itself */
|
||||
@Volatile var artBytes: ByteArray? = null
|
||||
@Volatile var artKey: String? = null
|
||||
@@ -199,6 +207,24 @@ object MediaHub {
|
||||
}
|
||||
}
|
||||
state = np
|
||||
/* something is on the bar again: whatever went wrong last is over —
|
||||
and the error a legacy controller was SENT is withdrawn explicitly,
|
||||
because a head unit keeps showing one until told otherwise */
|
||||
val hadError = error != null
|
||||
error = null
|
||||
if (hadError) {
|
||||
mainHandler.post {
|
||||
try {
|
||||
libSession?.sendError(
|
||||
androidx.media3.session.SessionError(
|
||||
androidx.media3.session.SessionError.INFO_CANCELLED, "",
|
||||
),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
/* a controller that has gone; nothing to withdraw from */
|
||||
}
|
||||
}
|
||||
}
|
||||
bind(app)
|
||||
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
|
||||
verifyApplicationThread(), a Tauri @Command is not promised to run
|
||||
@@ -211,9 +237,108 @@ object MediaHub {
|
||||
because the update that MADE the service found `service` still
|
||||
null; there is nothing to catch up now. */
|
||||
if (np.playing) requestFocus(app)
|
||||
player?.let {
|
||||
it.settle()
|
||||
it.refresh()
|
||||
player?.settle()
|
||||
refreshSoon()
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the card's rebuild rate ─────────────────────────────────────────
|
||||
Every refresh() is a getState(), a diff, a PlayerInfo to every
|
||||
connected controller (seventeen of them on 2026-09-18, after a day
|
||||
of head-unit sessions), and — when anything the card shows moved — a
|
||||
notification re-post, which under media3 mints a fresh PendingIntent
|
||||
for the custom button each time. This morning's logcat had the card
|
||||
re-posted six times a second for over a minute ("Package enqueue
|
||||
rate is 6.6 … Shedding"), then "Too many PendingIntent created for
|
||||
uid", then twenty refused foreground starts in two hundred
|
||||
milliseconds. Whatever asks that often, the answer is the same: the
|
||||
card is a mirror, and a mirror that is repainted more than a few
|
||||
times a second is not showing anything new. So refreshes are
|
||||
COALESCED — the first lands at once, anything inside the window
|
||||
folds into one rebuild at the window's end — and counted, so the
|
||||
next storm has a number beside it in the log instead of a mystery. */
|
||||
private const val REFRESH_WINDOW_MS = 250L
|
||||
@Volatile private var refreshPending = false
|
||||
@Volatile private var lastRefreshAt = 0L
|
||||
private var refreshes = 0
|
||||
private var refreshAsks = 0
|
||||
private var rateSince = 0L
|
||||
private val refreshNow = Runnable {
|
||||
refreshPending = false
|
||||
lastRefreshAt = android.os.SystemClock.uptimeMillis()
|
||||
refreshes += 1
|
||||
player?.refresh()
|
||||
}
|
||||
|
||||
/** Ask for a state rebuild. Main thread only. */
|
||||
fun refreshSoon() {
|
||||
val now = android.os.SystemClock.uptimeMillis()
|
||||
refreshAsks += 1
|
||||
if (rateSince == 0L) rateSince = now
|
||||
if (now - rateSince >= 10_000L) {
|
||||
if (refreshAsks > 20) {
|
||||
android.util.Log.w(
|
||||
"SoulStream",
|
||||
"card asked to rebuild " + refreshAsks + "x in 10s (" + refreshes + " done)",
|
||||
)
|
||||
}
|
||||
refreshAsks = 0
|
||||
refreshes = 0
|
||||
rateSince = now
|
||||
}
|
||||
if (refreshPending) return
|
||||
val since = now - lastRefreshAt
|
||||
if (since >= REFRESH_WINDOW_MS) {
|
||||
refreshNow.run()
|
||||
} else {
|
||||
refreshPending = true
|
||||
mainHandler.postDelayed(refreshNow, REFRESH_WINDOW_MS - since)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the car (or a headset) asked for could not play: the row
|
||||
* needs the app and the app is not up, the resolver found no source,
|
||||
* the network is signed out. Until now every one of those was silence
|
||||
* on the head unit. This puts the reason where a driver looks — the
|
||||
* playback state goes to error with the message, which Android Auto
|
||||
* draws as a card — and offers "Open SoulStream" as the way out, the
|
||||
* resolution the design asked for.
|
||||
*
|
||||
* Cleared by the next [update]: a track playing is the error being over.
|
||||
*/
|
||||
fun fail(ctx: Context, message: String) {
|
||||
val app = ctx.applicationContext
|
||||
error = message
|
||||
android.util.Log.i("SoulStream", "auto error shown: " + message)
|
||||
mainHandler.post {
|
||||
refreshSoon()
|
||||
val s = libSession ?: return@post
|
||||
try {
|
||||
val extras = android.os.Bundle()
|
||||
extras.putString(
|
||||
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_LABEL_COMPAT,
|
||||
"Open SoulStream",
|
||||
)
|
||||
app.packageManager.getLaunchIntentForPackage(app.packageName)?.let { launch ->
|
||||
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
extras.putParcelable(
|
||||
androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT,
|
||||
PendingIntent.getActivity(
|
||||
app, 7, launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
s.sendError(
|
||||
androidx.media3.session.SessionError(
|
||||
androidx.media3.session.SessionError.ERROR_INVALID_STATE,
|
||||
message,
|
||||
extras,
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "auto error could not be sent: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,10 +357,8 @@ object MediaHub {
|
||||
artKey = null
|
||||
mainHandler.post {
|
||||
dropFocus(app)
|
||||
player?.let {
|
||||
it.settle()
|
||||
it.refresh()
|
||||
}
|
||||
player?.settle()
|
||||
refreshSoon()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,12 +612,12 @@ object MediaHub {
|
||||
onAction?.invoke(action, position)
|
||||
}
|
||||
|
||||
/** True when the app is up and its resolver can be reached. */
|
||||
fun warm(): Boolean = onRemote != null
|
||||
/** True when the app is up: the page owns the queue and plays every tap. */
|
||||
fun warm(): Boolean = onPlay != null
|
||||
|
||||
/** Hand a playlist row to the app to play. */
|
||||
fun remote(playlist: Long, item: Long) {
|
||||
onRemote?.invoke(playlist, item)
|
||||
/** Hand a row to the app to play, as `AutoNative.describe` spelled it. */
|
||||
fun play(desc: String, startMs: Long) {
|
||||
onPlay?.invoke(desc, startMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,9 +716,90 @@ object LibraryTree : MediaLibrarySession.Callback {
|
||||
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_LIST)
|
||||
b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
|
||||
val rootParams = LibraryParams.Builder().setExtras(b).build()
|
||||
// The RECENT root is a different question — "what would you continue?"
|
||||
// — asked by Android's own resumption surfaces and by Auto. It is
|
||||
// answered with a root whose one child is the resume row
|
||||
// (auto_browse.rs RECENT_ROOT), read out of resume.json and never the
|
||||
// database; a host that gets the library tree here would draw four
|
||||
// tabs where it wanted one song.
|
||||
if (params?.isRecent == true) {
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(recentRootItem(), rootParams))
|
||||
}
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), rootParams))
|
||||
}
|
||||
|
||||
/** The id auto_browse.rs answers the resume row for. */
|
||||
const val RECENT_ROOT = "ss:recent-root"
|
||||
|
||||
private fun recentRootItem(): MediaItem =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(RECENT_ROOT)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("Continue")
|
||||
.setIsBrowsable(true)
|
||||
.setIsPlayable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* "Pick up where you left off", asked by a PLAY that reaches a player
|
||||
* with nothing in it: a Bluetooth head unit's button, the Quick Settings
|
||||
* resume card, Android Auto's autoplay on connection. media3 takes what
|
||||
* comes back, sets it on the player, prepares and plays — so this
|
||||
* answers with the resume row (`ss:resume`) and the position, and
|
||||
* [SoulStreamPlayer.handleSetMediaItems] turns that into the saved queue
|
||||
* at the saved spot. `isForPlayback` false is the same question asked to
|
||||
* DRAW a card; the answer is identical and nothing starts.
|
||||
*
|
||||
* Off the calling thread: it reads resume.json and stats a few files.
|
||||
*/
|
||||
override fun onPlaybackResumption(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
isForPlayback: Boolean,
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
|
||||
Futures.submit<MediaSession.MediaItemsWithStartPosition>(
|
||||
{
|
||||
val ctx = MediaHub.appContext ?: throw IllegalStateException("no context")
|
||||
val o = org.json.JSONObject(AutoNative.resumeInfo(ctx.dataDir.absolutePath))
|
||||
if (!o.optBoolean("ok", false)) {
|
||||
android.util.Log.i("SoulStream", "auto resume asked, nothing to resume")
|
||||
throw UnsupportedOperationException("nothing to resume")
|
||||
}
|
||||
val md = MediaMetadata.Builder()
|
||||
.setTitle(o.optString("title"))
|
||||
.setArtist(o.optString("artist"))
|
||||
.setAlbumTitle(o.optString("album"))
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
|
||||
artUri(ctx, art, controller.packageName)?.let { md.setArtworkUri(it) }
|
||||
val item = MediaItem.Builder()
|
||||
.setMediaId(o.optString("id", "ss:resume"))
|
||||
.setMediaMetadata(md.build())
|
||||
.build()
|
||||
val pos = o.optLong("positionMs", 0L)
|
||||
android.util.Log.i(
|
||||
"SoulStream",
|
||||
"auto resume " + (if (isForPlayback) "for playback" else "to show") +
|
||||
": " + o.optString("title") + " at " + pos + "ms",
|
||||
)
|
||||
MediaSession.MediaItemsWithStartPosition(ImmutableList.of(item), 0, pos)
|
||||
},
|
||||
io,
|
||||
)
|
||||
|
||||
/** The older spelling of the same question, for a host on the old path. */
|
||||
override fun onPlaybackResumption(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
|
||||
onPlaybackResumption(mediaSession, controller, true)
|
||||
|
||||
/**
|
||||
* The icon a root tab is drawn with — the thing that turns four words into
|
||||
* four tabs with pictures over them, which is what every other media app
|
||||
@@ -956,7 +1160,21 @@ object LibraryTree : MediaLibrarySession.Callback {
|
||||
val tab = tabIcon(ctx, id)
|
||||
if (tab != null) md.setArtworkUri(tab)
|
||||
else artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) }
|
||||
styleExtras(id)?.let { md.setExtras(it) }
|
||||
val extras = styleExtras(id) ?: android.os.Bundle()
|
||||
// A playlist row whose file is on this phone — a local track,
|
||||
// or a streamed one whose cache copy is whole — wears the
|
||||
// host's own "downloaded" mark. It is the design's reach
|
||||
// signal turned the safe way round: the rows that play with
|
||||
// the app shut are the marked ones, and the mark thins the
|
||||
// "needs the app" rows out as more of the list is kept.
|
||||
val here = id.startsWith("ss:pitem:") && !o.isNull("path") && o.optString("path").isNotEmpty()
|
||||
if (here) {
|
||||
extras.putLong(
|
||||
androidx.media3.session.MediaConstants.EXTRAS_KEY_DOWNLOAD_STATUS,
|
||||
androidx.media3.session.MediaConstants.EXTRAS_VALUE_STATUS_DOWNLOADED,
|
||||
)
|
||||
}
|
||||
if (!extras.isEmpty) md.setExtras(extras)
|
||||
out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -1135,8 +1353,27 @@ class MediaService : MediaLibraryService() {
|
||||
stops. Promoting after that would put a service already on its way
|
||||
out back into the foreground. A null intent is the system
|
||||
re-creating a START_STICKY service — not a foreground start, and
|
||||
it needs nothing. */
|
||||
if (intent != null) holdForeground()
|
||||
it needs nothing.
|
||||
|
||||
ONLY FOR AN INTENT SOMEBODY ELSE SENT — a media key, a card
|
||||
button — which is one that carries an action or a data uri.
|
||||
media3 starts this service ITSELF on every foreground notification
|
||||
update: `MediaNotificationManager.startForeground` calls
|
||||
`ContextCompat.startForegroundService(startSelfIntent)`, and that
|
||||
intent is a bare `Intent(context, MediaService::class.java)` with
|
||||
nothing but a uid extra — no action, no data. Promoting on THAT
|
||||
re-posted the notification, which made media3 start the service
|
||||
again, which landed here again: a loop at the rate two binder
|
||||
calls allow, about six a second, for as long as the service was
|
||||
in the foreground — while a track played, and for the ten-minute
|
||||
tail after it paused. That is the whole of 2026-09-18's storm:
|
||||
"Package enqueue rate is 6.6 … Shedding" for over a minute,
|
||||
"Too many PendingIntent created for uid" (media3 mints a fresh
|
||||
one for the custom button on every rebuild), and twenty refused
|
||||
foreground starts in two hundred milliseconds once the app was
|
||||
in the background. The self-start needs nothing from us: media3
|
||||
promotes in the same breath it sends it. */
|
||||
if (intent != null && (intent.action != null || intent.data != null)) holdForeground()
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
@@ -1260,9 +1497,18 @@ object AutoNative {
|
||||
* left in silence.
|
||||
*
|
||||
* A tapped track brings its album with it, so the record keeps playing
|
||||
* without any JS alive to advance it.
|
||||
* without any JS alive to advance it. `startMs` below zero means "where
|
||||
* it was left" for the resume row and the top for everything else.
|
||||
*/
|
||||
external fun play(dataDir: String, mediaId: String): String
|
||||
external fun play(dataDir: String, mediaId: String, startMs: Long): String
|
||||
|
||||
/** What would resume, and where, as JSON (`{"ok":false}` when nothing
|
||||
* can). Reads resume.json and stats a few files; starts nothing. */
|
||||
external fun resumeInfo(dataDir: String): String
|
||||
|
||||
/** A browse row as the PAGE understands it — `{"kind":"album","albumId":n}`
|
||||
* and its siblings — for the warm hand-off. Two lookups at most. */
|
||||
external fun describe(dataDir: String, mediaId: String): String
|
||||
|
||||
/** pause / resume / stop against the process's engine, for when there is
|
||||
* no WebView to take the usual round trip. */
|
||||
|
||||
+38
-7
@@ -63,6 +63,13 @@ class QueueItemArgs {
|
||||
var artKey: String? = null
|
||||
}
|
||||
|
||||
/** Why a row the car handed to the page did not play — the Rust
|
||||
* `RemoteFailure`, camelCase. */
|
||||
@InvokeArg
|
||||
class RemoteFailureArgs {
|
||||
var message: String? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge between the WebView and the service that owns the session.
|
||||
* `update` mirrors the track on the bar (and starts the service the first
|
||||
@@ -80,19 +87,34 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
payload.put("position", position)
|
||||
trigger("action", payload)
|
||||
}
|
||||
/* The car asking the APP to play something this process cannot
|
||||
resolve. Setting this is also what tells the media service the app
|
||||
is warm — it is null whenever there is no WebView. */
|
||||
/* The car asking the APP to play a row — any row, while the page is
|
||||
up and owns the queue. Setting this is also what tells the media
|
||||
service the app is warm — it is null whenever there is no WebView. */
|
||||
android.util.Log.i("SoulStream", "auto plugin init — warm hand-off armed")
|
||||
MediaHub.onRemote = { playlist, item ->
|
||||
MediaHub.onPlay = { desc, startMs ->
|
||||
val payload = JSObject()
|
||||
payload.put("action", "playRemote")
|
||||
payload.put("playlist", playlist)
|
||||
payload.put("item", item)
|
||||
payload.put("action", "playMediaId")
|
||||
payload.put("desc", desc)
|
||||
payload.put("startMs", startMs)
|
||||
trigger("action", payload)
|
||||
}
|
||||
}
|
||||
|
||||
/** The page is going away — the app swiped out of recents, say — while
|
||||
* the media service may keep this process alive for the car. From here
|
||||
* there is nobody behind `trigger`, so the hand-off must stop believing
|
||||
* the app is warm: a tap in the car would otherwise be posted to a
|
||||
* WebView that no longer exists and answered by nothing. Null means the
|
||||
* engine in this process plays what it can and the car is told about the
|
||||
* rest, exactly as on a cold start. A new Activity builds a new plugin,
|
||||
* whose init arms both again. */
|
||||
override fun onDestroy() {
|
||||
MediaHub.onPlay = null
|
||||
MediaHub.onAction = null
|
||||
android.util.Log.i("SoulStream", "auto plugin destroyed — hand-off disarmed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@Command
|
||||
fun update(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(NowPlayingArgs::class.java)
|
||||
@@ -126,6 +148,15 @@ class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
/** The page could not play a row the car handed it. The session says
|
||||
* why, on the car screen, instead of leaving the driver in silence. */
|
||||
@Command
|
||||
fun remoteFailed(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(RemoteFailureArgs::class.java)
|
||||
MediaHub.fail(activity.applicationContext, args.message ?: "SoulStream could not play that")
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
/** Android 13+: the card is a notification and needs the permission; asked
|
||||
* once, the first time something plays. Media controls in the shade and
|
||||
* on the lock screen follow the session either way. */
|
||||
|
||||
+81
-21
@@ -55,6 +55,24 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
/* ── the state media3 reads ──────────────────────────────────────── */
|
||||
|
||||
override fun getState(): SimpleBasePlayer.State {
|
||||
/* Something asked for could not play, and the reason is what the
|
||||
car should show rather than a blank player or the last track's
|
||||
card. A player error is only legal in STATE_IDLE, and media3's
|
||||
legacy stub turns exactly that into PlaybackStateCompat's
|
||||
STATE_ERROR with the message — which Android Auto draws as a card
|
||||
over the browse list. The playlist is kept (if there was one) so
|
||||
the transport does not lose its shape; the next update clears it. */
|
||||
MediaHub.error?.let { why ->
|
||||
val err = androidx.media3.common.PlaybackException(
|
||||
why, null, androidx.media3.common.PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
)
|
||||
return SimpleBasePlayer.State.Builder()
|
||||
.setAvailableCommands(commands(null))
|
||||
.setPlaybackState(Player.STATE_IDLE)
|
||||
.setPlayerError(err)
|
||||
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
|
||||
.build()
|
||||
}
|
||||
val np = MediaHub.state
|
||||
?: return SimpleBasePlayer.State.Builder()
|
||||
.setAvailableCommands(commands(null))
|
||||
@@ -179,8 +197,20 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
lock screen shows a grey hole. media3 decodes these bytes
|
||||
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
|
||||
is one BitmapFactory call this file no longer makes. */
|
||||
val bytes = MediaHub.artBytes ?: MediaHub.appIcon(ctx)
|
||||
if (bytes != null) md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||
val bytes = MediaHub.artBytes
|
||||
val remote = MediaHub.artKey?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
||||
if (bytes != null) {
|
||||
md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||
} else if (remote != null) {
|
||||
/* A streamed record's cover is a URL and no bytes ever arrive
|
||||
for it on the headless path — there is no page to decode
|
||||
one. media3's own loader fetches a URI, so the car gets
|
||||
the sleeve and the colour it washes the screen with,
|
||||
instead of the app icon's blue. */
|
||||
md.setArtworkUri(Uri.parse(remote))
|
||||
} else {
|
||||
MediaHub.appIcon(ctx)?.let { md.setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
|
||||
}
|
||||
} else if (!artKey.isNullOrEmpty()) {
|
||||
/* A LOCAL path only. A catalog record's art key is an http URL,
|
||||
and media3's default loader would happily fetch it — twenty
|
||||
@@ -256,7 +286,11 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
"SoulStream",
|
||||
"auto playWhenReady=" + playWhenReady + " headless=" + headless(),
|
||||
)
|
||||
if (headless()) {
|
||||
if (headless() || !MediaHub.warm()) {
|
||||
/* the engine's own queue, or a page that is gone (the app swiped
|
||||
away under live audio): pause and resume belong to the engine
|
||||
either way, and a message to a WebView that no longer exists
|
||||
is the button doing nothing */
|
||||
AutoNative.transport(if (playWhenReady) "resume" else "pause")
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
@@ -312,8 +346,26 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
|
||||
val id = mediaItems.getOrNull(i)?.mediaId
|
||||
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
|
||||
// The position media3 asks for: a resumption carries the saved one, a
|
||||
// tapped row carries TIME_UNSET. Below zero tells Rust "where it was
|
||||
// left", which only the resume row has an answer to.
|
||||
val startMs = if (startPositionMs == C.TIME_UNSET || startPositionMs < 0) -1L else startPositionMs
|
||||
return Futures.submit<Unit>(
|
||||
{
|
||||
// THE APP IS UP: the page owns the queue, so the tap is the
|
||||
// page's — whatever the row is. Starting it on the engine
|
||||
// underneath the page instead (which is what this did for a
|
||||
// library album while the page had a queue of its own) left
|
||||
// two drivers on one engine: the bar naming one track while
|
||||
// another played, and the next boundary decided by whichever
|
||||
// got there first. The page asks for focus itself when it
|
||||
// plays, and reports back through remote_failed if it cannot.
|
||||
if (MediaHub.warm()) {
|
||||
val desc = AutoNative.describe(ctx.dataDir.absolutePath, id)
|
||||
android.util.Log.i("SoulStream", "auto hand-off to the app: $id -> $desc")
|
||||
MediaHub.play(desc, startMs)
|
||||
return@submit
|
||||
}
|
||||
// FOCUS FIRST, and it is not optional. Without it the track
|
||||
// opens into a port the framework keeps muted: the first run
|
||||
// of this path showed a live AAudio track on our uid with
|
||||
@@ -321,25 +373,27 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
// audible to nobody. On the WebView path the JS asks for
|
||||
// focus; a car tapping a cold process has no JS to do it.
|
||||
MediaHub.requestFocus(ctx.applicationContext)
|
||||
val reply = AutoNative.play(ctx.dataDir.absolutePath, id)
|
||||
val reply = AutoNative.play(ctx.dataDir.absolutePath, id, startMs)
|
||||
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
|
||||
val queued = o.optInt("queued", 0)
|
||||
android.util.Log.i("SoulStream", "auto play $id queued=$queued")
|
||||
android.util.Log.i("SoulStream", "auto play $id queued=$queued start=$startMs")
|
||||
if (queued == 0) {
|
||||
// Nothing local. If the app is up, its resolver can play
|
||||
// this — a catalog row is not a broken row, it is one this
|
||||
// process cannot do alone. If the app is NOT up there is
|
||||
// nothing on the phone that could play it, and the focus
|
||||
// goes back rather than being held for silence.
|
||||
val remote = o.optBoolean("remote", false)
|
||||
if (remote && MediaHub.warm()) {
|
||||
android.util.Log.i("SoulStream", "auto hand-off to the app: $id")
|
||||
MediaHub.remote(o.optLong("playlist"), o.optLong("item"))
|
||||
} else {
|
||||
if (remote) {
|
||||
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
|
||||
}
|
||||
// Nothing on this phone can play it, and the process is
|
||||
// cold: the focus goes back rather than being held for
|
||||
// silence — and the car is TOLD, which it never was
|
||||
// before. A streamed row with no cache copy needs the
|
||||
// resolver, which needs the app.
|
||||
MediaHub.dropFocus(ctx.applicationContext)
|
||||
if (o.optBoolean("remote", false)) {
|
||||
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
|
||||
MediaHub.fail(
|
||||
ctx.applicationContext,
|
||||
"This track streams from the network. Open SoulStream on your phone to play it here.",
|
||||
)
|
||||
} else if (id == "ss:resume") {
|
||||
MediaHub.fail(ctx.applicationContext, "Nothing to continue yet.")
|
||||
} else {
|
||||
MediaHub.fail(ctx.applicationContext, "That track is not on this phone any more.")
|
||||
}
|
||||
} else {
|
||||
// Write the card ourselves. `getState()` mirrors
|
||||
@@ -354,9 +408,11 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
np.artist = o.optString("artist").ifEmpty { null }
|
||||
np.album = o.optString("album").ifEmpty { null }
|
||||
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
|
||||
np.position = 0.0
|
||||
/* a resumed track starts where it was left; the engine's
|
||||
own position takes over from the first tick */
|
||||
np.position = o.optLong("positionMs", 0L) / 1000.0
|
||||
np.playing = true
|
||||
np.hasNext = queued > 1
|
||||
np.hasNext = o.optBoolean("hasNext", queued > 1)
|
||||
np.hasPrev = false
|
||||
// The cover, which is also the colour: Android Auto washes
|
||||
// the playback screen with whatever artwork it is handed,
|
||||
@@ -379,6 +435,10 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
}
|
||||
|
||||
override fun handleStop(): ListenableFuture<*> {
|
||||
if (headless() || !MediaHub.warm()) {
|
||||
AutoNative.transport("stop")
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
MediaHub.act("stop")
|
||||
return awaitUpdate()
|
||||
}
|
||||
@@ -420,7 +480,7 @@ class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMa
|
||||
* and nothing else. */
|
||||
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
|
||||
val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
|
||||
if (headless()) {
|
||||
if (headless() || !MediaHub.warm()) {
|
||||
when (seekCommand) {
|
||||
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
|
||||
AutoNative.transport("next")
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
// event. They are commands like any other to the ACL: without them here the
|
||||
// listener was refused ("media-session.registerListener not allowed") and
|
||||
// the card's buttons went nowhere.
|
||||
const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
|
||||
// `remote_failed` is the RUST command's name, which is what the ACL keys on
|
||||
// (the invoke is `plugin:media-session|remote_failed`); the Kotlin side's
|
||||
// `remoteFailed` is reached through run_mobile_plugin and never appears here.
|
||||
const COMMANDS: &[&str] = &["update", "clear", "remote_failed", "registerListener", "removeListener"];
|
||||
|
||||
fn main() {
|
||||
// Registers the command permissions (permissions/autogenerated) and, on
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-remote-failed"
|
||||
description = "Enables the remote_failed command without any pre-configured scope."
|
||||
commands.allow = ["remote_failed"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-remote-failed"
|
||||
description = "Denies the remote_failed command without any pre-configured scope."
|
||||
commands.deny = ["remote_failed"]
|
||||
@@ -9,6 +9,7 @@ hear the card's buttons (`addPluginListener`).
|
||||
|
||||
- `allow-update`
|
||||
- `allow-clear`
|
||||
- `allow-remote-failed`
|
||||
- `allow-registerListener`
|
||||
- `allow-removeListener`
|
||||
|
||||
@@ -76,6 +77,32 @@ Denies the registerListener command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`media-session:allow-remote-failed`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the remote_failed command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`media-session:deny-remote-failed`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the remote_failed command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`media-session:allow-removeListener`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -7,4 +7,4 @@ transport, audio focus. `update` mirrors the track on the bar, `clear`
|
||||
takes the card down, and the two listener commands are what lets the page
|
||||
hear the card's buttons (`addPluginListener`).
|
||||
"""
|
||||
permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
|
||||
permissions = ["allow-update", "allow-clear", "allow-remote-failed", "allow-registerListener", "allow-removeListener"]
|
||||
@@ -318,6 +318,18 @@
|
||||
"const": "deny-registerListener",
|
||||
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remote_failed command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-remote-failed",
|
||||
"markdownDescription": "Enables the remote_failed command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remote_failed command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-remote-failed",
|
||||
"markdownDescription": "Denies the remote_failed command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the removeListener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -343,10 +355,10 @@
|
||||
"markdownDescription": "Denies the update command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
|
||||
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
|
||||
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -141,9 +141,38 @@ fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the page sends when a row the CAR handed it could not be played:
|
||||
/// the resolver found nothing, or the network is signed out. The session
|
||||
/// turns it into an error state the head unit can show, with the reason,
|
||||
/// instead of the silence a driver was getting.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteFailure {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remote_failed<R: Runtime>(app: tauri::AppHandle<R>, failure: RemoteFailure) -> Result<(), String> {
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
let session = app.state::<MediaSession<R>>();
|
||||
session
|
||||
.0
|
||||
.run_mobile_plugin::<serde_json::Value>("remoteFailed", failure)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(mobile))]
|
||||
{
|
||||
let _ = (app, failure);
|
||||
Err("the media session is a phone feature".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("media-session")
|
||||
.invoke_handler(tauri::generate_handler![update, clear])
|
||||
.invoke_handler(tauri::generate_handler![update, clear, remote_failed])
|
||||
.setup(|app, api| {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
|
||||
+349
-92
@@ -18,6 +18,11 @@
|
||||
//! Every failure path returns an empty-but-valid JSON array. A head unit that
|
||||
//! is handed nothing draws an empty folder; a head unit that is left waiting
|
||||
//! drops the app. Empty is always the better answer.
|
||||
//!
|
||||
//! Streamed playlist rows are the one place this reaches past the database:
|
||||
//! a row whose remembered peer file (`stream_sources.rs`) is byte-complete in
|
||||
//! the stream cache plays from here like a library track, because it IS a
|
||||
//! file on this phone. Everything else streamed is handed to the app.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::sys::jstring;
|
||||
@@ -29,6 +34,11 @@ use std::path::{Path, PathBuf};
|
||||
/// with a media id that happens to be a file path.
|
||||
pub const ROOT: &str = "ss:root";
|
||||
|
||||
/// The root a host asks for with `EXTRA_RECENT`: Android's own "continue
|
||||
/// where you left off" surfaces and Android Auto's resumption row. It holds
|
||||
/// exactly one playable row, the resume point, or nothing.
|
||||
pub const RECENT_ROOT: &str = "ss:recent-root";
|
||||
|
||||
/// The four tabs, in the order Kyle picked them (Recent · Albums · Artists ·
|
||||
/// Saved). Playlists is deliberately absent: the phone holds exactly one, and
|
||||
/// an empty tab is a dead quarter of the only four root slots Auto gives us.
|
||||
@@ -50,11 +60,10 @@ struct Node {
|
||||
subtitle: String,
|
||||
browsable: bool,
|
||||
playable: bool,
|
||||
/// Absolute path to a cover, when the row has one. Kotlin decides what a
|
||||
/// Absolute path to a cover, or an https URL. Kotlin decides what a
|
||||
/// path can become on the car's side; this layer only reports it.
|
||||
art: Option<String>,
|
||||
/// Absolute path to the audio file, for playable rows. Round fifty-one is
|
||||
/// what does anything with it.
|
||||
/// Absolute path to the audio file, for playable rows.
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
@@ -264,7 +273,7 @@ fn saved(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
fn recent(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
|
||||
// RECENTLY PLAYED ALBUMS, not tracks. A car's "Recent" is "put that back
|
||||
// on", and what a driver means by that is the record — a list of single
|
||||
// songs plays one and stops.
|
||||
@@ -274,8 +283,15 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
// that has since left the library drops out of the join, which is right
|
||||
// for a row whose whole promise is that tapping it works.
|
||||
let mut out: Vec<Node> = Vec::new();
|
||||
// First of all: where playback was left. It is the row a driver wants
|
||||
// most often, and it is the only one that knows a position.
|
||||
out.extend(resume_row(data_dir));
|
||||
// The count is the RECORD's tracks, not how many of them were played —
|
||||
// "Daft Punk · 3 tracks" under a thirteen-track album was the played
|
||||
// count leaking into the subtitle.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT a.id, a.title, a.artist, a.art_path, COUNT(DISTINCT t.id), MAX(p.started_at)
|
||||
"SELECT a.id, a.title, a.artist, a.art_path,
|
||||
(SELECT COUNT(*) FROM tracks x WHERE x.album_id = a.id), MAX(p.started_at)
|
||||
FROM plays p
|
||||
JOIN tracks t
|
||||
ON t.title = p.title COLLATE NOCASE
|
||||
@@ -304,7 +320,7 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
out.extend(rows.flatten());
|
||||
}
|
||||
|
||||
out.extend(playlists(conn).unwrap_or_default());
|
||||
out.extend(playlists(conn, data_dir).unwrap_or_default());
|
||||
|
||||
// A library that has never been played is not an empty shelf. On a fresh
|
||||
// install — or a phone whose history was wiped — "Recent" would otherwise
|
||||
@@ -316,39 +332,89 @@ fn recent(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The one row the recent root holds: "continue", with the track that was
|
||||
/// playing, where it was. Nothing when there is no resume point or none of
|
||||
/// it can play from here.
|
||||
fn resume_row(data_dir: &Path) -> Option<Node> {
|
||||
let (tracks, idx, position, from) = crate::headless::resume_queue(data_dir)?;
|
||||
let t = tracks.get(idx)?;
|
||||
let mins = (position / 60.0).floor() as i64;
|
||||
let secs = (position % 60.0).floor() as i64;
|
||||
let at = if position > 0.5 { format!(" · {mins}:{secs:02}") } else { String::new() };
|
||||
let source = if from.is_empty() { t.artist.clone() } else { from };
|
||||
Some(Node {
|
||||
id: crate::headless::RESUME_ID.to_owned(),
|
||||
title: t.title.clone(),
|
||||
subtitle: format!("Continue · {source}{at}"),
|
||||
browsable: false,
|
||||
playable: true,
|
||||
art: t.art.clone(),
|
||||
path: Some(t.path.to_string_lossy().into_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A playlist row's playable file from this process: its local path when it
|
||||
/// has one, else the byte-complete cache copy of its remembered peer file.
|
||||
fn row_file(
|
||||
data_dir: &Path,
|
||||
kind: &str,
|
||||
path: Option<&str>,
|
||||
track_id: Option<&str>,
|
||||
artist: &str,
|
||||
title: &str,
|
||||
) -> Option<(PathBuf, Option<crate::resume::StreamRef>)> {
|
||||
if kind == "local" {
|
||||
return path.map(|p| (PathBuf::from(p), None));
|
||||
}
|
||||
if kind != "stream" {
|
||||
return None;
|
||||
}
|
||||
let src = crate::stream_sources::lookup(data_dir, track_id, Some(artist), Some(title))?;
|
||||
let file = crate::stream_sources::cached_file(data_dir, &src)?;
|
||||
Some((
|
||||
file,
|
||||
Some(crate::resume::StreamRef {
|
||||
username: src.username,
|
||||
path: src.path,
|
||||
size: src.size,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Playlists, newest first, as browsable rows.
|
||||
///
|
||||
/// Counted out of `playlist_items` rather than `playlist_tracks`: it is the
|
||||
/// richer table, and the one that knows whether an entry is a local file
|
||||
/// (`kind`/`path`) or something that needs the resolver.
|
||||
fn playlists(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
/// (`kind`/`path`) or something that needs the resolver. A streamed entry
|
||||
/// whose cache copy is whole counts as here.
|
||||
fn playlists(conn: &Connection, data_dir: &Path) -> rusqlite::Result<Vec<Node>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.id, p.name, COUNT(i.id),
|
||||
SUM(CASE WHEN i.kind = 'local' AND i.path IS NOT NULL THEN 1 ELSE 0 END),
|
||||
"SELECT p.id, p.name,
|
||||
-- A streamed entry has no cover FILE, it has a remote URL.
|
||||
-- Auto fetches an https artwork uri itself, so the row gets a
|
||||
-- sleeve either way rather than a grey tile.
|
||||
COALESCE(MAX(i.art_path), MAX(i.art_url))
|
||||
FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id
|
||||
GROUP BY p.id
|
||||
(SELECT COALESCE(i.art_path, i.art_url) FROM playlist_items i
|
||||
WHERE i.playlist_id = p.id AND COALESCE(i.art_path, i.art_url) IS NOT NULL
|
||||
ORDER BY i.position LIMIT 1)
|
||||
FROM playlists p
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([ROW_CAP as i64], |r| {
|
||||
let id: i64 = r.get(0)?;
|
||||
let name: String = r.get(1)?;
|
||||
let n: i64 = r.get(2)?;
|
||||
let local: i64 = r.get(3).unwrap_or(0);
|
||||
let art: Option<String> = r.get(4)?;
|
||||
let heads: Vec<(i64, String, Option<String>)> = stmt
|
||||
.query_map([ROW_CAP as i64], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
let mut out = Vec::with_capacity(heads.len());
|
||||
for (id, name, art) in heads {
|
||||
let (n, here) = playlist_counts(conn, data_dir, id)?;
|
||||
// Say plainly when part of a playlist cannot play here. A row that
|
||||
// silently plays four of its nine tracks is worse than one that says
|
||||
// so before it is tapped.
|
||||
let sub = if local < n {
|
||||
format!("Playlist · {} of {} here", local, n)
|
||||
let sub = if here < n {
|
||||
format!("Playlist · {} of {} here", here, n)
|
||||
} else {
|
||||
format!("Playlist · {}", plural(n, "track", "tracks"))
|
||||
};
|
||||
Ok(Node {
|
||||
out.push(Node {
|
||||
id: format!("ss:playlist:{id}"),
|
||||
title: name,
|
||||
subtitle: sub,
|
||||
@@ -356,17 +422,44 @@ fn playlists(conn: &Connection) -> rusqlite::Result<Vec<Node>> {
|
||||
playable: false,
|
||||
art,
|
||||
path: None,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A playlist's entries, in order. Only the local ones are playable; the rest
|
||||
/// need the resolver, which does not exist in a process with no WebView, and
|
||||
/// are shown rather than hidden so the list matches the phone's.
|
||||
fn playlist_items(conn: &Connection, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
|
||||
/// (all rows, rows that play from this process) for one playlist.
|
||||
fn playlist_counts(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<(i64, i64)> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind
|
||||
"SELECT kind, path, track_id, artist, title FROM playlist_items WHERE playlist_id = ?1",
|
||||
)?;
|
||||
let mut n = 0i64;
|
||||
let mut here = 0i64;
|
||||
let rows = stmt.query_map([playlist_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, Option<String>>(1)?,
|
||||
r.get::<_, Option<String>>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows.flatten() {
|
||||
n += 1;
|
||||
let (kind, path, track_id, artist, title) = row;
|
||||
if row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title).is_some() {
|
||||
here += 1;
|
||||
}
|
||||
}
|
||||
Ok((n, here))
|
||||
}
|
||||
|
||||
/// A playlist's entries, in order. The local ones and the streamed ones whose
|
||||
/// cache copy is whole are playable; the rest need the resolver, which does
|
||||
/// not exist in a process with no WebView, and are shown rather than hidden
|
||||
/// so the list matches the phone's.
|
||||
fn playlist_items(conn: &Connection, data_dir: &Path, playlist_id: i64) -> rusqlite::Result<Vec<Node>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, artist, album, path, COALESCE(art_path, art_url), kind, track_id
|
||||
FROM playlist_items
|
||||
WHERE playlist_id = ?1
|
||||
ORDER BY position",
|
||||
@@ -379,20 +472,34 @@ fn playlist_items(conn: &Connection, playlist_id: i64) -> rusqlite::Result<Vec<N
|
||||
let path: Option<String> = r.get(4)?;
|
||||
let art: Option<String> = r.get(5)?;
|
||||
let kind: String = r.get(6)?;
|
||||
let local = kind == "local" && path.is_some();
|
||||
let sub = match (&album, local) {
|
||||
(_, false) => format!("{artist} · on your phone"),
|
||||
(Some(al), true) if !al.is_empty() => format!("{artist} · {al}"),
|
||||
let track_id: Option<String> = r.get(7)?;
|
||||
let file = row_file(data_dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title);
|
||||
// Every real row is PLAYABLE, whether or not its file is here: a
|
||||
// non-playable, non-browsable item is drawn by the host as a bare
|
||||
// tile in a list of rows (the first head-unit run showed four rows
|
||||
// and thirty-six tiles). The row that will need the app says so in
|
||||
// its subtitle, and a tap on it is the hand-off (warm) or the error
|
||||
// card (cold) — never a silent nothing. Kotlin marks the ones whose
|
||||
// file is here with the host's own "downloaded" indicator.
|
||||
let unresolved = kind == "unresolved";
|
||||
let sub = if unresolved {
|
||||
format!("{artist} · unresolved")
|
||||
} else if file.is_none() {
|
||||
format!("{artist} · needs the app")
|
||||
} else {
|
||||
match &album {
|
||||
Some(al) if !al.is_empty() => format!("{artist} · {al}"),
|
||||
_ => artist.clone(),
|
||||
}
|
||||
};
|
||||
Ok(Node {
|
||||
id: format!("ss:pitem:{id}"),
|
||||
title,
|
||||
subtitle: sub,
|
||||
browsable: false,
|
||||
playable: local,
|
||||
playable: !unresolved,
|
||||
art,
|
||||
path,
|
||||
path: file.map(|(p, _)| p.to_string_lossy().into_owned()),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
@@ -418,13 +525,13 @@ fn track_node(r: &rusqlite::Row<'_>) -> rusqlite::Result<Node> {
|
||||
/// The whole tree, dispatched on the parent id. Unknown ids answer with
|
||||
/// nothing, which is what a head unit asking about a node we have since
|
||||
/// stopped publishing should get.
|
||||
fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
fn children(conn: &Connection, data_dir: &Path, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
match parent {
|
||||
ROOT => Ok(tabs()),
|
||||
"ss:albums" => albums(conn),
|
||||
"ss:artists" => artists(conn),
|
||||
"ss:saved" => saved(conn),
|
||||
"ss:recent" => recent(conn),
|
||||
"ss:recent" => recent(conn, data_dir),
|
||||
_ => {
|
||||
if let Some(id) = parent.strip_prefix("ss:album:") {
|
||||
match id.parse::<i64>() {
|
||||
@@ -433,7 +540,7 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
}
|
||||
} else if let Some(id) = parent.strip_prefix("ss:playlist:") {
|
||||
match id.parse::<i64>() {
|
||||
Ok(n) => playlist_items(conn, n),
|
||||
Ok(n) => playlist_items(conn, data_dir, n),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
} else if let Some(name) = parent.strip_prefix("ss:artist:") {
|
||||
@@ -448,7 +555,7 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
}
|
||||
|
||||
/// Turn a media id from the browse tree into something playable: the files, in
|
||||
/// order, and which one the tap meant.
|
||||
/// order, which one the tap meant, and the name of what it came from.
|
||||
///
|
||||
/// A tapped TRACK brings its whole album with it at the right index, because
|
||||
/// "the album keeps going" is what a record player does and what a driver
|
||||
@@ -461,9 +568,10 @@ fn children(conn: &Connection, parent: &str) -> rusqlite::Result<Vec<Node>> {
|
||||
pub(crate) fn resolve_playable(
|
||||
data_dir: &str,
|
||||
media_id: &str,
|
||||
) -> Option<(Vec<crate::headless::PlayTrack>, usize)> {
|
||||
) -> Option<(Vec<crate::headless::PlayTrack>, usize, String)> {
|
||||
use crate::headless::PlayTrack;
|
||||
let db = db_path(Path::new(data_dir));
|
||||
let dir = Path::new(data_dir);
|
||||
let db = db_path(dir);
|
||||
let conn = match open_read_only(&db) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -471,7 +579,7 @@ pub(crate) fn resolve_playable(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let album_tracks = |album_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
|
||||
let album_tracks = |album_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.id, t.path, t.title, t.artist, t.duration_secs, a.title, a.art_path
|
||||
FROM tracks t JOIN albums a ON a.id = t.album_id
|
||||
@@ -479,54 +587,84 @@ pub(crate) fn resolve_playable(
|
||||
ORDER BY t.disc_no, t.track_no, t.title",
|
||||
)?;
|
||||
let rows = stmt.query_map([album_id], |r| {
|
||||
let id = r.get::<_, i64>(0)?;
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
id,
|
||||
PlayTrack {
|
||||
id: Some(id.to_string()),
|
||||
path: PathBuf::from(r.get::<_, String>(1)?),
|
||||
title: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
||||
album: r.get(5)?,
|
||||
art: r.get(6)?,
|
||||
stream: None,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
let rows: Vec<(i64, PlayTrack)> = rows.collect::<rusqlite::Result<_>>()?;
|
||||
let from = rows.first().map(|(_, t)| t.album.clone()).unwrap_or_default();
|
||||
Ok((rows, from))
|
||||
};
|
||||
|
||||
if let Some(id) = media_id.strip_prefix("ss:album:") {
|
||||
let rows = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
|
||||
let (rows, from) = album_tracks(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
||||
}
|
||||
// A playlist plays its LOCAL entries, in order, skipping the ones that
|
||||
// need the resolver. Silently dropping them is the right call here: the
|
||||
// row that led here already said "4 of 9 here", so the gap was declared
|
||||
// before the tap rather than discovered after it.
|
||||
let playlist_local = |playlist_id: i64| -> rusqlite::Result<Vec<(i64, PlayTrack)>> {
|
||||
// A playlist plays the entries this process can: its local files and the
|
||||
// streamed rows whose cache copy is whole, in order, skipping the ones
|
||||
// that need the resolver. Silently dropping those is the right call
|
||||
// here: the row that led here already said "4 of 9 here", so the gap was
|
||||
// declared before the tap rather than discovered after it.
|
||||
let playlist_here = |playlist_id: i64| -> rusqlite::Result<(Vec<(i64, PlayTrack)>, String)> {
|
||||
let name: String = conn
|
||||
.query_row("SELECT name FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0))
|
||||
.unwrap_or_default();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, artist, album, duration_secs, path, art_path
|
||||
"SELECT id, title, artist, album, duration_secs, path, COALESCE(art_path, art_url), kind, track_id
|
||||
FROM playlist_items
|
||||
WHERE playlist_id = ?1 AND kind = 'local' AND path IS NOT NULL
|
||||
WHERE playlist_id = ?1
|
||||
ORDER BY position",
|
||||
)?;
|
||||
let rows = stmt.query_map([playlist_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
PlayTrack {
|
||||
title: r.get(1)?,
|
||||
artist: r.get(2)?,
|
||||
album: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
|
||||
duration: r.get::<_, Option<i64>>(4)?.map(|s| s as f64),
|
||||
path: PathBuf::from(r.get::<_, String>(5)?),
|
||||
art: r.get(6)?,
|
||||
},
|
||||
))
|
||||
let id: i64 = r.get(0)?;
|
||||
let title: String = r.get(1)?;
|
||||
let artist: String = r.get(2)?;
|
||||
let album: Option<String> = r.get(3)?;
|
||||
let duration = r.get::<_, Option<i64>>(4)?.map(|s| s as f64);
|
||||
let path: Option<String> = r.get(5)?;
|
||||
let art: Option<String> = r.get(6)?;
|
||||
let kind: String = r.get(7)?;
|
||||
let track_id: Option<String> = r.get(8)?;
|
||||
Ok((id, title, artist, album, duration, path, art, kind, track_id))
|
||||
})?;
|
||||
rows.collect()
|
||||
let mut out = Vec::new();
|
||||
for row in rows.flatten() {
|
||||
let (id, title, artist, album, duration, path, art, kind, track_id) = row;
|
||||
let Some((file, stream)) =
|
||||
row_file(dir, &kind, path.as_deref(), track_id.as_deref(), &artist, &title)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
out.push((
|
||||
id,
|
||||
PlayTrack {
|
||||
id: Some(format!("pl{id}")),
|
||||
path: file,
|
||||
title,
|
||||
artist,
|
||||
album: album.unwrap_or_default(),
|
||||
duration,
|
||||
art,
|
||||
stream,
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok((out, name))
|
||||
};
|
||||
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
||||
let rows = playlist_local(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0));
|
||||
let (rows, from) = playlist_here(id.parse::<i64>().ok()?).ok()?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), 0, from));
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
|
||||
let item_id = id.parse::<i64>().ok()?;
|
||||
@@ -537,9 +675,12 @@ pub(crate) fn resolve_playable(
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()?;
|
||||
let rows = playlist_local(playlist_id).ok()?;
|
||||
let idx = rows.iter().position(|(i, _)| *i == item_id).unwrap_or(0);
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
|
||||
let (rows, from) = playlist_here(playlist_id).ok()?;
|
||||
// The tapped row itself must be playable here, or the tap means a
|
||||
// hand-off — starting the playlist from a different row than the one
|
||||
// under the driver's finger would be a lie.
|
||||
let idx = rows.iter().position(|(i, _)| *i == item_id)?;
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:track:") {
|
||||
let track_id = id.parse::<i64>().ok()?;
|
||||
@@ -548,9 +689,9 @@ pub(crate) fn resolve_playable(
|
||||
r.get(0)
|
||||
})
|
||||
.ok()?;
|
||||
let rows = album_tracks(album_id).ok()?;
|
||||
let (rows, from) = album_tracks(album_id).ok()?;
|
||||
let idx = rows.iter().position(|(id, _)| *id == track_id).unwrap_or(0);
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx));
|
||||
return Some((rows.into_iter().map(|(_, t)| t).collect(), idx, from));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -639,9 +780,16 @@ fn browse_json(data_dir: &str, parent: &str) -> String {
|
||||
if parent == ROOT {
|
||||
return rows(&tabs());
|
||||
}
|
||||
let db = db_path(Path::new(data_dir));
|
||||
let dir = Path::new(data_dir);
|
||||
// Nor does the recent root: it is one row out of resume.json, and a
|
||||
// host asking for it is asking "can you continue?", not "show me the
|
||||
// library".
|
||||
if parent == RECENT_ROOT {
|
||||
return rows(&resume_row(dir).into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
let db = db_path(dir);
|
||||
let nodes = match open_read_only(&db) {
|
||||
Ok(conn) => children(&conn, parent).unwrap_or_else(|e| {
|
||||
Ok(conn) => children(&conn, dir, parent).unwrap_or_else(|e| {
|
||||
warn(&format!("auto browse {parent}: {e}"));
|
||||
Vec::new()
|
||||
}),
|
||||
@@ -663,7 +811,7 @@ fn rows(nodes: &[Node]) -> String {
|
||||
/// A cold service process has never run Tauri, so nothing has installed an
|
||||
/// `android_logger` and `log::warn!` goes nowhere — which is why the first
|
||||
/// failure here was silent. Write to the platform log directly.
|
||||
fn warn(msg: &str) {
|
||||
pub(crate) fn warn(msg: &str) {
|
||||
let tag = std::ffi::CString::new("SoulStream").unwrap_or_default();
|
||||
if let Ok(m) = std::ffi::CString::new(msg) {
|
||||
// SAFETY: both pointers are valid NUL-terminated strings for the call.
|
||||
@@ -697,7 +845,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let par: Option<String> = env.get_string(&parent).ok().map(Into::into);
|
||||
let out = match (dir, par) {
|
||||
(Some(d), Some(p)) => browse_json(&d, &p),
|
||||
(Some(d), Some(p)) => {
|
||||
crate::headless::set_data_dir(Path::new(&d));
|
||||
browse_json(&d, &p)
|
||||
}
|
||||
_ => "[]".to_owned(),
|
||||
};
|
||||
// Even the hand-back has to not panic across the boundary: an empty array
|
||||
@@ -708,25 +859,28 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse(
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.play(dataDir, mediaId)` — start playback from a browse row, in
|
||||
/// a process that may have no Activity and no WebView.
|
||||
/// `AutoNative.play(dataDir, mediaId, startMs)` — start playback from a
|
||||
/// browse row, in a process that may have no Activity and no WebView.
|
||||
/// `startMs` below zero means "where it was left" (the resume row) or the
|
||||
/// top (everything else).
|
||||
///
|
||||
/// Returns how many tracks were queued, so Kotlin can answer the car with an
|
||||
/// error state instead of silence when a row resolves to nothing. Catalog and
|
||||
/// Soulseek rows are not in the tree yet and will resolve to nothing by
|
||||
/// construction, which is the correct answer for them: they need the resolver,
|
||||
/// which needs the WebView.
|
||||
/// Soulseek rows with no cache copy resolve to nothing by construction, which
|
||||
/// is the correct answer for them: they need the resolver, which needs the
|
||||
/// WebView.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
data_dir: JString,
|
||||
media_id: JString,
|
||||
start_ms: jni::sys::jlong,
|
||||
) -> jstring {
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
||||
let out = match (dir, id) {
|
||||
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m),
|
||||
(Some(d), Some(m)) => crate::headless::play_media_id(&d, &m, start_ms),
|
||||
_ => "{\"queued\":0}".to_owned(),
|
||||
};
|
||||
env.new_string(out)
|
||||
@@ -735,6 +889,30 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_play(
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.resumeInfo(dataDir)` — what would resume, and where, as one
|
||||
/// JSON object; `{"ok":false}` when nothing can. Never starts anything:
|
||||
/// Android's own resumption surfaces ask this to DRAW a card long before
|
||||
/// anyone presses play on it.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_resumeInfo(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
data_dir: JString,
|
||||
) -> jstring {
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let out = match dir {
|
||||
Some(d) => {
|
||||
crate::headless::set_data_dir(Path::new(&d));
|
||||
crate::headless::resume_info_json(Path::new(&d))
|
||||
}
|
||||
None => "{\"ok\":false}".to_owned(),
|
||||
};
|
||||
env.new_string(out)
|
||||
.or_else(|_| env.new_string("{\"ok\":false}"))
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `AutoNative.search(dataDir, query)` — rows for a typed or spoken query,
|
||||
/// same JSON shape as a browse.
|
||||
#[no_mangle]
|
||||
@@ -756,13 +934,88 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_search(
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// What a media id IS, for the page.
|
||||
///
|
||||
/// While the app is up the page owns the queue — shuffle, repeat, failover,
|
||||
/// the resolver, the session it restores — so a car's tap is handed to it
|
||||
/// whole rather than started on the engine underneath it, which would leave
|
||||
/// the bar naming one track while another played. The page cannot parse
|
||||
/// our ids and should not: this turns one into the thing the page already
|
||||
/// knows how to play, and the id stays a thing the Rust side minted.
|
||||
pub(crate) fn describe_json(data_dir: &str, media_id: &str) -> String {
|
||||
if media_id == crate::headless::RESUME_ID {
|
||||
return serde_json::json!({ "kind": "resume" }).to_string();
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:album:") {
|
||||
if let Ok(n) = id.parse::<i64>() {
|
||||
return serde_json::json!({ "kind": "album", "albumId": n }).to_string();
|
||||
}
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
||||
if let Ok(n) = id.parse::<i64>() {
|
||||
return serde_json::json!({ "kind": "playlist", "playlistId": n, "itemId": 0 }).to_string();
|
||||
}
|
||||
}
|
||||
let conn = match open_read_only(&db_path(Path::new(data_dir))) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return serde_json::json!({ "kind": "unknown" }).to_string(),
|
||||
};
|
||||
if let Some(id) = media_id.strip_prefix("ss:track:") {
|
||||
if let Ok(track) = id.parse::<i64>() {
|
||||
if let Ok(album) = conn.query_row(
|
||||
"SELECT album_id FROM tracks WHERE id = ?1",
|
||||
[track],
|
||||
|r| r.get::<_, i64>(0),
|
||||
) {
|
||||
return serde_json::json!({ "kind": "track", "albumId": album, "trackId": track })
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = media_id.strip_prefix("ss:pitem:") {
|
||||
if let Ok(item) = id.parse::<i64>() {
|
||||
if let Ok(pl) = conn.query_row(
|
||||
"SELECT playlist_id FROM playlist_items WHERE id = ?1",
|
||||
[item],
|
||||
|r| r.get::<_, i64>(0),
|
||||
) {
|
||||
return serde_json::json!({ "kind": "playlist", "playlistId": pl, "itemId": item })
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "kind": "unknown" }).to_string()
|
||||
}
|
||||
|
||||
/// `AutoNative.describe(dataDir, mediaId)` — the page's view of a browse
|
||||
/// row, for the warm hand-off. See [`describe_json`].
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_describe(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
data_dir: JString,
|
||||
media_id: JString,
|
||||
) -> jstring {
|
||||
let dir: Option<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let id: Option<String> = env.get_string(&media_id).ok().map(Into::into);
|
||||
let out = match (dir, id) {
|
||||
(Some(d), Some(m)) => describe_json(&d, &m),
|
||||
_ => "{\"kind\":\"unknown\"}".to_owned(),
|
||||
};
|
||||
env.new_string(out)
|
||||
.or_else(|_| env.new_string("{\"kind\":\"unknown\"}"))
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// What a row would need the APP for, when it cannot play from here.
|
||||
///
|
||||
/// A playlist entry with `kind='stream'` has no file on this phone — only a
|
||||
/// catalog id — and turning that into audio is the resolver's job, which is
|
||||
/// hundreds of lines of shared JS that exist only while a WebView does. So
|
||||
/// this does not try. It reports WHICH playlist and entry were meant, and the
|
||||
/// warm path hands that to the app, which already knows how to play it.
|
||||
/// A playlist entry with `kind='stream'` and no whole cache copy has no file
|
||||
/// on this phone — only a catalog id — and turning that into audio is the
|
||||
/// resolver's job, which is hundreds of lines of shared JS that exist only
|
||||
/// while a WebView does. So this does not try. It reports WHICH playlist and
|
||||
/// entry were meant, and the warm path hands that to the app, which already
|
||||
/// knows how to play it.
|
||||
pub(crate) fn remote_ref(data_dir: &str, media_id: &str) -> Option<(i64, i64)> {
|
||||
let conn = open_read_only(&db_path(Path::new(data_dir))).ok()?;
|
||||
if let Some(id) = media_id.strip_prefix("ss:playlist:") {
|
||||
@@ -840,7 +1093,10 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_positionMs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jni::sys::jlong {
|
||||
(crate::headless::audio(None).progress().0 * 1000.0) as jni::sys::jlong
|
||||
match crate::headless::audio_if_up() {
|
||||
Some(a) => (a.progress().0 * 1000.0) as jni::sys::jlong,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `AutoNative.sounding()` — whether the engine is actually playing, as
|
||||
@@ -850,16 +1106,17 @@ pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_sounding(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jni::sys::jboolean {
|
||||
u8::from(crate::headless::audio(None).progress().1)
|
||||
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.progress().1))
|
||||
}
|
||||
|
||||
/// `AutoNative.driving()` — true when the headless queue is what is playing,
|
||||
/// so Kotlin can send a transport command to Rust instead of to a WebView
|
||||
/// that may not exist.
|
||||
/// that may not exist. Asked on every card rebuild, so it must never CREATE
|
||||
/// the engine: a process with no engine is not driving.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_driving(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
) -> jni::sys::jboolean {
|
||||
u8::from(crate::headless::audio(None).driving())
|
||||
u8::from(crate::headless::audio_if_up().is_some_and(|a| a.driving()))
|
||||
}
|
||||
+304
-5
@@ -15,13 +15,21 @@ use crate::state::AppState;
|
||||
pub struct AppStatus {
|
||||
pub version: &'static str,
|
||||
pub connected_as: Option<String>,
|
||||
/// A dropped session is being dialled back (state.rs `Reconnect`).
|
||||
pub redialing: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn app_status(state: State<'_, AppState>) -> Result<AppStatus, String> {
|
||||
let redialing = state
|
||||
.reconnect
|
||||
.lock()
|
||||
.map(|r| r.dialing)
|
||||
.unwrap_or(false);
|
||||
Ok(AppStatus {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
connected_as: state.connected_as.lock().await.clone(),
|
||||
redialing,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -456,6 +464,32 @@ pub async fn connect_and_login(
|
||||
password: String,
|
||||
remember: bool,
|
||||
) -> Result<String, String> {
|
||||
// An explicit login is a new generation: whatever redial loop the last
|
||||
// session's drop may have started stands down, and the old pump's own
|
||||
// Disconnected (from the shutdown inside login_inner) schedules nothing.
|
||||
let generation = {
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.generation += 1;
|
||||
r.wanted = true;
|
||||
r.attempt = 0;
|
||||
r.dialing = false;
|
||||
r.generation
|
||||
};
|
||||
login_inner(app, username, password, remember, generation).await
|
||||
}
|
||||
|
||||
/// The login itself, shared by the user's own tap and the automatic redial
|
||||
/// (`schedule_reconnect`). `generation` is the session this login belongs
|
||||
/// to: the event pump it spawns carries it, and a drop reported by a pump
|
||||
/// of an older generation redials nothing and says nothing.
|
||||
pub(crate) async fn login_inner(
|
||||
app: tauri::AppHandle,
|
||||
username: String,
|
||||
password: String,
|
||||
remember: bool,
|
||||
generation: u64,
|
||||
) -> Result<String, String> {
|
||||
let state = app.state::<AppState>();
|
||||
// Drop any previous session first.
|
||||
if let Some(old) = state.client.lock().await.take() {
|
||||
old.shutdown();
|
||||
@@ -533,6 +567,12 @@ pub async fn connect_and_login(
|
||||
|
||||
*state.client.lock().await = Some(client);
|
||||
*state.connected_as.lock().await = Some(username.clone());
|
||||
{
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.connected_at = crate::resume::now_secs();
|
||||
r.dialing = false;
|
||||
r.wanted = true;
|
||||
}
|
||||
|
||||
// Forward client events to the webview for the life of this session.
|
||||
let event_app = app.clone();
|
||||
@@ -855,10 +895,45 @@ pub async fn connect_and_login(
|
||||
}
|
||||
}
|
||||
Event::Disconnected { reason } => {
|
||||
let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason });
|
||||
// A pump outlives the session it served for a moment:
|
||||
// login_inner shuts the old client down and its reader
|
||||
// reports the drop AFTER the new session has logged in.
|
||||
// Until 2026-09-18 that stale drop stamped the fresh
|
||||
// session offline and cleared its username. Only the
|
||||
// pump of the current generation may say "disconnected".
|
||||
let (current, wanted) = event_app
|
||||
.try_state::<AppState>()
|
||||
.and_then(|st| st.reconnect.lock().ok().map(|r| (r.generation, r.wanted)))
|
||||
.unwrap_or((generation, true));
|
||||
if current != generation {
|
||||
// Two ways to be stale: replaced by a newer login
|
||||
// (say nothing — the page already heard "connected"
|
||||
// from it), or ended by the user's own Disconnect,
|
||||
// which the page still deserves to hear.
|
||||
if !wanted {
|
||||
let _ = event_app.emit(
|
||||
"soulseek:state",
|
||||
UiEvent::Disconnected {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
log::info!("soulseek: an older session's pump ended ({reason})");
|
||||
break;
|
||||
}
|
||||
log::warn!("soulseek: disconnected ({reason})");
|
||||
let _ = event_app.emit(
|
||||
"soulseek:state",
|
||||
UiEvent::Disconnected {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
);
|
||||
if let Some(st) = event_app.try_state::<AppState>() {
|
||||
*st.connected_as.lock().await = None;
|
||||
}
|
||||
// The drop belongs to THIS session: dial it back unless
|
||||
// the user ended it on purpose.
|
||||
schedule_reconnect(event_app.clone(), generation);
|
||||
break;
|
||||
}
|
||||
Event::ExcludedSearchPhrases(p) => {
|
||||
@@ -1033,6 +1108,209 @@ fn now_ms() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The wait before redial number `attempt` (1-based), in seconds.
|
||||
///
|
||||
/// 5 · 15 · 45 · 120 · 300 · 300 …, each with up to a fifth of jitter so
|
||||
/// two instances on one address never dial in step. The server drops the
|
||||
/// handshake of anyone who logs in eight to ten times in quick succession
|
||||
/// (paid for on 2026-08-03), and eight of these span a quarter of an hour.
|
||||
fn redial_wait_secs(attempt: u32) -> u64 {
|
||||
let base: u64 = match attempt {
|
||||
0 | 1 => 5,
|
||||
2 => 15,
|
||||
3 => 45,
|
||||
4 => 120,
|
||||
_ => 300,
|
||||
};
|
||||
let jitter = (now_ms() % 1000) * base / 5000; // 0 ..= base/5
|
||||
base + jitter
|
||||
}
|
||||
|
||||
/// Dial the dropped session back, on a schedule the server tolerates.
|
||||
///
|
||||
/// One loop per generation: it stands down the moment the user connects or
|
||||
/// disconnects by hand (either bumps the generation), the moment a login
|
||||
/// lands (its own or anyone's), and when there are no stored credentials to
|
||||
/// dial with. Every attempt is announced to the page as
|
||||
/// `soulseek:state { kind: "reconnecting", attempt, in_secs }`, so the
|
||||
/// network row can say "reconnecting…" instead of "offline".
|
||||
///
|
||||
/// The attempt counter is reset by a drop that FOLLOWED a stable session
|
||||
/// (two minutes or more), and kept by a drop that followed a redial which
|
||||
/// lasted seconds — so a link that flaps keeps backing off rather than
|
||||
/// hammering.
|
||||
pub(crate) fn schedule_reconnect(app: tauri::AppHandle, generation: u64) {
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !r.wanted || r.generation != generation || r.dialing {
|
||||
return;
|
||||
}
|
||||
let now = crate::resume::now_secs();
|
||||
if r.connected_at > 0 && now.saturating_sub(r.connected_at) >= 120 {
|
||||
r.attempt = 0;
|
||||
}
|
||||
r.dialing = true;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
let (attempt, wait) = {
|
||||
let state = app.state::<AppState>();
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !r.wanted || r.generation != generation {
|
||||
r.dialing = false;
|
||||
return;
|
||||
}
|
||||
r.attempt += 1;
|
||||
(r.attempt, redial_wait_secs(r.attempt))
|
||||
};
|
||||
log::info!("soulseek: redial {attempt} in {wait}s");
|
||||
let _ = app.emit(
|
||||
"soulseek:state",
|
||||
serde_json::json!({ "kind": "reconnecting", "attempt": attempt, "in_secs": wait }),
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let stale = {
|
||||
let r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
!r.wanted || r.generation != generation
|
||||
};
|
||||
if stale || state.connected_as.lock().await.is_some() {
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.dialing = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let cfg = config::load(&app);
|
||||
let Some(password) = config::load_password(&cfg.username).filter(|_| !cfg.username.is_empty())
|
||||
else {
|
||||
// Nothing to dial with: the user signed in without
|
||||
// remembering. Say so once and stop wanting.
|
||||
log::warn!("soulseek: cannot redial, no stored credentials");
|
||||
let state = app.state::<AppState>();
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.wanted = false;
|
||||
r.dialing = false;
|
||||
return;
|
||||
};
|
||||
match login_inner(app.clone(), cfg.username.clone(), password, false, generation).await {
|
||||
Ok(_) => {
|
||||
log::info!("soulseek: redial {attempt} succeeded");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("soulseek: redial {attempt} failed: {e}");
|
||||
// login_inner cleared connected_as; the loop goes round.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// DEBUG ONLY: sever the live server connection the way a network drop
|
||||
/// would, without touching the user's intent — so the redial can be watched
|
||||
/// from the page. A release build refuses.
|
||||
#[tauri::command]
|
||||
pub async fn debug_drop_connection(state: State<'_, AppState>) -> Result<(), String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let guard = state.client.lock().await;
|
||||
let client = guard.as_ref().ok_or("not connected")?;
|
||||
client.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = state;
|
||||
Err("debug builds only".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The page naming its queue for the resume point (`resume.rs`): the window
|
||||
/// around the current track, the index, the position, and what the queue
|
||||
/// was built from. A no-op off Android — the desktop restores from its own
|
||||
/// session and nothing there starts the app without a page.
|
||||
#[tauri::command]
|
||||
pub fn playback_note_queue(
|
||||
app: tauri::AppHandle,
|
||||
items: Vec<crate::resume::ResumeItem>,
|
||||
idx: usize,
|
||||
position: f64,
|
||||
from: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let rp = crate::resume::ResumePoint {
|
||||
items,
|
||||
idx,
|
||||
position: position.max(0.0),
|
||||
from: from.unwrap_or_default(),
|
||||
updated_at: crate::resume::now_secs(),
|
||||
};
|
||||
if let Ok(dir) = crate::paths::app_data(&app) {
|
||||
crate::headless::set_data_dir(&dir);
|
||||
}
|
||||
// An empty queue is a no-op, never a clear: a page with nothing on
|
||||
// its bar is the state of every boot before the session is restored,
|
||||
// and a stale resume point costs a wrong "Continue" where a wiped one
|
||||
// costs the feature (2026-09-18).
|
||||
if rp.items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
match crate::headless::audio_if_up() {
|
||||
Some(audio) => audio.note_queue(rp),
|
||||
None => {
|
||||
if let Ok(dir) = crate::paths::app_data(&app) {
|
||||
crate::resume::save(&dir, &rp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (app, items, idx, position, from);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The playlists kept offline (config.rs `offline_playlists`).
|
||||
#[tauri::command]
|
||||
pub fn playlist_offline_list(app: tauri::AppHandle) -> Vec<i64> {
|
||||
config::load(&app).offline_playlists
|
||||
}
|
||||
|
||||
/// Keep a playlist offline, or stop. The fetching itself is the page's
|
||||
/// (src/mobile/offline.svelte.js) — this only records the wish.
|
||||
#[tauri::command]
|
||||
pub fn playlist_offline_set(app: tauri::AppHandle, id: i64, keep: bool) -> Vec<i64> {
|
||||
let mut cfg = config::load(&app);
|
||||
cfg.offline_playlists.retain(|p| *p != id);
|
||||
if keep {
|
||||
cfg.offline_playlists.push(id);
|
||||
}
|
||||
config::save(&app, &cfg);
|
||||
cfg.offline_playlists
|
||||
}
|
||||
|
||||
/// Where playback was left (`resume.rs`), for the page: a car's PLAY into a
|
||||
/// warm app with nothing on the bar is handed to the page as "resume", and
|
||||
/// the page builds its queue from this. `None` when there is nothing, and
|
||||
/// always `None` off Android.
|
||||
#[tauri::command]
|
||||
pub fn playback_resume_point(app: tauri::AppHandle) -> Option<crate::resume::ResumePoint> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
crate::paths::app_data(&app).ok().and_then(|d| crate::resume::load(&d))
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = app;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"username": u.username,
|
||||
@@ -4073,6 +4351,14 @@ pub async fn catalog_album_info(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
// The user's own choice: the redial stands down and the pump's coming
|
||||
// Disconnected is an older generation's, which schedules nothing.
|
||||
{
|
||||
let mut r = state.reconnect.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.wanted = false;
|
||||
r.generation += 1;
|
||||
r.dialing = false;
|
||||
}
|
||||
if let Some(client) = state.client.lock().await.take() {
|
||||
client.shutdown();
|
||||
}
|
||||
@@ -5317,12 +5603,25 @@ pub async fn waveform(
|
||||
/// under the words "Nothing playing". This is the boot-time answer, the same
|
||||
/// shape of recovery `app_status` already provides for the Soulseek connection.
|
||||
#[tauri::command]
|
||||
pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot {
|
||||
players
|
||||
pub fn player_state(players: State<'_, PlayerState>) -> PlayerStateReply {
|
||||
let snapshot = players
|
||||
.last_transport
|
||||
.lock()
|
||||
.map(|snapshot| snapshot.clone())
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
// Taken, not read: the page that asks first owns the handover, and a
|
||||
// reload later must not restore a car queue that is long gone.
|
||||
let handover = players.handover.lock().ok().and_then(|mut h| h.take());
|
||||
PlayerStateReply { snapshot, handover }
|
||||
}
|
||||
|
||||
/// The transport, plus — once, on Android, after a car started the engine —
|
||||
/// the queue it was driving (`playback.rs` `handover`).
|
||||
#[derive(Serialize)]
|
||||
pub struct PlayerStateReply {
|
||||
#[serde(flatten)]
|
||||
pub snapshot: crate::playback::PlaybackSnapshot,
|
||||
pub handover: Option<crate::resume::ResumePoint>,
|
||||
}
|
||||
|
||||
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
|
||||
@@ -5502,7 +5801,7 @@ fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) {
|
||||
type DwmSet =
|
||||
unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32;
|
||||
unsafe {
|
||||
let dwm = LoadLibraryA(b"dwmapi.dll | ||||