aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md2
-rw-r--r--docs/USERGUIDE.md5
-rw-r--r--packages/meshbay-android/README.md4
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js6
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt29
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt2
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/cast/CastService.kt10
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt2
-rw-r--r--packages/meshbay-client/package.json2
-rw-r--r--packages/meshbay-client/src/main.js17
-rw-r--r--packages/meshbay-client/src/preload.js5
-rw-r--r--packages/meshbay-common/pyproject.toml2
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py2
-rw-r--r--packages/meshbay-hub/pyproject.toml2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/__init__.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js86
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js10
-rw-r--r--packages/meshbay-hub/tests/test_android_cast.py10
-rw-r--r--packages/meshbay-node/pyproject.toml2
-rw-r--r--packages/meshbay-node/src/meshbay_node/__init__.py2
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"