diff options
21 files changed, 168 insertions, 36 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index e638f9b..525c5c3 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -17,7 +17,7 @@ > it. §13 is the register of those labels. > > Wire versions at the time of writing: **MNP 6.0** (oldest peer accepted 4.0), -> **MHP 0.1**, packages **0.17.0**. The normative source for the wire format is +> **MHP 0.1**, packages **0.18.0**. The normative source for the wire format is > `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol > serves, not its byte layout. diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 1790475..d6eab26 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -974,8 +974,9 @@ Better to know now than to go looking for it: - **The Android application is not released yet.** It works — groups, chat, films, downloads, casting — but is built and installed by hand and signed with a development key, so there is no store page and no update channel. The - back button and the lock screen do not drive it yet. A phone browser works - too. + back button and the lock screen do not drive it yet. Music keeps playing + with the screen off, from one track to the next, with a notification shown + while it plays. A phone browser works too. - **A node cannot be hosted on Android**, and is not planned to be. - **Hubs do not talk to each other yet.** Everyone in a group needs an account on the same hub. 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 a781350..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() @@ -98,7 +100,9 @@ class MainActivity : Activity() { domStorageEnabled = true // IndexedDB and localStorage: session, resume positions allowFileAccess = false allowContentAccess = false - mediaPlaybackRequiresUserGesture = true + // The page is our own bundled UI. With `true`, WebView wants play() inside the tap + // handler itself, and the music player only calls it after fetching the track. + mediaPlaybackRequiresUserGesture = false setSupportMultipleWindows(false) mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW } @@ -226,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 } @@ -281,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/package.json b/packages/meshbay-client/package.json index 80c021f..8a1fec7 100644 --- a/packages/meshbay-client/package.json +++ b/packages/meshbay-client/package.json @@ -1,6 +1,6 @@ { "name": "meshbay-client", - "version": "0.17.0", + "version": "0.18.0", "description": "MeshBay desktop client — the interface ships with the application, not from the hub", "license": "AGPL-3.0-or-later", "author": "MeshBay Team <team@meshbay.org>", 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-common/pyproject.toml b/packages/meshbay-common/pyproject.toml index 188ccf6..f8035ba 100644 --- a/packages/meshbay-common/pyproject.toml +++ b/packages/meshbay-common/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-common" -version = "0.17.0" +version = "0.18.0" description = "MeshBay shared cryptographic primitives and protocol types" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 2a58326..3ffb2b7 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -1,6 +1,6 @@ """MeshBay common — shared crypto primitives and protocol types.""" -__version__ = "0.17.0" +__version__ = "0.18.0" # 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are # additive — an 0.1 peer sends no `before` and gets the newest page, which is # what it wanted — so this is a MINOR bump, not a MAJOR one. diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml index 7f1154c..c927242 100644 --- a/packages/meshbay-hub/pyproject.toml +++ b/packages/meshbay-hub/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-hub" -version = "0.17.0" +version = "0.18.0" description = "MeshBay Hub — identity authority and group registry server" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py index 4d3d6dd..cf6868d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/__init__.py +++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py @@ -1,3 +1,3 @@ """MeshBay Hub — identity authority and group registry.""" -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index a1afe38..2a3efaf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -88,7 +88,7 @@ async def hub_pubkey(): # reads MBK1/MBK2. An older client would still write them, and every such # bundle is one no browser of the account can open again. MIN_CLIENT_VERSION = "0.17.0" -RECOMMENDED_CLIENT_VERSION = "0.17.0" +RECOMMENDED_CLIENT_VERSION = "0.18.0" @router.get("/version") 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 e8124fa..e97ce00 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). @@ -217,6 +218,13 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue // one of them being off is a property of that group rather than of each of // its tracks in turn — see advancePastFailure below (docs/playlists.md §11.3). const downGroupsRef = useRef(new Set()); + // When the page last came back from hidden. A phone with its screen off + // drops every connection at once, so a track that fails for lack of one + // while the page sleeps, or just after it wakes, says nothing about its + // group: it is retried rather than skipped (see the load effect below). + const wokeAtRef = useRef(0); + const [retryNonce, setRetryNonce] = useState(0); + const WAKE_GRACE_MS = 30000; const currentTrack = tracks[order[pos]] || null; @@ -272,6 +280,23 @@ 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 or loads: without it a phone freezes the + // page a minute after the screen goes off, and the next track never loads. + // Let go a few seconds late, because skipping a track that failed passes + // through "not playing", and Android refuses to take the hold back from the + // background: a playlist would stop at its first bad file. + const keepAliveRef = useRef({ held: false, timer: null }); + const active = playing || loading; + useEffect(() => { + const k = keepAliveRef.current; + if (!active) return; + clearTimeout(k.timer); + if (!k.held) { k.held = true; playback.keepAlive(true); } + return () => { + k.timer = setTimeout(() => { k.held = false; playback.keepAlive(false); }, 5000); + }; + }, [active]); + // 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 @@ -345,7 +370,11 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue } if (!transport) throw groupDown(); if (!transport.connected) await transport.waitForReconnect(); - if (!transport.connected) throw groupDown(); + if (!transport.connected) { + const e = groupDown(); + e.transport = transport; + throw e; + } let downloadId = entry.id; let downloadSize = entry.size; @@ -441,8 +470,46 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue // effect above (pos/order/tracks all just changed together) and also // after skipNext/skipPrev/onEnded update `pos` alone. useEffect(() => { + const onVisibility = () => { + if (document.visibilityState === 'visible') wokeAtRef.current = Date.now(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, []); + + const asleepOrJustWoke = () => document.visibilityState === 'hidden' + || Date.now() - wokeAtRef.current < WAKE_GRACE_MS; + + // Retries the current track once the page is back and a connection may be: + // the screen coming on, the network coming back, or the track's own + // transport finishing its reconnect, whichever comes first. A visible page + // also retries on a timer, so a failure that outlives the grace window + // ends on the ordinary error path instead of a spinner nobody can clear. + const retryWhenAwake = (myToken, transport) => { + let stop = () => {}; + let timer = null; + const fire = () => { + stop(); + if (loadTokenRef.current === myToken) setRetryNonce((n) => n + 1); + }; + const onVisibility = () => { if (document.visibilityState === 'visible') fire(); }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('online', fire); + const unsubscribe = transport ? transport.addReconnectListener(fire) : () => {}; + if (document.visibilityState === 'visible') timer = setTimeout(fire, 10000); + stop = () => { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('online', fire); + unsubscribe(); + }; + if (transport && transport.connected) fire(); + }; + + useEffect(() => { if (!currentTrack) return; const myToken = ++loadTokenRef.current; + let waiting = false; setLoading(true); setError(''); (async () => { @@ -459,17 +526,28 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue prefetchNext(pos); } catch (err) { if (loadTokenRef.current !== myToken) return; + // Autoplay refused (no recent tap): the track is loaded and fine, it + // waits for the play button. Not an error, and no reason to skip it. + if (err && err.name === 'NotAllowedError') { setPlaying(false); prefetchNext(pos); return; } + if (err && err.isGroupDown && asleepOrJustWoke()) { + // Our own connection is what went, not the group: keep the spinner + // and this track, and try it again once we are back. + waiting = true; + setPlaying(false); + retryWhenAwake(myToken, err.transport); + return; + } setError(err.message || String(err)); setPlaying(false); // `fetchTrackBlob` throws `err_transport` when no node answered for // this track's group; anything else is about the file itself. advancePastFailure(err.isGroupDown ? currentTrack.groupId : null); } finally { - if (loadTokenRef.current === myToken) setLoading(false); + if (loadTokenRef.current === myToken && !waiting) setLoading(false); } })(); // eslint-disable-next-line - }, [currentTrack && currentTrack.id]); + }, [currentTrack && currentTrack.id, retryNonce]); // Belt and suspenders alongside the catch block above: `.play()` rejecting // is the common path for a source the browser can't decode at all, but a @@ -524,7 +602,7 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue const audio = audioRef.current; if (!audio) return; if (playing) { audio.pause(); setPlaying(false); } - else { audio.play().then(() => setPlaying(true)).catch(() => {}); } + else { audio.play().then(() => { setError(''); setPlaying(true); }).catch(() => {}); } }, [playing]); // Reshuffling keeps the currently playing track in place — turning shuffle 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 diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml index ce1f718..e505048 100644 --- a/packages/meshbay-node/pyproject.toml +++ b/packages/meshbay-node/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-node" -version = "0.17.0" +version = "0.18.0" description = "MeshBay Node — local file host, streaming server, and group daemon" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-node/src/meshbay_node/__init__.py b/packages/meshbay-node/src/meshbay_node/__init__.py index c3c18ff..c58bc8a 100644 --- a/packages/meshbay-node/src/meshbay_node/__init__.py +++ b/packages/meshbay-node/src/meshbay_node/__init__.py @@ -1,3 +1,3 @@ """MeshBay Node — local file host, streaming server, and group daemon.""" -__version__ = "0.17.0" +__version__ = "0.18.0" |