diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-04 22:46:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-04 22:46:00 +0200 |
| commit | 652465910dcdcc761aa2e078ff1a82625e2a0aa5 (patch) | |
| tree | 2378741f73ef0faaf138689ea366febef905e85c /packages | |
| parent | 859d7ce71795dcfd5701cb08a684dcef23e7c4c0 (diff) | |
| download | meshbay-652465910dcdcc761aa2e078ff1a82625e2a0aa5.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
11 files changed, 81 insertions, 20 deletions
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. */ diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 4c8707c..fa4f3ec 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -20,8 +20,8 @@ 'use strict'; -const { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage, - shell, Tray } = +const { app, BrowserWindow, dialog, ipcMain, Menu, powerSaveBlocker, protocol, + safeStorage, shell, Tray } = require('electron'); const { execFile, spawn } = require('node:child_process'); const crypto = require('node:crypto'); @@ -2304,6 +2304,19 @@ function registerBridge() { return true; }); + // The screen may sleep while music plays; the machine may not, or the + // connection to the node goes with it. + let playbackBlocker = null; + handle('playback:keep-alive', async (_event, on) => { + if (on === true && playbackBlocker === null) { + playbackBlocker = powerSaveBlocker.start('prevent-app-suspension'); + } else if (on !== true && playbackBlocker !== null) { + powerSaveBlocker.stop(playbackBlocker); + playbackBlocker = null; + } + return true; + }); + handle('cast:status', async () => ({ active: castRelay.active, url: castRelay.url, diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 43cf317..3af6c10 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -165,6 +165,11 @@ contextBridge.exposeInMainWorld('meshbay', { serviceMode: (action) => ipcRenderer.invoke('node:service-mode', action), }, + // Music is playing: the machine must not idle-sleep under it. + playback: { + keepAlive: (on) => ipcRenderer.invoke('playback:keep-alive', on === true), + }, + // LAN cast relay. The main process runs a local HTTP server and the // renderer feeds it decrypted segments. A Chromecast or Smart TV on the // same Wi-Fi plays from the URL. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js index f0f7c47..500f518 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -6,6 +6,7 @@ import { t } from './i18n.js'; import { Icon } from './icon.js'; import { useStickyBand } from './sticky.js'; import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; +import { playback } from './platform.js'; /** * The Music app's persistent player bar (docs/MESHBAY_DESIGN.md §9.8). @@ -279,6 +280,15 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue for (const { url } of blobCacheRef.current.values()) URL.revokeObjectURL(url); }, []); + // Held for as long as a track plays, through the advance to the next one: + // without it a phone freezes the page a minute after the screen goes off, + // and the next track never loads. + useEffect(() => { + if (!playing) return; + playback.keepAlive(true); + return () => playback.keepAlive(false); + }, [playing]); + // Screen Wake Lock, opt-in only (Settings → music_keep_screen_on) and only // while a track is actually playing — off by default because the ordinary // expectation, matching Spotify/Deezer, is that the phone locks on its own diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index c131495..0174edc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -204,6 +204,16 @@ export const folder = { async forget() { return bridge && bridge.folder ? bridge.folder.forget() : false; }, }; +/** + * Music is playing: the shell keeps what playback needs awake (on a phone, the + * page itself with the screen off). A no-op in a browser. + */ +export const playback = { + keepAlive(on) { + if (bridge && bridge.playback) bridge.playback.keepAlive(on).catch(() => {}); + }, +}; + /** Pick a directory to add as a group root. Returns { path, name } or null. */ export const rootPicker = { available: Boolean(bridge && bridge.rootPicker), diff --git a/packages/meshbay-hub/tests/test_android_cast.py b/packages/meshbay-hub/tests/test_android_cast.py index a346d15..bc5264e 100644 --- a/packages/meshbay-hub/tests/test_android_cast.py +++ b/packages/meshbay-hub/tests/test_android_cast.py @@ -111,11 +111,13 @@ def test_relay_calls_keep_their_order(): SRC / "bridge" / "Channels.kt") -def test_screen_off_survival_is_switched_on_only_while_casting(): +def test_screen_off_survival_is_switched_on_only_while_casting_or_playing(): activity = _read(SRC / "MainActivity.kt") - casting = activity.split("private fun casting(on: Boolean)", 1)[1].split("\n }", 1)[0] - assert "web.keepVisible = on" in casting - assert "startForegroundService(service) else stopService(service)" in casting + keep = activity.split("private fun keepAlive()", 1)[1].split("\n }\n", 1)[0] + assert "val on = casting || playing" in keep + assert "web.keepVisible = on" in keep + assert "if (!on) { stopService(service); return }" in keep + assert "startForegroundService(service)" in keep assert activity.count("keepVisible =") == 1, "the page is kept visible from one place only" view = _read(SRC / "shell" / "ShellWebView.kt") assert "var keepVisible = false" in view |