From 652465910dcdcc761aa2e078ff1a82625e2a0aa5 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 4 Oct 2026 22:46:00 +0200 Subject: feat(android): music keeps playing with the screen off While a track plays, the page asks the shell to stay awake (playback:keep-alive): on Android the cast's foreground service and visible WebView, with a notification; on desktop a power save blocker. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-android/README.md | 4 +++- .../app/src/main/assets/bridge/meshbay-bridge.js | 6 ++++++ .../main/kotlin/org/meshbay/client/MainActivity.kt | 25 +++++++++++++++------- .../kotlin/org/meshbay/client/bridge/Channels.kt | 2 ++ .../kotlin/org/meshbay/client/cast/CastService.kt | 10 +++++---- .../org/meshbay/client/shell/ShellWebView.kt | 2 +- 6 files changed, 35 insertions(+), 14 deletions(-) (limited to 'packages/meshbay-android') diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md index 85e2406..bfd82cf 100644 --- a/packages/meshbay-android/README.md +++ b/packages/meshbay-android/README.md @@ -24,7 +24,9 @@ found and driven through the platform cast SDK with the default media receiver. While a cast runs, a media-playback foreground service holds the CPU and the Wi-Fi, and the WebView is kept reported visible — without that, Chromium freezes the page 60 s after the screen goes off. Where play services are -absent, the page is offered no cast at all. +absent, the page is offered no cast at all. Music playing on the phone itself +holds the same service and the same visibility, for as long as it plays +(`playback:keep-alive`), so the next track still loads with the screen off. ```bash # needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties) diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index 4755531..7ace33b 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -162,6 +162,12 @@ chromecastPlay: () => call('cast:chromecast:play'), } } : {}), + // Music is playing: keep the process, the Wi-Fi and the page awake with + // the screen off, behind a notification, until told otherwise. + playback: { + keepAlive: (on) => call('playback:keep-alive', on === true), + }, + // Where downloads go, chosen once. A display name comes back, never a URI. folder: { choose: () => call('folder:choose'), diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index ea31bca..fc446b9 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -56,6 +56,8 @@ class MainActivity : Activity() { try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null } } private var shim: ScriptHandler? = null + private var casting = false + private var playing = false private var fullscreen: View? = null private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null @@ -80,11 +82,11 @@ class MainActivity : Activity() { startActivity = { intent -> runOnUiThread { startActivity(intent) } }) // A process killed mid-download left unfinished files; nothing else will. Thread { saves.cleanUpAfterAKilledProcess() }.start() - cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting(on) } }, + cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting = on; keepAlive() } }, tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, - cast = cast) + cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() @@ -228,14 +230,20 @@ class MainActivity : Activity() { } /** - * A cast keeps the process, the Wi-Fi and the page alive with the screen - * off (spike S-2a, scenario F): the foreground service holds the first two, - * the WebView reported visible holds the third. + * A cast, or music playing here, keeps the process, the Wi-Fi and the page + * alive with the screen off (spike S-2a, scenario F): the foreground service + * holds the first two, the WebView reported visible holds the third. */ - private fun casting(on: Boolean) { + private fun keepAlive() { + val on = casting || playing web.keepVisible = on val service = Intent(this, CastService::class.java) - if (on) startForegroundService(service) else stopService(service) + if (!on) { stopService(service); return } + service.putExtra(CastService.EXTRA_TEXT, + if (casting) "Casting on this Wi-Fi" else text.get("music.queue_title", channels.locale)) + // Refused from the background on Android 12+; the page is then simply + // frozen with the screen off, as it was before this existed. + try { startForegroundService(service) } catch (e: IllegalStateException) { Log.w(Bridge.TAG, "keep-alive refused: $e") } } private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false } @@ -283,7 +291,8 @@ class MainActivity : Activity() { } override fun onDestroy() { - if (::cast.isInitialized && cast.relay.active) { cast.relay.stop(); casting(false) } + if (::cast.isInitialized && cast.relay.active) cast.relay.stop() + if (casting || playing) { casting = false; playing = false; keepAlive() } if (::web.isInitialized) { root.removeView(web); web.destroy() } super.onDestroy() } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index 6992cdb..7542fa1 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -25,6 +25,7 @@ class Channels( private val keys: KeyChannels? = null, private val saves: SaveSinks? = null, private val cast: CastChannels? = null, + private val onPlayback: (Boolean) -> Unit = {}, ) { @Volatile var locale = "en" private set @@ -34,6 +35,7 @@ class Channels( "hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1)) "ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray()) "ui:locale" -> setLocale(args.optString(0, "")) + "playback:keep-alive" -> { onPlayback(args.optBoolean(0, false)); true } // Where downloads go, chosen once; a display name, never a URI. "folder:choose" -> saves().chooseFolder() diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt index eaa471f..3ea8f92 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt @@ -14,8 +14,9 @@ import android.os.IBinder import android.os.PowerManager /** - * Keeps a cast alive with the screen off: a media-playback foreground service - * with a partial wake lock and a Wi-Fi lock, for as long as the relay runs. + * Keeps a cast, or music playing on the phone itself, alive with the screen + * off: a media-playback foreground service with a partial wake lock and a + * Wi-Fi lock, for as long as either runs. * * Not sufficient alone — measured (spike S-2a): Chromium freezes a hidden page * 60 s after the screen goes off whatever the process importance, and the @@ -31,12 +32,12 @@ class CastService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val nm = getSystemService(NotificationManager::class.java) - nm.createNotificationChannel(NotificationChannel(CHANNEL, "Casting", NotificationManager.IMPORTANCE_LOW)) + nm.createNotificationChannel(NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)) val open = PendingIntent.getActivity(this, 0, packageManager.getLaunchIntentForPackage(packageName), PendingIntent.FLAG_IMMUTABLE) val n = Notification.Builder(this, CHANNEL) .setContentTitle(intent?.getStringExtra(EXTRA_TITLE) ?: "MeshBay") - .setContentText("Casting on this Wi-Fi") + .setContentText(intent?.getStringExtra(EXTRA_TEXT) ?: "Casting on this Wi-Fi") .setSmallIcon(android.R.drawable.ic_media_play) .setContentIntent(open) .setOngoing(true) @@ -65,6 +66,7 @@ class CastService : Service() { private const val CHANNEL = "cast" private const val ID = 7 const val EXTRA_TITLE = "title" + const val EXTRA_TEXT = "text" // A bound on the wake lock, not on the cast: a process that is killed // without stopping the service must not hold the CPU for ever. private const val MAX_HOLD_MS = 6L * 3600 * 1000 diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt index 92a186f..731da69 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt @@ -8,7 +8,7 @@ import android.webkit.WebView * While `keepVisible` is set, the WebView is told its window stayed visible * when the screen turns off. Chromium then never marks the page hidden, and * its freeze of hidden pages — exactly 60 s after hiding, measured (spike - * S-2a C) — never starts. Set only while a cast runs: a page that is never + * S-2a C) — never starts. Set only while a cast runs or music plays: a page that is never * hidden is never throttled, which is the battery cost the freeze exists to * avoid. */ -- cgit v1.2.3